Skip to content

C#: Represent Variant as its own type instead of System.Object #3837

Description

@neikeq

Currently, we represent Variant in C# with the object type (System.Object). We convert Variant values to the managed type that best matches the Variant type and return the result as object.

This has the following downsides:

  1. Converting to and from native Variant results in boxing of basic value types (like int, or Vector2).
  2. Converting object to a native Variant (ConvertManagedObjectToVariant):
    2.1. Requires runtime type checking.
    2.2. In the case of System.Collections.Generic types, we need to box value type items.
    2.3. Easy to attempt to create/marshal a Variant with an unsupported type. It's not obvious which types are supported and this results in a nil Variant and a runtime error.
  3. Converting a native Variant to object (ConvertVariantToManagedObjectOfType):
    3.1. Requires runtime type checking.
    3.2. Requires reflection when dealing with generic types (e.g., to access the constructor dynamically).
  4. Confusing InvalidCastException when casting the object to int.
    Variant stores 64-bit integers, so the boxed value is of type long. You can't cast a boxed long to int. You need to unbox it first and then do the cast:
    object boxed = (long)10;
    _ = (int)boxed; // Exception. Not casting, rather unboxing with the wrong type.
    _ = (int)(long)boxed; // Good. Unbox with the right type and then cast.

I propose solving these problems with the addition of a C# Variant type that replaces object in this regard.

Layout

public struct Variant
{
    short _type;
    godot_variant_basic_data _data;
    object _obj;
}

The _data field is a union that stores all basic value types. That is, types that do not require disposal:

// Variant types that don't need disposal
Bool, Int, Float
Vector2, Vector2i
Rect2, Rect2i
Vector3, Vector3i
Plane
Quaternion
Color
Rid

✔️ We don't need to box the values in the _data field (1.), it's a union of value types.

The content of the _obj field depends on whether we want lazy marshaling or not.

Lazy marshaling

We assign the _obj field with a wrapper containing the native Variant itself. No marshaling is done until the user actually attempts a type conversion.

This results in an extra allocation (the wrapper) if a conversion is eventually requested.
The wrapper (like other Godot type disposables) also has the overhead of needing to be registered/unregistered for disposal on hot-reload and app exit.

This version wins when dealing with many Variants whose value ends up not being accessed. I don't think this is very common, so it doesn't seem to be worth the downsides.

Eager marshaling