为什么我会收到这些奇怪的 C# 8“可空引用类型”警告:非空字段在退出构造函数之前必须包含非空值?

问题描述

我正在尝试处理 C# 8 新的可为空的编译器检查。以下结构给了我一些奇怪的警告。

// 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]
    }
}

我是否创造了一些不可能的情况,编译器无法弄清楚意图?这是与构造函数中的可为空引用类型相关的编译器错误吗?我在这里错过了什么?

解决方法

所有这些场景对我来说都非常合理。让我们看看每一个,但首先让我们在一些事情上达成一致:

  • 不可为空/可为空的引用类型不是真正的类型。它们只是编译器知道如何解释的注释

  • 结构被限制为 notnull,但是有人可以传递一个 null 引用到它,如果他们没有启用可空分析 (#nullable enable),则不会出现任何警告

  • val is {} 是一个 null 测试。通过使用它,您暗示值 可以null

  • 编译器使用流分析。它寻找各种模式来确定值的状态。它会相信你的话某些东西可以是/不是null,并且它会使用以前的赋值作为变量可空性的证明,即使它可能没有意义

    • 这是这里的关键。 我们认为未完全构造的对象的字段可以从一行更改为下一行是没有意义的;没有两个线程改变值。但是编译器不是人类智能的,当我们告诉它我们知道得更好时,它会听从我们
  • 在大多数(所有?)情况下,流分析不会反向工作,这意味着行 N 上的断言状态不会影响行 N-1

让我们看一下例子:

public struct ThingA<T> where T : notnull
{
    private readonly bool _hasValue;
    private readonly T _value;
    
    ThingA(T value)
    {
        _value = value;
        _hasValue = value is { };
    }
}

ThingA 不发出警告。您已限制为 notnull 并分配给 _value。在此之前,您尚未执行任何可空性断言,因此编译器假定到目前为止该参数实际上不是 null 根据合同。流分析不会向后工作。使用针对参数的 _hasValue 测试分配给 null 只会影响 value 参数未来可为空状态。编译器不会说“嘿,我记得使用 value 来分配某些东西,让我去修复所有东西”——那样工作量太大了。现在我们退出构造函数并且您没有重新分配 _value 字段,因此其先前确定的“非空”状态仍然存在。没有警告。

public struct ThingB<T> where T : notnull
{
    private readonly bool _hasValue;
    private readonly T _value;
    
    ThingB(T value)
    {
        _value = value;
        _hasValue = _value is { };
    }
}

ThingB 以与 ThingA 类似的流程开始。因为我们之前没有 null 测试并且我们对 T 有 notnull 约束,所以分配给 _value 字段会加强其每个合同的非空状态。但这就是它变得棘手的地方!您现在对完全相同的字段执行 null 测试,从而暗示它可能实际上是 null。流分析不会向后工作,因此我们不会在分配给该字段的行上收到警告,但您已告诉编译器该字段的状态可能是 null。我们正在使用可能的 null 字段退出构造函数。这是您的警告。

public struct ThingC<T> where T : notnull
{
    private readonly bool _hasValue;
    private readonly T _value;
    
    ThingC(T value)
    {
        _hasValue = value is { };
        _value = value;
    }
}

对于 ThingC,您对参数执行 null 检查,暗示它可以null。参数的 null 状态更改为“可能为空”,然后在分配给 _value 时使用该参数。好吧,您刚刚说它可以null,但根据约束,它不能。这是第一个警告。现在我们离开构造函数体,字段的状态仍然是“可能为空”(根据赋值)。这是第二个警告。

在您声明的评论中

即使我检查空值并抛出一个 赋值前的 ArgumentNullException。

嗯,这取决于您将支票放在哪里。考虑一下:

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;
    }
}

正常情况下,流分析会捕捉到这一点。除了这遇到与 ThingC 相同的问题:您告诉编译器参数仍然(不知何故!)可以在您的 null 赋值/ _hasValue 测试。你会得到同样的警告。 SharpLab

如果您在 null 测试之后移动 null-check plus 异常,您将不会收到警告:

_hasValue

处理 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; } } 时,没有警告。该异常保护对已断言参数不是 ThingE_value 字段的赋值。尽管 null 赋值暗示它可以。您说的是“从现在开始,它肯定 不是 _hasValue”。 SharpLab

请记住,可为空的注释和约束不是类型系统的“真实”部分。直接传递可空值甚至 null 不会产生编译错误,只会产生警告(当然除非启用了 null)。还请记住,您的类/方法需要一个非TreatWarningsAsErrors 参数可以被欺骗,无论是通过

  • 禁用(或只是不启用)null
  • 使用 #nullable-forgiving 运算符 null
  • 忽略警告(根据我的经验,很多人都这样做)

您仍然必须对 ! 保持警惕,通常是过牌并立即。这将有助于进行大量的流量分析。流量分析并不完美。有很多模式本应可检测但无法检测,团队一直在努力使其变得更好(C#9 有多项改进)。

有趣的是,当可空引用类型首次出现时,我真的不确定我们是否真的需要执行 null 测试,如果我们使用的是非 null 类型。 2019 年,在他的一次 C#8 演讲之后,我在 Microsoft Ignite 上与 Mads Torgersen 进行了一次相当长的对话。他同意我的看法,即这个主题可能不清楚,而且(至少在当时)甚至文件也没有让它变得明显。他强调,除非类型是内部类型——因此,是公共 API 的一部分——否则执行 null 保护前置条件测试仍然是必要的。