Dissecting new generic constraints in C# 7.3

Sergey Tepliakov

During the last Build conference, Microsoft has announced the next version of Visual Studio with C# 7.3 support. This is yet another minor language update with some quite interesting features. The main change was related to generics, starting from C# 7.3 there 3 more constraints: unmanaged, System.Enum and System.Delegate.

The unmanaged constraint

The unmanaged constraint on generic type T enforces the type to be an ‘unmanaged’ struct that does not recursively contain reference type fields. The concept of ‘unmanaged types’ existed in the C# language for a long time and you can find this term in the C# language specification and in the official documentation, but now the compiler can actually enforce it.

This feature could be useful for low-level scenarios, like native-managed interoperability, but could be helpful for common enterprise scenarios as well. An instance of ‘unmanaged’ type T is convertible to T* which is convertible to byte* that opens some interesting possibilities. Let suppose you want to keep your data in a key-value store. To get a key you may consider using SHA1 or some other hashing algorithm, but you don’t want to implement the hashing logic for every type in the system.

Now you can implement this logic for all ‘unmanaged’ types in your application the following way:

public unsafe static byte[] ComputeHash<T>(T data) where T : unmanaged
{
    byte* bytes = (byte*)(&data);
    using (var sha1 = SHA1.Create())
    {
        var size = sizeof(T);
        using (var ms = new UnmanagedMemoryStream(bytes, size))
        {
            return sha1.ComputeHash(ms);
        }
    }
}

This code works perfectly fine because sizeof operator is actually designed for ‘unmanaged’ types even though there was no special constraint in the language to enforce the correctness of this operator at compile time.

The feature is relatively simple but there are some caveats:

  • Unmanaged types are different from ‘blittable types’. Blittable types have the same representation in both managed and unmanaged code. This means that some ‘unmanaged’ types are not blittable and some blittable types are not ‘unmanaged’. For instance, bool and char are ‘unmanaged’ types but they’re not blittable, because the underlying representation for both of these types could be “platform-specific”. On the other hand, strings could be blittable in some cases, but they do not belong to a new family of ‘unmanaged’ types because string is a reference type.
  • Existing generic types are not ‘unmanaged’. int?, (int, double), System.KeyValuePair<int, int> are not ‘unmanaged’ types because these generic types don’t have the ‘unmanaged’ constraint.
  • You still can’t safely use Marshal.SizeOf<T> when T is an ‘unmanaged’ type. Even though the documentation for Marhal.SizeOf<T> states that the method “Returns the size of an unmanaged type in bytes.”, it is not quite true today. Any enum is considered to be an unmanaged type but trying to use an enum type in Marshal.SizeOf<MyEnum>() will lead to a runtime error. For all other unmanaged types, this function works similar to sizeof operator.

The Enum constraint

The System.Enum constraint on type T enforces that the type is an enum. Enum types are not as ubiquitous as other primitive types, but this constraint still may be very useful in many scenarios.

For instance, you can solve issues with the existing API provided by System.Enum type:

public static TEnum[] GetValues<TEnum>() where TEnum : System.Enum
{
    return (TEnum[])Enum.GetValues(typeof(TEnum));
}
 

// BCL-based version
MyEnum[] values = (MyEnum[])Enum.GetValues(typeof(MyEnum));
            
// Type-safe version
MyEnum[] values2 = GetValues<MyEnum>();

But we can make a step forward and introduce a concept of EnumTraits – a special type that represents a metadata for a given enum type. For instance, we can get a min/max value, available values, and some other information:

public static class EnumTraits<TEnum> where TEnum : struct, Enum
{
    private static HashSet<TEnum> _valuesSet;
    static EnumTraits()
    {
        var type = typeof(TEnum);
        var underlyingType = Enum.GetUnderlyingType(type);
 
        EnumValues = (TEnum[])Enum.GetValues(typeof(TEnum));
        _valuesSet = new HashSet<TEnum>(EnumValues);
 
        var longValues =
            EnumValues
            .Select(v => Convert.ChangeType(v, underlyingType))
            .Select(v => Convert.ToInt64(v))
            .ToList();
 
        IsEmpty = longValues.Count == 0;
        if (!IsEmpty)
        {
            var sorted = longValues.OrderBy(v => v).ToList();
            MinValue = sorted.Min();
            MaxValue = sorted.Max();
        }
    }
 
