Replies: 3 comments 7 replies
-
|
Boxing is barely the tip of the iceberg when it comes to trying to implement type unions, to the extent that I don't think a container type is reasonable without intimate knowledge and support for it in the runtime. Attempting to emulate "transitivity"/"interchangeability" would be a substantially larger barrier and one that I don't think belongs in the language. |
Beta Was this translation helpful? Give feedback.
-
|
This is supposed to be a language feature. See https://github.com/dotnet/csharplang/blob/main/proposals/TypeUnions.md#specialized---union-structs |
Beta Was this translation helpful? Give feedback.
-
|
It would be nice if it also supports explicit null checks like |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Motivation
At the moment,
Nullable<T>is the only value type that has custom boxing rules, which require the runtime to have hardcoded handling of this type whenever boxing/unboxing occurs. With the recent focus on unions, I think it might be interesting to consider generalizing this special behaviour and extending it to user-defined value types. In practical terms, it means some (specially marked) value types could box/unbox to "arbitrary" objects, evennull. With a general mechanism, types mimickingNullable<T>could be created, andNullable<T>itself could be expressed in terms of this mechanism.Situation
At the moment, a simple union of primitive values could be implemented in this compact way:
New C# syntax or code generators could simplify writing or using such types as needed, but the runtime is still blind to what such a type encodes:
This could not be fixed in the language alone (like for
Nullable<T>), since the boxing may occur in generic types which are blind to the concrete type. In this situation, it might be argued that this is not even desirable, but it only takes a little modification to change that:Now
NumberIntandNumberFloatare concrete realizations ofNumber, conceptually "deriving" from it while adding new members (this is also how tagged union structs might be implemented internally). However, the runtime still cannot see that these new types are special cases ofNumber:There are three options to solve this behaviour:
ValueUnion<T...>type which would be boxable to one of itsTs, and any such type would need to be expressed in terms of it. This essentially adds another special type alongsideNullable<T>whereby it can now be conceptualized asValueUnion<T, null>.The last option is enough to cover most existing cases, and even though it might be very strange to consider now, there were a few situations where something similar has already happened ‒ types like
TypedReferencegot formalized intoref struct, andIDynamicInterfaceCastablewas added to affect interface casts in a way only proxying in .NET Framework could do before, so many assumptions about boxing or castability of types got broken a handful of times already.Implementation
There are two options I can think of to solve this, one more constrained but generally sufficient, and one more powerful but potentially hard to reason about:
Option A ‒ with attributes and fields, safe and predictable
With this approach, a struct may only box to a value of one of the fields it contains (no indirection through references), hereby named the active field. Which field is active is determined by another field storing its identifier. As an example:
When such a type is encountered, the runtime has to:
[BoxableFieldsContainer]until none remains, and then getting all fields with[BoxableField]on the last visited type. Additionally, no field marked with[BoxableFieldsContainer]may have a type that is a type parameter. Specified this way, this always results in a single type holding all potentially active fields ‒ the converse would mean potentially having to encode paths to those fields, not just individual fields, in the selector.[BoxableFieldsContainer]) but this time, the first type that stores a[BoxedFieldSelector]field is enough (and there must be only one such field). Alternatively, if there is no selector field and there is only one potentially active field, the selector field is implied to always identify that field.TypeLoadException).These pieces of information could be stored alongside the type itself in memory. Since no indirection is allowed, a field found in this process can be stored easily as an offset from the beginning of the data alongside the type of the field.
When boxing an instance of a value type marked with
[IndirectlyBoxable], the runtime has to:RuntimeFieldHandlecould be used to identify any field on a type (the one holding the potentially active fields).[BoxableField(0)],[BoxableField(1)], etc.boolcould be used too (more about this later).InvalidCastException.(object)activeFieldwas used.This indicates recursion during boxing (an
[IndirectlyBoxable]type boxing through another[IndirectlyBoxable]type), but that is fine in this case, because it is always bounded by the "depth" of the final (normally boxable) field, since it too must be contained in the original struct's data.During unboxing, the performed steps are:
default-initialize the unboxed type.readonly).readonly).It is possible (and obvious) that the values of all other fields are not preserved during boxing/unboxing. This is expected and necessary ‒ any important information that needs to be preserved must be stored in the active field.
Handling
default/nullAn indirectly boxable type could be nullable under one of these conditions:
[IndirectlyBoxable(AllowNull = true)], making it explicitly nullable.default. Consequently, no field may be[BoxableField(0)], and the selector field is allowed to bebool.default, the boxing results innull.null, the result of the unboxing is always simplydefaultof the unboxed-to type.null), making it implicitly nullable.null.nullto such a type activates any such nullable potentially active field.This has these consequences:
v is nullis a sensible condition for a value of such type and is equivalent to(object)v is null.structconstraint (likeNullable<T>).nullfrom multiple distinguishable states, even when other fields are ignored ‒ thedefaultstate for explicitly nullable values, and when the active field boxes tonull.nullcomes from. Callingv1.Equals(v2)should result intruewhen(object)v1 is nulland(object)v2 is null(the default implementation should take it into account, and a potentialoverrideis responsible for ensuring this).An indirectly boxable value type whose set of potentially active fields includes a reference-typed field is always nullable.
With these rules,
Nullable<T>could be easily expressed as:Summary
Pros:
AllowNull = true), and may not allow passing the type through thestructconstraint if so.(T)vwhenTis assignable from the type of a potentially active field, equivalent to(T)(object)v, or the converse (converting from a value assignable to one of its potentially active fields).switchto identify the active field, and everything else happens through it.Nullable<T>is no longer exceptional in any way and is treated as just a regular explicitly nullable value type.ref structmay be made indirectly boxable when all its potentially active fields are boxable (not non-boxableref structs).ref structcould hold a cachedref structbut be freely boxable through another field whose value could be used, after unboxing, to restore the non-boxableref structfield.ref structvariable crossesasync/yieldboundary, preserving its state.Cons:
Nullable<T>. Additional work needs to be done on the compiler's side to ensure those types are not passed throughwhere T : struct, unless the runtime prohibits such nullable types (for the moment).RuntimeHelpers.Equalsshould get a generic alternative to avoid (potentially destructive) boxing, andwhere T : ValueType, new()should be allowed by C# to be able to pick all value types.Option B ‒ with an interface and no limits
Akin to
IDynamicInterfaceCastable, value types could implement a new interface,IDynamicBoxable:The runtime's job is rather simple in this situation ‒ when boxing a value of this type,
Boxis called; when unboxing to this type,Unboxis called.Pros:
IDynamicBoxableinterface could be used/checked manually if needed.Cons:
isneeds to callBoxto determine success (unlike with option A, when knowing the field is enough for most cases).Boxis not allowed to produce any other type than defined here, and returning such would result inInvalidCastException.AllowNull = truecould be used here too to arrive at basically the same situation as with option A.With this option,
Nullable<T>can also be expressed:Beta Was this translation helpful? Give feedback.
All reactions