We're nearly at the end of this sub-series on attributes for getting better results from C# 8's nullable references feature, so soon, I'll be getting back to other nullability-related topics (and maybe even other topics one day).
But not today. As ever, the point of all this is to help the compiler do a better job of detecting null-related programming errors by giving it more information about our intent.
The last couple of posts on
MaybeNull have been concerned with post-conditions—telling the compiler what it can infer when a method returns. Now we're going to look at
NotNullIfNotNull. These are also concerned with post-conditions, but the difference is that these all make conditional statements about post-conditions: the compiler is only able to draw inferences when certain conditions apply. (That's why these attributes all have
If in their names.)
To see why we need these, let's look at a common scenario in which the
NotNull attributes fall short. It's the
IDictionary<TKey, TValue> example I showed way back in the first article in this subseries:
This is tricky because the
TryGet method has an
out argument that will return null in some situations and not in others. In the initial previews of C# 8.0, dictionaries were awkward to work with. They tended to produce false positives—it was incorrect to use a non-nullable type with the
out argument, because the value would definitely be null in cases where no entry was found, but by passing a nullable variable you ended up needing to use the null forgiving operator in the code path where an entry is found because the compiler had no way of knowing that the value would not in fact be null in that case.
In fact, you'll still these problems if you enable null-awareness on a project that targets versions of .NET earlier than .NET Core 3.0 (or .NET Standard 2.0 or earlier)—although the C# compiler tolerates enabling nullable references on these older targets, there are various scenarios that are unsatisfactory if you do this.
But starting with .NET Core 3.0 and .NET Standard 2.1, the dictionary's
TryGet method now has a
[MaybeNullWhen(false)] attribute on the relevant argument:
The effect of this attribute is that when you use the dictionary in the way shown above, the
value.Length expression should no longer produce a warning. Although
value's type is
string? here, the attribute enables the compiler to infer that if
TryGetValue returns true, it will not in fact be null.
There is a similarity here to the
MaybeNull attribute I described in an earlier post, which states that when the type argument is non-nullable, the output in this particular case may still be nullable. (Obviously, if the type argument for the
TValue type parameter is a nullable type such as
string?, as in
IDictionary<string, string?>, then it's always possible for the output to be null even when the dictionary does have an entry for the specified key. So
MaybeNullWhen only come into play when the type argument is non-nullable.)
The difference is that
MaybeNullWhen is conditional: it says that if the relevant type argument is non-nullable, then the output may nonetheless be null, but only if the method returns
NotNullWhen attribute is similar in concept, but comes into play in cases where the type is nullable. So with this attribute, a particular
bool return value means that the value is not null. But whereas
MaybeNullWhen is mainly used in generic scenarios,
NotNullWhen is useful in many non-generic scenarios. For example, here's how the
IsNullOrWhiteSpace defined by .NET's
string type looks:
There are no generic type arguments here—just a known type of
string?. That has to be nullable because the basic point of this method is to handle scenarios where you don't yet know whether you have null. However, the presence of this attribute means that if the method returns
false, the compiler can infer that the value is non-null. This enables this kind of code to compile without warning:
If you try that on any version of .NET older than .NET Core 3.0 (or if you target .NET Standard, any version older than 2.1) the compiler will raise a warning for the
pieceOf.Length expression, complaining that you are deferencing something that might be null. But with newer versions, it will correctly infer that if execution enters the body of that
pieceOf will not be null, and so it does not need to generate a warning.
NotNullIfNotNull attribute requires the name of a parameter. This attribute states that if the named parameter is not null then the attribute's target (which can be either the method's return value, or some parameter other than the one it names) will also definitely not be null. This is used, for example, in the .NET class library's
Choosing between MaybeNullWhen and NotNullWhen
Here's a question for you: how should we modify this example when enabling nullable references?
It might occur to you to look at
IDictionary<TKey, TValue> and use its very similar
TryGetValue (shown above) as an exemplar, leading you to annotate the
out argument with
MaybeNullWhen, but this would be a mistake. The correct approach in this case is to modify the argument to be nullable and then annotate it with
Why, you may be wondering, does the dictionary not do this? Part of the answer is: generics. The example above actually make a good deal more sense than generic dictionary's equivalent method because it states clearly exactly what's going on: this
out argument is sometimes null (hence
string?) but won't be if the method returns
So why doesn't
IDictionary<TKey, TValue>.TryGetValue do the same? It can't, because for reasons discussed earlier in this series (see the MaybeNull entry) you can't write
T is an unconstrained type parameter. Since the dictionary needs to use a type parameter for its
out argument, it has to use an attribute to indicate that even when a non-nullable type argument has been supplied for the
TValue type parameter, when it comes to the
out argument, the nullable form needs to be used. Moreover,
NotNullWhen would be inappropriate because a dictionary is in no place to make such a strong claim.
If I have an
IDictionary<string, string?> then I'm entirely at liberty to store a null value for any key, so the dictionary has no business asserting that the output of
TryGetValue will definitely not be null. The most it can say is that if
TryGetValue returns false, and if the type argument in question is a reference type or a nullable value, then the result will be null. (And since C#'s nullable reference system doesn't actually have a "will be null" categorisation—only "not null" or "may be null"—it can only say that it may be null in that scenario.)
Given that it works that way, it seems a little odd that the compiler seems able to infer definite non-nullness with the generic dictionary
TryGetValue, since when we plug in a reference type such as
string, the apparent type of the
out argument is
string?, and the
MaybeNullWhen makes no direct assertion that anything will ever definitely be non-null. But the way to think of this is that in these circumstances, the TryGetValue is a bit of a shape shifter. If it returns false, then its
out argument will be nullable, but if it returns true, then it is as though its
out argument has exactly the same type as was supplied for the
TValue type parameter.
This means that if you use
IDictionary<string, string> in effect, the signature of the method is
TryGet(string key, out string value) if it returns true, but the signature becomes
TryGet(string key, out string? value) if it returns false. C#'s type system cannot directly represent this, so it looks to us like it is simply
TryGet(string key, out string? value) in either case, but the null checking acts as though it were the former in the case where true is returned, and that's how it's able to infer that the output is non-null in that case.
These three attributes may seem like a slightly random selection of features—it's not hard to imagine other variations on these theme. What if we wanted something like
NotNullWhen but based on some other
bool argument for example? However, between them, these three attributes cover various widely-used idioms in .NET, striking a good balance between minimizing spurious warnings vs having way too many different attributes to discover and understand.
If you've ploughed the whole of this series so far then and you may be thinking that there are too many attributes even now. (And thank you, by the way!) But each of the ones we've looked at plays a significant role in making nullable references support more effective.
Next up is the final part of this sub-series, and we'll be looking at some attributes that were already present before C# 8, but which now have an extended role because they have an impact on nullability analysis.