03/03/2023
The Biggest New Thing in C# for Years
But hang on - haven't reference types always been nullable?
That's absolutely right. It's fundamental to the way a reference type works. The variable doesn't contain the data itself, but a reference to a separate memory location where the data is stored. Thus, if there's no data, the reference is set to null.
What's actually new in C# version 8 is non-nullable references. And that's potentially a breaking change, of which more anon.
First Came Nullable Values
Originally in C#, values could never be null, because a variable of a value type actually contains the data, not a reference to it. This doesn't always make sense. For example:
DateTime dateOfDecommission;
What if our object - whatever it may be - hasn't yet been given a decommission date? We could give it special value such as DateTime.MinValue or DateTime.MaxValue, but those are actual values - far into the past or far into the future. They are valid dates and we'd always need to have specific coding to check for them.
And so C# version 2 introduced nullable value types:
DateTime? dateOfDecommission = null;
In addition to all the other values the variable may have, it can take on the value null. This doesn't turn it into a reference type - the variable still contains the data - but the data structure has an extra boolean field added to it, which we can access:
if (dateOfDecommission.HasValue) ...
More usually we just compare to null, which the compiler interprets as checking the HasValue flag.
if (dateOfDecommission == null) ...
So Why Nullable References?
Consider the following UML:
Here we have two different relationship types - association and containment - which we could read in English as a MotorCar may have a Driver, but must have an Engine. (Whether this is correct will depend on the context. For software dealing with an assembly line, a car would never have a driver and might or might not have an engine. But for a car hire company, the diagram would make sense.)
Until now, there's been no way of expressing this in C#. We just had references:
public class MotorCar
{
private Driver _driver;
private Engine _engine;
}
It's up to the developer - to all the developers in the team - to remember that _driver can be null and _engine cannot.
Can You Guess the Syntax?
In C#8, declaring a nullable reference is just the same as declaring a nullable value - we use a question mark:
public class MotorCar
{
private Driver? _driver;
private Engine _engine;
}
And that's what makes it a breaking change. In C#8, _driver is actually what it always was - a nullable reference. It's _engine that has changed. In C#7 it was nullable, now it's non-nullable.
It's All Optional
For that reason, this feature is opt-in. We can do it on a per-file basis with
#nullable enable
or we can set it for an entire project in the .csproj file. Otherwise, all references are treated just as in C#7.
And even if we turn the feature on, any breaches of the rules will come out as warnings, not errors.
So What are the Rules?
Nullable and non-nullable references are identical in the compiled code. The only difference is in the rules that are applied during compilation. To begin with, the above code fragment will produce a warning. By default, reference class members are initialized to null, but _engine cannot be null. We need to fix this somehow, either with a constructor or in line:
public class MotorCar
{
private Driver? _driver;
private Engine _engine = new Engine();
}
But the big benefit comes with code like this:
public override string ToString()
{
return $"Car with capacity {_engine.Capacity}cc is driven by {_driver.Name}.";
}
There's a problem here: we haven't checked whether those references are null. If either _engine or _driver is null, then we'll get a NullReferenceException, and ideally we should be putting in safety checks. In C#7 we might have put safety checks on _engine and on _driver, even though conceptually _engine can never be null. Or we might have forgotten to put checks on either.
In C#8, the compiler would give us a warning on _driver, but would be happy with _engine, since it knows _engine can never be null. It tells us precisely where we need to put the checks:
if (_driver != null)
return $"Car with capacity {_engine.Capacity}cc is driven by {_driver.Name}.";
else
return $"Car with capacity {_engine.Capacity}cc has no driver.";
or perhaps
return $"Car with capacity {_engine.Capacity}cc is driven by {_driver?.Name ?? "no one"}.";
Either approach will remove the warnings. The compiler has enforced that a non-nullable reference cannot be assigned with null, so it doesn't insist that we check for null.
It's Complicated
If you start using nullable references, then you should never get a NullReferenceException, whilst also avoiding unnecessary checks for null.
But because the feature fundamentally changes the way that C# has be written for decades, there's a lot of further complexity, including attributes such as [NotNullWhen], and the intriguingly named Null-Forgiving Operator (a new use of the exclamation mark).
You can find out more in these two videos:
- C# Nullable References: https://youtu.be/VkelpHkkg4I
- C# Null Forgiving Operator: https://youtu.be/H2sfNnB1QAU
Enhance your programming skills further with one of our software design and development courses.
This piece was originally posted April 7, 2021 and has been reposted with updated formatting.