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.
-
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
NumberInt
andNumberFloat
are 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 itsT
s, 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
TypedReference
got formalized intoref struct
, andIDynamicInterfaceCastable
was 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:RuntimeFieldHandle
could be used to identify any field on a type (the one holding the potentially active fields).[BoxableField(0)]
,[BoxableField(1)]
, etc.bool
could be used too (more about this later).InvalidCastException
.(object)activeField
was 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
/null
An 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 simplydefault
of the unboxed-to type.null
), making it implicitly nullable.null
.null
to such a type activates any such nullable potentially active field.This has these consequences:
v is null
is a sensible condition for a value of such type and is equivalent to(object)v is null
.struct
constraint (likeNullable<T>
).null
from multiple distinguishable states, even when other fields are ignored ‒ thedefault
state for explicitly nullable values, and when the active field boxes tonull
.null
comes from. Callingv1.Equals(v2)
should result intrue
when(object)v1 is null
and(object)v2 is null
(the default implementation should take it into account, and a potentialoverride
is 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 thestruct
constraint if so.(T)v
whenT
is 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).switch
to 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 struct
may be made indirectly boxable when all its potentially active fields are boxable (not non-boxableref struct
s).ref struct
could hold a cachedref struct
but be freely boxable through another field whose value could be used, after unboxing, to restore the non-boxableref struct
field.ref struct
variable crossesasync
/yield
boundary, 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.Equals
should 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,
Box
is called; when unboxing to this type,Unbox
is called.Pros:
IDynamicBoxable
interface could be used/checked manually if needed.Cons:
is
needs to callBox
to determine success (unlike with option A, when knowing the field is enough for most cases).Box
is not allowed to produce any other type than defined here, and returning such would result inInvalidCastException
.AllowNull = true
could 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