C# 8.0 nullable references: transcending the type system with AllowNull
As I described in the preceding post in this series on getting better results with nullability attributes, there are numerous attributes associated with C# 8.0's nullable references feature.
These enable you to express your intent in more detail, enabling the compiler to do a better job of its null correctness analysis.
With these attributes we find more problems, and get fewer spurious warnings.
In this post, I'm going to look at the AllowNull
attribute. It lets you state that a null is allowed even when the type seems to state otherwise.
Why might you do such a thing? If you want a nullable string reference, wouldn't you just write string?
?
Asymmetric properties
One of AllowNull
's jobs is to enable properties to be asymmetric. Sometimes you will define a property which can be counted on never to return null, but you might want to allow it to be reset to some default state by setting it to null.
You can't do this through the C# type system alone: if you define a property of type string
, the compiler will produce warnings if code tries to set it to null without using the null forgiving operator (i.e., !
aka the dammit operator). Conversely, if you use string?
the compiler will produce warnings for any code reading that property unless that code checks for nulls. Either kind of warning is unwanted here.
This example shows how you can annotate this kind of property to avoid these warnings:
[AllowNull]
public string DisplayName
{
get => this.displayName ?? this.Id;
set => this.displayName = value;
}
private string? displayName;
public string Id { get; set; }
Here we have a DisplayName
property which will fall back to returning the Id
property if no display name has been set, so it can be counted on never to return a null. That means its type, string
correctly describes the get
behaviour. But its set accepts null to unset the display name, reverting to the fallback behaviour. The [AllowNull]
attribute tells the compiler that this asymmetric behaviour is our intention.
Generics
The other scenario in which AllowNull
crops up is generic code. For example, the IComparable<T>
type defined by .NET looks like this:
public interface IComparable<in T>
{
int CompareTo([AllowNull] T other);
}
This interface has been around since .NET Framework 2.0 (i.e., since 2005), so it existed for a decade and a half before nullability came along. But the documentation makes the intention clear—it describes the rules implementations are expected to implement when nulls are passed, so evidently the expectation is that the argument might be null. Why not just say so? Why isn't the type T?
? After all, the non-generic equivalent uses object?
:
public interface IComparable
{
int CompareTo(object? obj);
}
The generic version can't use T?
because that doesn't work. It doesn't work because T?
can mean either of two completely different things. If T
is a reference type, then T?
is a reference (with exactly the same runtime representation as T
) that the C# compiler knows is not supposed to contain nulls. But if T
is a value type, T?
is shorthand for Nullable<T>
.
This has a different runtime representation from the non-nullable value type T
, and also tends to produce quite different code when used. Since IL for generic code has to be produced at compile time, the compiler needs to know at compile time whether or not it's dealing with a nullable value.
For this reason, you can't put a ?
after a type parameter unless the compiler knows for certain whether the type is a value type or a reference type. So you have to use either a class
or struct
constraint for the compiler to let you write T?
.
But in most cases, we don't want to apply such constraints. IComparable<T>
can't be limited to just classes, or just value types: it is widely used for both.
The AllowNull
allows us to express what we want here: it indicates that if T
happens to be a reference type, then this parameter needs to be treated as though it were the nullable version of that type, regardless of whether the type argument was nullable or non-nullable. So whether you have IComparable<MyClass>
or IComparable<MyClass?>
, in either case the method will have an effective signature of int CompareTo(MyClass? other)
Conclusion
AllowNull
helps us out in two scenarios. One is where a type is stated once, but used in multiple ways, and we want different nullability in different places.
The usual example of this is a property: we only get to declare the type of a property once, but we may require different nullability for the get and set accessors.
Generics may also create scenarios where a type is expressed just once (as the type parameter) but then used in different ways.
And then there's a second scenario, specific to generics, in which we want to be able to say that in cases where the argument for a type parameter is a reference type, we want the nullable version of that type.