【问题标题】:Why am I getting these strange C# 8 "nullable reference types" warnings: Non-nullable field must contain a non-null value before exiting constructor.?为什么我会收到这些奇怪的 C# 8“可空引用类型”警告:非可空字段必须在退出构造函数之前包含非空值。?
【发布时间】:2021-02-06 05:08:44
【问题描述】:

我正在尝试处理 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 只适用于泛型类型的消费者,而不适用于里面的代码......
  • 您的意思是“是对象”检查吗?
  • 第一个不工作的例子对我来说很有意义,因为给定初始化规则,该字段的非空保证只能在 构造函数之后回来。实际的错误消息是不幸的,但可能发生错误似乎是合理的。第二个非工作示例对我来说意义不大;我没有充分的理由证明这一点(否则我会发布关于两者的答案)。如果两者都是编译器错误,并且可能已经报告,我不会感到惊讶。你检查了 github repo 吗?

标签: c# .net c#-8.0


【解决方案1】:

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

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

  • 该结构被限制为notnull,但是有人可以将null 引用传递给它,如果他们没有启用可空分析(#nullable enable),则不会发出任何警告(#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 根据合同。流分析不能倒退。使用针对参数的null 测试分配给_hasValue 只会影响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,但根据约束它不能。这是第一个警告。现在我们离开构造函数体,字段的状态仍然是“可能为空”(根据赋值)。还有第二个警告。

在您声明的 cmets 中

即使我检查 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 相同的问题:你告诉编译器参数仍然(不知何故!)可以在你的_hasValue assignment/null 测试中是null。你会得到同样的警告。 SharpLab

如果您将null-check 加上异常 _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 时,没有警告。异常保护对已断言参数不是null_value 字段的分配。尽管_hasValue 分配暗示它可以。你说的是“从现在开始肯定不是null”。 SharpLab

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

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

你仍然必须警惕null,通常检查并立即扔掉。这将有助于大量的流量分析。流量分析并不完美。有很多模式应该是可检测的,但实际上并没有,团队一直在努力使其变得更好(C#9 有一些改进)。

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2022-10-23
    • 2021-08-02
    • 1970-01-01
    • 2022-01-07
    • 2020-07-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多