Part 0 - Nullabe Reference Types
Part 1 - Default Interface MembersPart 2 - Pattern Matching EnhancementsPart 3 - All the Small Things As is the tradition of this no-blog when a new version of the C# programming language is released, I must inform my millions of readers of my correct opinion of the new feature set. It seems to me that C# 8.0 is the most impactful release of C# only rivaled by version 2 (and of course version 1.0 since it went from non-existing to existing with that version). I guess I will need to split this commentary in parts. This first part is about the gamechanger – non-nullable reference types.
I won't disrespect the reader by repeating the popular lines about the evils of null, the billion dollar mistake and so on. I will just assume you know why null is a problem. The C# design team is facing the immense task of mitigating this problem while providing the most usable syntax, maximum backward compatibility and smooth transition path for existing libraries and projects. So how does this work? Well for starters there is a switch for this feature. If you do not want it, you do not have to use it. You can upgrade your projects and use other new features and never care about this. Of course if you do not care about this you are wrong and you should feel bad. The feature is enabled by adding <Nullable>enable</Nullable> in the project file. Interestingly the feature is still not enabled by default for new project which comes to show that the battle against null is just starting. As we will see the ecosystem is not yet ready.
Before you enable the feature types like string really meant string or null. Now they mean just string and if you try to assign null or something that is declared as potentially null you get a warning. I believe the warning was chosen to ease transition and be able to compile and run your code in the process. You should treat these warnings as errors you have not cleaned up yet. What if you want the type to accept null? You write
string? just like you do with nullable value types (
int?).
string nonNullString = null; //warning
string? possiblyNullString = null; //fine
nonNullString = possiblyNullString; //warning possiblyNullString could be null
//so it is unsafe to assign it to a non-nullable variable
if(possiblyNullString != null)
{
nonNullString = possiblyNullString; //fine
}
So what we see here is that the compiler performs static analysis through ifs and other constructs and tracks the nullability of variables. Inside the if possiblyNullString is treated as string instead of
string? because we have checked. This all works with method arguments, class members and so on. For example if we have
public void Print(string s)
{
…
}
And call it like
Print(null);
Print(possiblyNullString);
We will get warnings in both cases. So we are done, right? Just fix the warnings and we have beaten null. I wish it was that simple.
string[] nulls = new string[5];
nulls[0].ToString();
Wait, WTF?!? Why didn't the compiler warn me? I thought we were friends! Turns out the compiler can't track array initialization and arrays still start with nulls. There are a bunch of other cases like this including multithreaded scenarios. OK I can live with that, lets move forward.
string[] letters = new string[] { "a", "b" };
string letter = letters.FirstOrDefault(l => l == "c");
letter.ToString();
Oh come on! Null reference exception again and no warnings. Is this even doing anything? So it turns out there are not two but four types of nullability. Non-nullable, nullable, oblivious and unknown. Oblivious is the type when you are using a library that hasn't been annotated yet like every old library. The compiler just pretends all assignments and dereferencing is fine. This is of course needed because otherwise we will be stuck in a situation where we can't upgrade our projects as if we were using Python 2 (nah, I'm kidding, it wouldn't be that bad). Turns out even core parts of the framework are not annotated yet. I'm starting to understand why this feature isn't enabled by default. Lets write our own method and annotate it.
public static T MyFirstOrDefault<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
foreach(T element in source)
{
if(predicate(element))
{
return element;
}
}
return default;
}
Sooo… how do I annotate? Remember when I told you about that Unknown nullability. Yeah that's what the nullability is inside the generic method. There is a new
notnull constraint that you can put on your generic parameters so that they are treated as not nullable but the thing is we don't want that here. We want our method to work on nullable types as well. The good news is that we get a warning on the last line that default can return null but how do we get rid of it? Enter the null-forgiveness operator (a.k.a. the damn it operator). When you want to disable the null checks in a specific place you just add a
!.
return default!
Also you can do things like
string s = null;
s!.ToString();
and make the compiler shut up. I mean maybe we want a null reference exception? After all in ten years when I have not seen one in half a decade I might miss it and might want to produce one to remember the feeling. They thought about everything! So we made the compiler shut up but now it does not warn us about the NRE
string[] letters = new string[] { "a", "b" };
string letter = letters.MyFirstOrDefault(l => l == "c");
letter.ToString();
What we need here is a way to tell the compiler that even though the source
T is not nullable the result
T might be null. We're passing in
IEnumerable<string> but the result is
string? and the checks must kick in. Note that the compiler doesn't have the ability to look into our method and infer that, we must tell it and the way to do is are the nullability attributes. The one we will use here is the
MaybeNull attribute.
[return: MaybeNull]
public static T MyFirstOrDefault<T>(this IEnumerable<T> source, Func<T, bool> predicate)
And voilà the warnings appear. We've told the compiler that the
T in the return type may actually be null even though the
T in the source type cannot be null. The method also works when everything is nullable. If you look at the .NET source code
this method is already annotated but it didn't make it into the .NET Core 3.0 release. Interestingly the presence of the attribute doesn't remove the need to use the damnit operator on that
return default! statement although I think it should.
The
MaybeNull attribute can be applied to parameters, fields, properties, indexers and returns. Lets look at its mirror image the NotNull attribute. I like to use a helper method in my API controllers that goes like this:
void ThrowNotFoundIfNull(object? o)
{
if(o == null)
{
throw new HttpResponseException(StatusCode.NotFound);
}
}
I normally use in GET REST methods. I retrieve the object with the specified id from the database, check it for null with this method and continue on if the object is not null. If it is I convert it to 404 response.
So now lets use it like this
string[] letters = new string[] { "a", "b" };
string? letter = letters.MyFirstOrDefault(l => l == "c");
ThrowNotFoundIfNull(letter);
letter.ToString();
Now I know that there cannot be a NRE because the method checks for null but the compiler doesn't know this so we use the
NotNull attribute to tell it that if this method completes
letter should no longer be treated as nullable.
void ThrowNotFoundIfNull([NotNull]object? o)
And just like that the warnings are gone. Of course the code still throws exception from the method but it is one we told it to.
Note that both attributes are postconditions, they tell us the state of parameters and return values after the execution has completed. Similar issue arises with the
String.IsNullOrEmpty method. The nullness of its argument depends on the bool value it returns. The
NotNullWhen(bool) attribute allows us to specify that and the compiler connects the nullness with the bool value.
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
And then
if(String.IsNullOrEmpty(letter))
{
letter.ToString(); //warning
}
else
{
letter.ToString(); //no warning
}
The
TryParse methods use similar scheme. The
MaybeNullWhen(bool) attribute is used in the mirror case, when we need to attach nullness to a previously non-null value.
There are three more attributes
NotNullIfNotNull – informs the compiler that although the method is annotated as nullable the actual nullness of the return type depends on the nullness of one of the arguments. Null-in null-out.
[return: NotNullIfNotNull("path")]
public static string? GetFileName(string? path)
DoesNotReturn – pretty self-explanatory. The method annotated with this attribute will throw an exception so the compiler should not bother with null warnings in the branch where the method is called. Used for helper methods for throwing exceptions.
DoesNotReturnIf(bool) - same but depends on the state of a bool parameter. Used for assertions.
void Assert([DoesNotReturnIf(false)] bool condition)
Armed with these we can fix most of the nullability issues we encounter. Sadly, that's not all of them. The most significant problem is that a lot of libraries are built without concern for non-nullable reference types. Even when devs try to add support the architecture is still not perfect for this feature. Let's look at Entity Framework as an example. The first issue we encounter is that DbSets are declared as properties and EF automagically puts object into them. They are not assigned anywhere visible to the compiler. So what do we do? We null-assign them damnit!
public DbSet<Customer> Customers { get; set; } = null!;
public DbSet<Order> Orders { get; set; } = null!;
Similar issue arises with references for relationships. Logically the Customer property always exists but technically it may not be loaded with the Order. We need to choose between declaring it as non-nullable or forcing null checks or damnit operators all over the place although we know that we added that
Include(o => o.Customer). For more information check
this article on nullability support in EF Core The C# compiler provides several features to ease migration of existing projects. There are two nullable contexts that can be enabled or disabled. The nullable annotation contexts allows writing code where reference types can be marked as nullable (i.e.
string? is valid code). This context alone does not enable the warnings. The goal here is to be able to annotate your library over time without generating thousands of warnings. There is also the nullable warnings context. This context enables the warnings we have seen. If it is disabled but annotation context is enabled reference types are treated as oblivious. If you enable warnings but disable annotation the compiler depends on type inference for the warnings and on the annotations from outside sources. This is a reasonable mode if you don't want to annotate but still want to benefit from the ecosystem that is annotated. These contexts can be separately enabled on project level, on file level or even in specific parts of files via directives like
#nullable disable annotations /
#nullable restore annotations. Of course, you should try to enable both as soon as it is practical.
The problem with null is very hard to solve for an existing language that had nulls for almost two decades. The battle is just starting but it is worth it. We would never get null-safety as good as Rust but I believe .NET will do better than languages like Kotlin because the ecosystem is driven mainly by C# and everyone will be trying to get null annotations. As we saw even the standard library is not annotated yet but I believe in 5 years we will be able to eliminate about 90% of NREs which make it into production and will be able to speed development by eliminating the need to run the code just to see you missed a null check. We will never get rid of NREs completely but it will be a substantial improvement over the current situation.