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

Posted

技术标签:

【中文标题】为啥这段代码会给出“可能的空引用返回”编译器警告?【英文标题】:Why does this code give a "Possible null reference return" compiler warning?为什么这段代码会给出“可能的空引用返回”编译器警告? 【发布时间】:2020-04-05 23:20:38 【问题描述】:

考虑以下代码:

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;
        

谁能解释这种行为?

【问题讨论】:

Debug.Assert 无关紧要,因为这是运行时检查,而编译器警告是编译时检查。编译器无权访问运行时行为。 The Debug.Assert is irrelevant because that is a runtime check - 它相关的,因为如果您注释掉该行,警告就会消失。 在我看来这是 .NET 4.8 中的一个错误......一个已经初始化的只读字段永远不会为空......所以警告无法正常工作 我在这里添加了很多不同的案例,并且有一些非常有趣的结果。稍后会写一个答案 - 现在要做的工作。 @EricLippert: Debug.Assert 现在有一个 DoesNotReturnIf(false) 的注释 (src) 用于条件参数。 【参考方案1】:

我可以对这里发生的事情做出合理的猜测,但这有点复杂:) 它涉及null state and null tracking described in the draft spec。从根本上说,在我们想要返回的地方,如果表达式的状态是“可能为空”而不是“非空”,编译器会发出警告。

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

我将通过去掉字段来稍微简化示例,并考虑使用以下两个签名之一的方法:

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

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

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

无条件返回

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

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

到目前为止,一切都很简单。如果参数的类型为string?,则方法开头的参数的可空状态为“可能为空”,如果其类型为string,则为“非空”。

简单的条件返回

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

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 块中,状态在两段代码中都是“不为空”。所以特别是在 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
    

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

看来我们已经了解到:

围绕“如何计算局部变量”的逻辑不用于传播状态信息。稍后会详细介绍。 引入 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

注意 M8 感觉它应该等同于 M2 - 两者都有一个非空参数,它们无条件返回 - 但是引入与 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

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

哪些比较会影响状态?

所以这是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

因此,即使 x is object 现在是 x != null 的推荐替代品,它们的效果也不相同:仅与 null 比较(与 is、@987654346 中的任何一个进行比较@ 或 !=) 将状态从“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 永远为真,但它不满足规范中的要求,即使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);

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

【讨论】:

伟大的分析乔恩。我在研究 Coverity 检查器时学到的关键是代码是其作者信念的证据。当我们看到一个空检查应该告诉我们代码的作者认为检查是必要的。检查器实际上是在寻找作者的信念不一致的证据,因为正是在我们看到关于无效性的不一致信念的地方才会发生错误。 当我们看到例如if (x != null) x.foo(); x.bar();时,我们有两个证据; if 语句是“作者认为 x 在调用 foo 之前可能为空”这一命题的证据,以下语句是“作者认为 x 在调用 bar 之前不为空”的证据,并且这个矛盾得出存在错误的结论。该错误要么是不必要的空检查的相对良性错误,要么是潜在的崩溃错误。哪个 bug 是真正的 bug 不清楚,但很明显有一个。 相对简单的检查器不会跟踪本地人的含义,也不会修剪“错误路径”——人类可以告诉你的控制流路径是不可能的——往往会产生误报的问题正是因为他们没有准确地模拟作者的信念。这是棘手的一点! “is object”、“is ”和“!= null”之间的不一致是我们过去几周一直在内部讨论的问题。将在不久的将来在 LDM 上提出它,以决定我们是否需要将这些视为纯空检查(这使得行为一致)。 @ArnonAxelrod 这表示它不意味着为空。它仍然可以为空,因为可空引用类型只是编译器提示。 (示例:M8(null!); 或从 C# 7 代码中调用它,或忽略警告。)它不像平台其余部分的类型安全。【参考方案2】:

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

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

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

当我们推出该功能时,感觉我们基本上达到了正确的平衡,但也有一些地方确实令人尴尬,我们将在 C# 9.0 中重新审视这些。

【讨论】:

你知道你想把格理论放在规范中;晶格理论真棒而且一点也不混乱!去做吧! :) 当 C# 项目经理回复时,您就知道您的问题是合法的! @TanveerBadar:格理论是关于分析具有偏序的值集;类型就是一个很好的例子;如果 X 类型的值可分配给 Y 类型的变量,那么这意味着 Y“足够大”以容纳 X,并且足以形成格子,然后告诉我们在编译器中检查可分配性可以表述为在晶格理论方面的规范中。这与静态分析有关,因为除了类型可分配性之外,分析器感兴趣的许多主题也可以用格表示。 @TanveerBadar:lara.epfl.ch/w/_media/sav08:schwartzbach.pdf 提供了一些关于静态分析引擎如何使用格理论的很好的介绍性示例。 @EricLippert Awesome 并没有开始描述你。该链接将立即进入我的必读列表。【参考方案3】:

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

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

这是一个令人惊讶的棘手问题,您应该预计编译器需要一段时间才能获得专家多年工作的复杂工具。例如,Coverity 流量检查器可以毫无问题地推断出您的两个变体中的任何一个都没有零回报,但 Coverity 流量检查器会为企业客户花费大量资金。

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

【讨论】:

"Unsophisticated" 是对的——我认为如果它偶然发现条件句之类的东西是可以原谅的,因为我们都知道在这些问题上停止问题有点棘手,但事实上有bool b = x != nullbool b = x is 之间的差异(实际上没有使用任何赋值!)表明即使是公认的空检查模式也是有问题的。不要贬低团队毫无疑问的辛勤工作,以使这项工作主要针对真实的、正在使用的代码库 - 看起来分析是大写-P 务实的。 @JeroenMostert:Jared Par 在Jon Skeet's answer 的评论中提到微软正在内部讨论这个问题。【参考方案4】:

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

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

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

【讨论】:

以上是关于为啥这段代码会给出“可能的空引用返回”编译器警告?的主要内容,如果未能解决你的问题,请参考以下文章

verilog,为啥设计组合电路时不能引入反馈

为啥在返回右值引用时给出 C++ 编译器警告?

为啥 gcc 给出警告:函数 qsort_r 的隐式声明?

为啥这个 Java 代码会编译?

为啥这段代码会导致编译器在 Xcode 中报告“未使用的变量”

或者是无效的 C++:为啥这段代码会编译?