C# 8.0 nullable references: conditional post-conditions
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 NotNull
and 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 NotNullWhen
, MaybeNullWhen
, and 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 When
or If
in their names.)
To see why we need these, let's look at a common scenario in which the MaybeNull
and NotNull
attributes fall short. It's the IDictionary<TKey, TValue>
example I showed way back in the first article in this subseries:
public void UseDictionary(string key, IDictionary<string, string> dictionary)
{
if (dictionary.TryGetValue(key, out string? value))
{
Console.WriteLine("String length:" + value.Length);
}
}
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:
bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
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 MaybeNull
and 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 false
.
The 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:
public static bool IsNullOrWhiteSpace([NotNullWhen(false)] string? value);
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:
public void HowLong(string? pieceOf)
{
if (!string.IsNullOrWhitespace(pieceOf))
{
Console.WriteLine(pieceOf.Length);
}
else
{
Console.WriteLine("How long is nothing? How soon is now? Who can say?");
}
}
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 if
statement, pieceOf
will not be null, and so it does not need to generate a warning.
The 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 Path.GetFileName
method:
[return: NotNullIfNotNull("path")]
public static string? GetFileName(string? path);
Choosing between MaybeNullWhen and NotNullWhen
Here's a question for you: how should we modify this example when enabling nullable references?
public static bool TryGetValue(string key, out string value) ...
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 NotNullWhen
:
public static bool TryGetValue(string key, [NotNullWhen(true)] out string? value) ...
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 true
.
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?
when 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 TryGetValue
method's 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.
Conclusion
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.