为什么此代码会给出 "Possible null reference return" 编译器警告?

Why does this code give a "Possible null reference return" compiler warning?

考虑以下代码:

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

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

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

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

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

            if (_test == null)
                return "";
            else
                return _test;
        }

任何人都可以解释这种行为吗?

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

我对流检查器的实现没有具体的了解,但过去从事过类似代码的实现,我可以做出一些有根据的猜测。流量检查器 可能 在误报情况下推断出两件事:(1) _test 可能为空,因为如果不能,您将不会在第一,并且 (2) isNull 可以是真或假——因为如果它不能,你就不会把它放在 if 中。但是如果 _test 不为空,return _test; 只有 运行 的连接,则不会建立该连接。

这是一个非常棘手的问题,您应该预料到编译器需要一段时间才能达到专家们多年工作的工具的复杂性。例如,Coverity 流量检查器可以毫无问题地推断出您的两个变体都没有空值 return,但是 Coverity 流量检查器对企业客户来说要花很多钱。

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

我可以合理地猜测这里发生了什么,但这有点复杂:)它涉及到null state and null tracking described in the draft spec。从根本上说,在我们想要 return 的地方,如果表达式的状态是 "maybe null" 而不是 "not null".

,编译器将发出警告

这个答案有点叙述性,而不仅仅是 "here's the conclusions"...我希望这样更有用。

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

public static string M(string? text)
public static string M(string text)

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

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

无条件return

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

public static string M1(string? text) => text; // Warning
public static string M2(string text) => text;  // No warning

到目前为止,很简单。如果参数的类型为 string?,则该方法开头的参数可为空状态为 "maybe null";如果其类型为 string.

,则为 "not null"

简单条件 return

现在让我们在 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
    }
}

太好了,所以它看起来像在条件本身检查是否为空的 if 语句中,if 语句的每个分支中变量的状态可以不同:在 else块,两段代码的状态都是"not null"。因此,特别是在 M3 中,状态从 "maybe null" 变为 "not null"。

有条件的 return 带有局部变量

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

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

M5 和 M6 都发出警告。所以我们不仅没有得到 M5 中状态从 "maybe null" 到 "not null" 的积极影响(就像我们在 M3 中所做的那样)...我们得到了 相反的 在 M6 中生效,状态从 "not null" 变为 "maybe null"。真是让我吃惊。

看来我们已经了解到:

  • 围绕 "how a local variable was computed" 的逻辑不用于传播状态信息。稍后会详细介绍。
  • 引入 null 比较可以警告编译器它以前认为不为 null 的东西可能毕竟是 null。

无条件 return 在忽略比较后

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

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
}

请注意 M8 感觉它应该等同于 M2 - 两者都有一个非空参数,它们 return 无条件 - 但是引入与 null 的比较会改变状态 "not null"到 "maybe null"。我们可以通过尝试在条件 text 之前取消引用 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
}

请注意 return 语句现在如何没有警告:在 执行 text.Length 之后的状态 是 "not null"(因为如果我们成功执行了该表达式,它不能为空)。因此 text 参数由于其类型而以 "not null" 开头,由于空值比较而变为 "maybe null",然后在 text2.Length.[=42 之后再次变为 "not null" =]

哪些比较会影响状态?

所以这是 text is null 的比较...类似比较有什么影响?这里还有四种方法,都以不可为 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
}

所以尽管 x is object 现在是 x != null 的推荐替代品,但它们没有相同的效果:只是 与 null 的比较(使用 is==!= 中的任何一个)将状态从 "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
}

即使我们知道alwaysTrue永远为真,它不满足规范中对[=23=之后的代码的要求]声明不可达,这就是我们需要的。

这是另一个关于明确赋值的例子:

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

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

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

实际的静态分析引擎可能会做这些事情,但在某种程度上也会是 "heuristic" 或 "arbitrary":你不一定能说出它遵循的规则,以及那些规则甚至可能会随着时间而改变。

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

随着我们推出该功能,感觉我们基本上取得了正确的平衡,但也有一些地方确实令人尴尬,我们将针对 C# 9.0 重新审视这些地方。

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

如果有人好奇,我试图在 https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

中尽可能明确地阐明编译器的逻辑

没有提及的一点是我们如何决定是否应考虑空检查 "pure",从某种意义上说,如果您这样做,我们应该认真考虑是否有可能出现空检查。在 C# 中有很多 "incidental" 空值检查,您在其中测试空值是做其他事情的一部分,因此我们决定将检查集缩小到我们确信人们正在做的检查故意地。我们提出的启发式是 "contains the word null",所以这就是 x != nullx is object 产生不同结果的原因。