    public static bool IsEmpty { get; }
    public static long MinValue { get; }
    public static long MaxValue { get; }
    public static TEnum[] EnumValues { get; }
 
    // This version is almost an order of magnitude faster then Enum.IsDefined
    public static bool IsValid(TEnum value) => _valuesSet.Contains(value);
}
 
enum EnumLong : long
{
    X = -1,
    Y = 1,
    Z = 2,
}
 
Console.WriteLine(EnumTraits<EnumLong>.MinValue); // -1
Console.WriteLine(EnumTraits<EnumLong>.MaxValue); // 2
Console.WriteLine(EnumTraits<EnumLong>.IsValid(0)); // False

We can go even further: in the static constructor we can look for a specific attribute that user may use to provide enum’s friendly name. We can check for duplicates and expose a property like HasDuplicates, we can check FlagsAttribute for an enum type and expose this information via IsFlagsEnum property. Or we can even implement a more efficient version of Enum.HasFlag that causes two boxing allocations per call.

As you probably know .NET Core 2.1 has a lot of performance improvements, including an optimized version of Enum.HasFlag. But unfortunately, not every application can benefit from these optimizations, so it makes sense to have a more efficient version of Enum.HasFlag for the other runtimes.

But this is not as simple as you would think. The Enum constraint does not allow you to perform arithmetic operations on enum’s instances.

public static long ToLong<TEnum>(TEnum value) where TEnum : System.Enum
{
    // Cannot convert 'TEnum' to 'long'
    return (long)value;
}

Every enum type implicitly implements IConvertible and you may try to leverage this:

public static long ToLong<TEnum>(TEnum value) where TEnum : System.Enum, IConvertible
{
    return value.ToInt64(provider: null);
}

This code works fine but causes boxing allocation for each invocation: the thing that we tried to avoid in the first place.

The only remaining option that I could think of is the code generation. Even though different enum instances could be of a different size (byte, short, int — the default enum’s underlying type, or long) the IL-code that is required to convert an integral value of different types to a specific target type is the same. For instance, the IL-code for converting byte, short or int to a long is the same: Conv_I8:

public static class EnumConverter
{
    public static Func<T, long> CreateConvertToLong<T>() where T : struct, Enum
    {
        var method = new DynamicMethod(
            name: "ConvertToLong",
            returnType: typeof(long),
            parameterTypes: new[] { typeof(T) },
            m: typeof(EnumConverter).Module,
            skipVisibility: true);
 
        ILGenerator ilGen = method.GetILGenerator();
 
        ilGen.Emit(OpCodes.Ldarg_0);
        ilGen.Emit(OpCodes.Conv_I8);
        ilGen.Emit(OpCodes.Ret);
        return (Func<T, long>)method.CreateDelegate(typeof(Func<T, long>));
    }
}

The implementation of an allocation-free version of HasFlags is fairly straightforward:

public static class EnumExtensions
{
    /// <summary>
    /// Allocation-free version of <see cref="Enum.HasFlag(Enum)"/>
    /// </summary>
    public static bool HasFlags<TEnum>(this TEnum left, TEnum right) where TEnum: struct, Enum
    {
        var fn = Converter<TEnum>.ConverterFn;
        return (fn(left) & fn(right)) != 0;
    }
 
    private class Converter<TEnum> where TEnum: struct, Enum
    {
        public static readonly Func<TEnum, long> ConverterFn = EnumConverter.CreateConvertToLong<TEnum>();
    }
}

Let’s compare this implementation with Enum.HasFlag and with a simple bitwise comparison.

                Method |       Mean |     Error |    StdDev | Scaled |    Gen 0 | Allocated | 
