简体   繁体   中英

Why am I getting these strange C# 8 “nullable reference types” warnings: Non-nullable field must contain a non-null value before exiting constructor.?

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

    • This is the one that's the kicker here. It doesn't make sense to us that the field of a not fully-constructed object can be changed from one line to the next; there's not two threads changing values. But the compiler isn't human-smart and will defer to us when we tell it that we know better
  • 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

  • Disabling (or just not enabling) #nullable
  • Use of the null -forgiving operator, !
  • Ignoring the warnings (as is my experience, very many people do)

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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM