为什么这段代码会给出“可能的空引用返回”编译器警告?

Mat*_*son 81 c# nullable-reference-types

考虑以下代码:

using System;

#nullable enable

namespace Demo
{
    public sealed class TestClass
    {
        public string Test()
        {
            bool isNull = _test == null;

            if (isNull)
                return "";
            else
                return _test; // !!!
        }

        readonly string _test = "";
    }
}
Run Code Online (Sandbox Code Playgroud)

当我构建它时,标有的行!!!给出了编译器警告:warning CS8603: Possible null reference return.

我觉得这有点令人困惑,因为它_test是只读的并且初始化为非空。

如果我将代码更改为以下内容,警告就会消失:

        public string Test()
        {
            // bool isNull = _test == null;

            if (_test == null)
                return "";
            else
                return _test;
        }
Run Code Online (Sandbox Code Playgroud)

谁能解释这种行为?

Jon*_*eet 62

我可以对这里发生的事情做出合理的猜测,但这有点复杂:) 它涉及规范草案中描述空状态和空跟踪。从根本上说,在我们想要返回的地方,如果表达式的状态是“maybe null”而不是“not null”,编译器会发出警告。

这个答案是某种叙述形式,而不仅仅是“这是结论”……我希望这样更有用。

我将通过删除字段来稍微简化示例,并考虑具有以下两个签名之一的方法:

public static string M(string? text)
public static string M(string text)
Run Code Online (Sandbox Code Playgroud)

在下面的实现中,我为每个方法指定了不同的编号,以便我可以明确地引用特定示例。它还允许所有实现都出现在同一个程序中。

在下面描述的每种情况下,我们会做各种事情,但最终会尝试返回text- 所以它的 null 状态text很重要。

无条件退货

首先,让我们尝试直接返回它:

public static string M1(string? text) => text; // Warning
public static string M2(string text) => text;  // No warning
Run Code Online (Sandbox Code Playgroud)

到此为止,就这么简单。方法开始时参数的可空状态如果是 type 则为“maybe null”,如果它是 typestring?则为“not null” string

简单的条件返回

现在让我们在if语句条件本身中检查 null 。(我会使用条件运算符,我相信它会产生相同的效果,但我想更真实地回答这个问题。)

public static string M3(string? text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}

public static string M4(string text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}
Run Code Online (Sandbox Code Playgroud)

太好了,所以看起来就像在if条件本身检查空值的if语句中,语句的每个分支内的变量状态可能不同:在else块内,两段代码中的状态都是“非空”。因此,特别是在 M3 中,状态从“可能为空”变为“不为空”。

带局部变量的条件返回

现在让我们尝试将该条件提升到局部变量:

public static string M5(string? text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}

public static string M6(string text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}
Run Code Online (Sandbox Code Playgroud)

无论M5和M6发出警告。因此,我们不仅没有在 M5 中获得从“可能为空”到“非空”的状态变化的积极影响(就像我们在 M3 中所做的那样)……我们在 M6 中得到了相反的效果,状态从“不为空”到“可能为空”。这真的让我很惊讶。

所以看起来我们已经了解到:

  • 围绕“如何计算局部变量”的逻辑不用于传播状态信息。稍后再谈。
  • 引入 null 比较可以警告编译器它以前认为不是 null 的东西毕竟可能是 null。

忽略比较后无条件返回

让我们通过在无条件返回之前引入比较来看看其中的第二个要点。(所以我们完全忽略了比较的结果。):

public static string M7(string? text)
{
    bool ignored = text is null;
    return text; // Warning
}

public static string M8(string text)
{
    bool ignored = text is null;
    return text; // Warning
}
Run Code Online (Sandbox Code Playgroud)

请注意 M8 感觉它应该与 M2 等效 - 两者都有一个它们无条件返回的非空参数 - 但引入与 null 的比较将状态从“not null”更改为“maybe null”。我们可以通过尝试text在条件之前取消引用来获得进一步的证据:

public static string M9(string text)
{
    int length1 = text.Length;   // No warning
    bool ignored = text is null;
    int length2 = text.Length;   // Warning
    return text;                 // No warning
}
Run Code Online (Sandbox Code Playgroud)

请注意该return语句现在如何没有警告:执行的状态text.Length为“非空”(因为如果我们成功执行该表达式,则它不可能为空)。因此,text参数由于其类型而以“not null”开头,由于 null 比较而变为“maybe null”,然后在text2.Length.

哪些比较会影响状态?

所以这是一个比较text is null...类似的比较有什么影响?这里还有四个方法,都以不可为空的字符串参数开头:

public static string M10(string text)
{
    bool ignored = text == null;
    return text; // Warning
}

public static string M11(string text)
{
    bool ignored = text is object;
    return text; // No warning
}

public static string M12(string text)
{
    bool ignored = text is { };
    return text; // No warning
}

public static string M13(string text)
{
    bool ignored = text != null;
    return text; // Warning
}
Run Code Online (Sandbox Code Playgroud)

因此,即使x is object现在是 的推荐替代方案x != null,它们也没有相同的效果:只有与 null(与is, ==or 中的任何一个)的比较才能!=将状态从“not null”更改为“maybe null”。

为什么吊装条件有影响?

回到我们之前的第一个要点,为什么 M5 和 M6 不考虑导致局部变量的条件?这并不像让其他人感到惊讶那样让我感到惊讶。将这种逻辑构建到编译器和规范中需要大量工作,但收益相对较小。这是另一个与可空性无关的示例,其中内联某些内容会产生影响:

public static int X1()
{
    if (true)
    {
        return 1;
    }
}