---------------------- |-----------:|----------:|----------:|-------:|---------:|----------:| 
           EnumHasFlag | 24.6219 ns | 0.4849 ns | 0.6798 ns |   1.00 | 152.3438 |  480005 B | 
        EnumExHasFlags |  7.4968 ns | 0.1374 ns | 0.1285 ns |   0.30 |        - |       0 B | 
 EnumBitwiseComparison |  0.3752 ns | 0.0074 ns | 0.0134 ns |   0.02 |        - |       0 B |

As you can see, the optimized version is 3 times faster than the original Enum.HasFlagimplementation and is allocation free. It is still way slower than a simple bit-wise comparison that consists only of a few assembly instructions.

The oddities of the Enum constraint

There is one very interesting caveat with the Enum constraint: the constraint does not imply but itself that the T is a struct. Moreover, you can actually combine the Enumconstraint with the class constraint:

// The only valid TEnum type is `System.Enum`
public static void Foo<TEnum>() where TEnum : class, Enum
{ }
        static void Main(string[] args)
        {
// Error 'EnumLong' must be a class
Foo<EnumLong>();
// Ok, but why would you do that?!?!?
Foo<System.Enum>();

The combination where TEnum: class, Enum makes no sense and the only reasonable way to use the Enum constraint is to use it with the struct constraint: where TEnum: struct, Enum.

The Delegate constraint

The System.Delegate constraint on type T enforces that the type is a delegate. I think this is the least useful constraint from all the new ones, but I can definitely see the value of it in some scenarios.

In the previous section, we’ve used code generation to work-around some restrictions for the Enum constraint. The solution was relatively simple but it may be improved by creating a facade class with the new Delegate constraint.

/// Non-generic facade for creating dynamic methods for a given delegate type
/// </summary>
public static class DynamicMethodGenerator
{
    public static DynamicMethodGenerator<TDelegate> Create<TDelegate>(string name)
        where TDelegate : Delegate
        => new DynamicMethodGenerator<TDelegate>(name);
}
 
/// <summary>
/// Generator class for constructing delegates of type <typeparamref name="TDelegate"/>.
/// </summary>
public sealed class DynamicMethodGenerator<TDelegate> where TDelegate : Delegate
{
    private readonly DynamicMethod _method;
 
    internal DynamicMethodGenerator(string name)
    {
        MethodInfo invoke = typeof(TDelegate).GetMethod("Invoke");
 
        var parameterTypes = invoke.GetParameters().Select(p => p.ParameterType).ToArray();
 
        _method = new DynamicMethod(name, invoke.ReturnType, 
            parameterTypes, restrictedSkipVisibility: true);
    }
 
    public ILGenerator GetILGenerator() => _method.GetILGenerator();
 
    public TDelegate Generate()
    {
        return (TDelegate)_method.CreateDelegate(typeof(TDelegate));
    }
}

This simple class drastically simplifies the code generation and makes it less error-prone:

public static Func<T, long> CreateConvertToLong<T>() where T : struct, Enum
{
    var generator = DynamicMethodGenerator.Create<Func<T, long>>("ConvertToLong");
 
    ILGenerator ilGen = generator.GetILGenerator();
 
    ilGen.Emit(OpCodes.Ldarg_0);
    ilGen.Emit(OpCodes.Conv_I8);
    ilGen.Emit(OpCodes.Ret);
    return generator.Generate();
}

Conclusion

None of the new constraints are game changing features but they definitely can be quite useful in real life scenarios.

  • The unmanaged constraint is useful for native-managed interop scenarios as well as for more widely use cases like serialization/deserialization or hash computations.
  • The Enum constraint helps to work-around existing issues with utility methods from System.Enum type, or for creating application-specific “enum traits” types for custom validation, enum-ToString conversion etc.
  • The Delegate constraint is helpful for code generation but may be helpful in other cases as well. For instance, when dealing with expression trees, for creating generic event handlers or for implementing command pattern.

0 comments

Discussion is closed.

Feedback usabilla icon