C# 10.0 improves handling of nullable references in generic types - no more AllowNull
A couple of years ago I blogged about how C# lets us transcend the type system with the AllowNull attribute. At the time I noted that in one of these case—generic code that wants to allow nullability when using type parameters—that the obvious solution turned out to work. You'd think that you could just write, say T?
, but the compiler would reject that. Well in C# 10.0, it does work!
(Strictly speaking this was fixed in C# 9.0 and .NET 5.0. However, that wasn't an LTS release. For most of my work, non-LTS releases aren't really a viable target, so for me, this feature effectively became available with C# 10.0 and .NET 6.0.)
The non-generic case
To explain the scenario at hand, let's first look at some non-generic code to understand what we're looking to express. Here's an interface from the .NET runtime libraries:
public interface IComparable
{
int CompareTo(object? obj);
}
This expresses ordered comparison, enabling you to determine whether one value is less then, equal to, or greater than some other value.
CompareTo
's only argument is of type object?
, indicating that implementations of this interface are required to be able to cope with null inputs. Before nullable reference types were introduced in C# 8.0, this interface had to be declared with just object
here but it still worked the same way: implementations were required to accept null, it's just that this wasn't previously formally captured in the interface definition.
The generic case, before nullable reference types
IComparable
was introduced before generics, which is why the argument is of type object
. This caused a problem for value types. For example, all the built-in numeric types implement IComparable
, but if you pass an int
to this version of CompareTo
, the compiler would generate code to box the value. That enabled an int
to be passed as an object
but it was inefficient. Also, the use of object
failed to convey the idea that this interface is intended for comparing like with like. Generics enabled both of these problems to be addressed:
public interface IComparable<in T>
{
int CompareTo(T other);
}
What does this say about nulls? Nothing much. If you supply a value type as the type argument, e.g., IComparable<int>
, then it's not possible to pass a null to CompareTo
because null
is not a valid value for int
. But if you use a reference type, this definition doesn't tell us what to expect; we had to look at the documentation to know that implementations for which T
was a reference type were expected to accept a null input. Back before nullable reference types were introduced in C# 8.0, there was no way to express this formally in a method signature.
C# 8.0 era nullability
As I showed in my blog from a couple of years back, in C# 8.0 and .NET Core 3.1, the interface definition was modified with the addition of an AllowNull
attribute:
public interface IComparable<in T>
{
int CompareTo([AllowNull] T other);
}
This states explicitly that in cases where the type argument supplied for T
is a reference type, nulls are allowed. For example, we might write either IComparable<string?>
or IComparable<string>
. If it weren't for that [AllowNull]
, the CompareTo
method would accept nulls only in the first case (because the type argument is the nullable string?
). But because of that [AllowNull]
, if the type argument is a non-nullable reference type we can pass null to CompareTo
. So we're allowed to do that even with IComparable<string>
.
In that old blog I explained why the more obvious approach of writing T?
wouldn't work. That would mean quite different things in cases where the type parameter was a value type. Take IComparable<int>
for example. The type int?
is a very different sort of a thing from string?
. The types string
and string?
have identical runtime representations, the only difference being that the C# compiler tries its best to stop you from assigning a null value into a string?
. But int
and int?
have completely different runtime representations.
A "nullable" value type is really a syntactic shorthand for Nullable<T>
, which is actually a value type. The C# compiler can end up generating quite different code for Nullable<T>
compared to what it produces for otherwise identical-looking source code that uses some nullable reference type (because Nullable<T>
gets special recognition from the compiler), so if we were allowed to write T?
, the compiler wouldn't always know what to do when compiling generic code.
And yet...
C# 10.0 era nullability
If you look at the definition of IComparable<T>
in a C# 10 project, you'll see it looks like this:
public interface IComparable<in T>
{
int CompareTo(T? other);
}
That seems like a much more natural thing to write. But what about the problem I described with value types? The design for this feature (officially called Unconstrained type parameter annotations) states that when a method argument using a type parameter is declared as nullable in this way, it is equivalent to the older way of using [AllowNull]
.
The effect is that while this does what you'd expect for reference types, it is essentially ignored when the type argument is a value type. This becomes clear when you try to implement this interface for specific value and reference types, as these examples show:
class CI : IComparable<int>
{
public int CompareTo(int other)
{
return 42.CompareTo(other);
}
}
class CS : IComparable<string>
{
public int CompareTo(string? other)
{
return 42.CompareTo(other);
}
}
This seems a little surprising in the IComparable<int>
case—it just looks like the ?
has gone missing. But in practice this aligns with how IComparable<int>
has always worked since it was introduced back in 2005. For it to suddenly require an implementation to make CompareTo
take an int?
would be a major breaking change. Given that nullability works completely differently for value types and reference types, there really are only two options for what T?
could mean in generic definitions like this: either it is defined to have no meaning (which is how it was in C# 8.0), or it can be apply only when the type argument is a reference type. That latter seems more useful, which is presumably why it is now supported.