Dissecting new generic constraints in C# 7.3

Sergey Tepliakov

Sergey

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:

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:

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:

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.

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

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:

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

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

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 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.

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

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.
Sergey Tepliakov
Sergey Tepliakov

Senior Software Engineer, Tools for Software Engineers

Follow Sergey   

No Comments.