public static int X2()
{
    bool alwaysTrue = true;
    if (alwaysTrue)
    {
        return 1;
    }
    // Error: not all code paths return a value
}
Run Code Online (Sandbox Code Playgroud)

即使我们知道这alwaysTrue总是正确的,但它并不能满足规范中使if语句后的代码无法访问的要求,而这正是我们所需要的。

这是另一个例子,围绕明确的分配:

public static void X3()
{
    string x;
    bool condition = DateTime.UtcNow.Year == 2020;
    if (condition)
    {
        x = "It's 2020.";
    }
    if (!condition)
    {
        x = "It's not 2020.";
    }
    // Error: x is not definitely assigned
    Console.WriteLine(x);
}
Run Code Online (Sandbox Code Playgroud)

即使我们知道代码将完全输入这些if语句体之一,但规范中没有任何内容可以解决这个问题。静态分析工具很可能能够做到这一点,但是尝试将其放入语言规范中将是一个坏主意,IMO - 静态分析工具具有可以随时间演变的各种启发式方法很好,但不是那么多对于语言规范。

  • 伟大的分析乔恩。我在研究 Coverity 检查器时学到的关键一点是,代码是*其作者信念的证据*。当我们看到空检查时,应该通知我们代码的作者认为该检查是必要的。检查器实际上是在寻找“作者信念不一致的证据”,因为正是在这些地方,我们看到了关于错误发生的不一致信念,例如无效性。 (7认同)
  • 当我们看到例如 `if (x != null) x.foo(); 时 x.bar();`我们有两个证据;`if` 语句是“作者认为在调用 foo 之前 x 可能为空”这一命题的证据,而下面的语句是“作者认为在调用 bar 之前 x 不为空”的证据,并且这种矛盾得出存在错误的结论。该错误要么是不必要的空检查的相对良性错误,要么是潜在的崩溃错误。哪个bug是真正的bug尚不清楚,但很明显是有一个。 (6认同)
  • @ArnonAxelrod 这表示它不是“意味着”为空。它仍然可能为 null,因为可空引用类型只是编译器提示。(示例:M8(null!);或从 C# 7 代码调用它,或忽略警告。)它与平台其余部分的类型安全不同。 (4认同)
  • “is object”、“is {}”和“!= null”之间的不一致是我们过去几周一直在内部讨论的一个问题。我们将在不久的将来在 LDM 上提出它,以决定我们是否需要将这些视为纯空检查(这使得行为一致)。 (3认同)

小智 51

可空流分析跟踪变量的空状态,但不跟踪其他状态,例如bool变量的值(isNull如上),也不跟踪单独变量(例如isNull_test)的状态之间的关系。

实际的静态分析引擎可能会做这些事情,但在某种程度上也会是“启发式的”或“任意的”:您不一定能说出它遵循的规则,这些规则甚至可能会随着时间而改变。

这不是我们可以直接在 C# 编译器中做的事情。可空警告的规则非常复杂(正如 Jon 的分析所示!),但它们是规则,可以推理。

当我们推出该功能时,感觉我们大部分时间都达到了正确的平衡,但有一些地方确实很尴尬,我们将在 C# 9.0 中重新审视这些地方。

  • 当 C# 程序经理回复时,您就知道您的问题是合法的! (16认同)
  • 您知道您想将晶格理论纳入规范中;晶格理论*棒*而且一点也不混乱!做吧!:) (5认同)
  • @TanveerBadar:格子理论是关于具有偏序的值集的分析;类型就是一个很好的例子;如果类型 X 的值可分配给类型 Y 的变量,则意味着 Y “足够大”以容纳 X,并且足以形成一个格,然后它告诉我们可以将编译器中的可分配性检查表述为在晶格理论的规范中。这与静态分析相关,因为除了类型可分配性之外,分析器感兴趣的许多主题也可以用格来表达。 (2认同)
  • 鉴于这个答案是“来自马口”,我已将其标记为答案,而不是乔恩的答案(尽管乔恩显然是一个很好的答案!) (2认同)

Eri*_*ert 31

您已经发现证据表明,在跟踪局部变量中编码的含义时,产生此警告的程序流算法相对简单。

我对流检查器的实现没有特定的了解,但过去曾研究过类似代码的实现,我可以做出一些有根据的猜测。流检查器可能会在误报情况下推断出两件事:(1)_test可能为空,因为如果不能,您将无法进行比较,以及(2)isNull可能为真或为假——因为如果它不能,你就不会在if. 但是return _test;唯一运行的连接如果_test不为空,则不会建立该连接。

这是一个非常棘手的问题,您应该预计编译器需要一段时间才能达到专家多年工作的工具的复杂程度。例如,Coverity 流检查器在推断您的两个变体都没有空返回时完全没有问题,但是 Coverity 流检查器对企业客户来说花费了大量资金。

此外,Coverity 检查器旨在在一夜之间在大型代码库上运行;C# 编译器的分析必须在编辑器中的击键之间运行,这显着改变了您可以合理执行的深入分析的种类。


小智 11

所有其他答案几乎完全正确。

如果有人好奇,我尝试在https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947 中尽可能明确地说明编译器的逻辑

没有提到的一点是我们如何决定是否应该将空检查视为“纯”,从某种意义上说,如果您这样做,我们应该认真考虑空检查是否有可能。在 C# 中有很多“附带”空检查,您在其中测试空值作为做其他事情的一部分,因此我们决定将检查范围缩小到我们确定人们有意进行的检查。我们想出了启发式是“包含单词空”,所以这就是为什么x != nullx is object产生不同的结果。