I'm trying to get a handle on C# 8's new nullable compiler checks. The following struct is giving me some strange warnings.
// This struct gives no warnings
public struct Thing<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
Thing(T value)
{
_value = value;
_hasValue = value is { };
}
}
// This struct gives warning on the constructor:
// "Non-nullable field "_value" must contain a non-null value before exiting constructor.
// Consider declaring the field as nullable.
public struct Thing<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
Thing(T value)
~~~~~
{
_value = value;
_hasValue = _value is { };
}
}
// This struct gives two warnings, one on the constructor and one on `_value = value`
// [1] "Non-nullable field "_value" must contain a non-null value before exiting constructor.
// Consider declaring the field as nullable.
// [2] Possible null reference assignment.
// This is true even if I check value for null and throw an ArgumentNullException before the assignment.
public struct Thing<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
Thing(T value)
~~~~~ // [1]
{
_hasValue = value is { };
_value = value;
~~~~~ // [2]
}
}
Have I created some impossible situation where the compiler can't figure out the intent? Is this a compiler bug related to nullable reference types in constructors? What am I missing here?
All of these scenarios make perfect sense to me. Let's look at each one, but first let's agree on some things:
Non-nullable/nullable reference types aren't real types. They are just annotations the compiler knows how to interpret
The struct is constrained to notnull
however someone can pass a null
reference to it, without any warnings if they have not enabled nullable analysis ( #nullable enable
)
val is {}
is a null
test. By using it, you imply that the value can be null
The compiler uses flow analysis. It looks for various patterns to determine the state of a value. It will take your word that something can be/isn't null
and it will use previous assignments as proof of a variable's nullability even when it might not make sense
Under most (all?) circumstances, flow analysis does not work backwards, meaning that an asserted state on line N
does not impact line N-1
Let's look at the examples:
public struct ThingA<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
ThingA(T value)
{
_value = value;
_hasValue = value is { };
}
}
ThingA
emits no warnings. You've constrained to notnull
and assigned to _value
. Prior to this you've not performed any nullability assertions so the compiler assumes that up to this point the parameter is in fact not null
per the contract . Flow analysis doesn't work backwards. Assigning to _hasValue
using a null
test against the parameter only affects the future nullable state of the value
parameter . The compiler doesn't say "hey I remembered using value
to assign to something, let me go fix up everything"--that'd be too much work. Now we exit the constructor and you've not reassigned the _value
field so its previously decided state of "not null" remains. No warnings.
public struct ThingB<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
ThingB(T value)
{
_value = value;
_hasValue = _value is { };
}
}
ThingB
starts out with a similar flow to ThingA
. Because we have no previous null
tests and we have a notnull
constraint on T, assigning to the _value
field reinforces its non-null state per the contract. But here's where it gets tricky! You now perform a null
test against the very same field, thus implying that it could in fact be null
. Flow analysis doesn't work backwards so we don't get a warning on the line assigning to the field, but you have told the compiler that the field's state could be null
. We're exiting the constructor with a possibly null
field. Here's your warning.
public struct ThingC<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
ThingC(T value)
{
_hasValue = value is { };
_value = value;
}
}
For ThingC
, you perform a null
check on the parameter, implying it can be null
. The parameter's null
state is changed to "maybe null" You then use that parameter when assigning to _value
. Well you just said it can be null
, but per the constraint it can't be. Here's the first warning. Now we leave the constructor body and the field's state is still "maybe null" (per the assignment). There's the second warning.
In the comments you state
This is true even if I check value for null and throw an ArgumentNullException before the assignment.
Well, that's only true depending on where you place the check. Consider this:
public struct ThingD<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
ThingD(T value)
{
if (value is null) throw new Exception();
_hasValue = value is { };
_value = value;
}
}
Under normal circumstances, flow analysis would catch this. Except this suffers the same problem as ThingC
: you're telling the compiler that the parameter still (somehow!) can be null
in your _hasValue
assignment/ null
test. You get the same warning. SharpLab
If you move the null
-check plus exception after the _hasValue
test, you get no warning:
public struct ThingE<T> where T : notnull
{
private readonly bool _hasValue;
private readonly T _value;
ThingE(T value)
{
_hasValue = value is { };
if (value is null) throw new Exception();
_value = value;
}
}
When processing ThingE
, there's no warning. The exception guards the assignment to the _value
field having asserted the parameter is not null
. This is despite the _hasValue
assignment implying that it can be. You're saying "from this point on its definitely not null
". SharpLab
Remember that nullable annotations and constraints aren't a "real" part of the type system. Passing a nullable value or even null
directly will not produce a compilation error, just a warning (unless of course TreatWarningsAsErrors
is enabled). Also remember that your classes/methods expecting a non- null
parameter can be lied to , either by
#nullable
null
-forgiving operator, !
You must still guard for null
, typically checking and throwing right-away . This will help out flow analysis a ton. Flow analysis isn't perfect. There are a lot of patterns that should be detectable but aren't and the team is constantly working on making it better (C#9 had several improvements).
Anecdotally, when nullable reference types first came out, I was really unsure about whether we really needed to perform null
tests if we were using non- null
types. I had a quite lengthy conversation with Mads Torgersen at Microsoft Ignite back in 2019 following one of his C#8 presentations. He agreed with me that the subject could be unclear and that (at the time, at least) even the documentation didn't make it obvious. He stressed that unless the types were internal--so, part of your public API--that performing null
guard pre-condition tests was still a necessity.
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.