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


70

考虑以下代码:

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是只读的并且初始化为非null。

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

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

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

谁能解释这种行为?


1
Debug.Assert是无关紧要的,因为这是运行时检查,而编译器警告是编译时检查。编译器无权访问运行时行为。
Polyfun

5
The Debug.Assert is irrelevant because that is a runtime check-这重要,因为如果您注释掉该行,警告就会消失。
马修·沃森

1
@Polyfun:编译器可以潜在地(通过属性)知道Debug.Assert如果测试失败将抛出异常。
乔恩·斯基特

2
我在这里添加了许多不同的案例,并且有一些非常有趣的结果。稍后将写出答案-现在要做。
乔恩·斯基特

2
@EricLippert:Debug.Assert现在具有用于条件参数的注释(srcDoesNotReturnIf(false)
乔恩·斯基特

Answers:


38

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

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

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

当我们推出该功能时,感觉好像我们大多数时候已经达到了适当的平衡,但是确实有些地方确实很尴尬,我们将在C#9.0中重新讨论这些功能。


3
您知道您想将晶格理论放入规范中。晶格理论很棒,一点也不令人困惑!做吧!:)
埃里克·利珀特

7
当C#的程序管理器响应时,您知道您的问题是合法的!
山姆·鲁比

1
@TanveerBadar:格子理论是关于具有偏序的值集的分析;类型是一个很好的例子;如果将类型X的值分配给类型Y的变量,则意味着Y“足够大”以容纳X,并且足以形成晶格,这随后告诉我们在编译器中检查可分配性可以用词组表示根据晶格理论在规范中。这与静态分析有关,因为除类型可分配性之外,分析器还感兴趣的许多主题都可以用格来表示。
埃里克·利珀特

1
@TanveerBadar:lara.epfl.ch/w/_media/sav08:schwartzbach.pdf提供了一些很好的介绍性示例,说明了静态分析引擎如何使用晶格理论。
埃里克·利珀特

1
@EricLippert Awesome尚未开始描述您。该链接将立即进入我的必读列表。
Tanveer Badar

56

我可以对这里发生的事情做出一个合理的猜测,但这有点复杂:)它涉及规范草案中描述null状态和null跟踪。从根本上说,在我们要返回的位置,编译器将警告表达式的状态是“也许为空”而不是“非空”。

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

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

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

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

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

无条件退货

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

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

到目前为止,如此简单。在方法开始时,参数的可空状态为“可能为null”(如果类型为),string?而为“ 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
    }
}

太好了,因此看起来像在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中效果,其中状态从“不为空”到“可能为空”。真的让我感到惊讶。

看来我们已经了解到:

  • 围绕“如何计算局部变量”的逻辑不用于传播状态信息。以后再说。
  • 引入空比较可以警告编译器,它以前认为不为空的内容可能最终还是为空。

比较被忽略后无条件返回

让我们通过无条件返回之前的比较来看看其中的第二点。(因此,我们完全忽略了比较结果。):

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-两者都具有一个非空参数,它们无条件返回-但是引入空值比较会将状态从“非空值”更改为“可能为空值”。我们可以通过尝试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为“不为空”(因为如果成功执行该表达式,则不能为空)。因此text参数由于其类型而从“ not null”开始,由于null比较而变为“ may null”,然后在之后再次变为“ not 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
}

因此,尽管x is object现在推荐使用x != null,但它们不会产生相同的效果:仅与null(带有is==!=)可以将状态从“ not null”更改为“也许为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-静态分析工具具有可以随时间发展而来的各种启发式方法是很好的,但不是那么多语言规范。


7
伟大的分析乔恩。在研究Coverity Checker时,我学到的关键是代码是其作者信念的证据。当我们看到一个空检查时,应该通知我们代码的编写者认为该检查是必要的。检查员实际上是在寻找作者的信念不一致的证据,因为在这里我们看到关于错误发生的不一致信念(例如无效)的地方。
埃里克·利珀特

6
例如,当我们看到if (x != null) x.foo(); x.bar();两个证据时;该if陈述是命题的证据“作者认为在调用foo之前x可能为空”,而以下陈述是“作者认为在调用bar之前x不会为null”的证据,这种矛盾导致了结论是存在错误。该错误可能是不必要的空检查的相对良性错误,也可能是崩溃的错误。哪一个错误是真正的错误尚不清楚,但是很明显有一个错误。
埃里克·利珀特

1
问题在于,相对简单的检查程序不会跟踪本地人的含义,并且不会修剪“错误路径”(控制人类可以告诉您的流路径是不可能的),正是由于他们没有准确地对错误的模型进行建模,所以往往会产生误报。作者的信念。有点棘手!
埃里克·利珀特

3
“ is object”,“ is {}”和“!= null”之间的不一致是我们最近几周在内部讨论的项目。将在不久的将来在LDM上进行讨论,以决定是否需要将它们视为纯null检查(这将使行为保持一致)。
JaredPar

1
@ArnonAxelrod这表示,它并不意味着为空。它仍然可以为null,因为可为空的引用类型仅是编译器提示。(例如:M8(null!);或者从C#7代码中调用它,或者忽略警告。)这与平台其余部分的类型安全不同。
乔恩·斯基特

29

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

我对流检查器的实现没有特别的了解,但是在过去使用类似代码的实现时,我可以做出一些有根据的猜测。流检查器可能会在误报情况下推断出两件事:(1)_test可能为null,因为如果不能,则首先不会进行比较;(2)isNull可能为true或false –因为如果不能,则您不会在中使用它if。但是return _test;如果唯一运行的连接_test不为null,则不会建立该连接。

这是一个令人难以置信的棘手问题,您应该期望编译器花一些时间才能获得经过专家多年工作的先进工具。例如,Coverity流程检查器在推论两个变体都没有零收益方面完全没有问题,但是Coverity流程检查器为公司客户花费了大笔钱。

另外,Coverity检查程序设计为在一夜之间在大型代码库上运行;C#编译器的分析必须在编辑器中的两次击键之间运行,这将大大改变您可以合理执行的深度分析的种类。


“不老练”是正确的-我认为,如果偶然遇到诸如条件这样的事情,这是可以原谅的,因为我们都知道,停顿问题在这些问题上有点棘手,但事实是bool b = x != nullvs与bool b = x is { }(均未实际使用!)表明,即使是公认的空检查模式也存在问题。不要贬低团队的辛勤工作,使之基本上像真正的,正在使用的代码库一样进行工作-看起来分析是大写P实用的。
Jeroen Mostert

@JeroenMostert:Jared Par在对Jon Skeet的回答的评论中提到微软正在内部讨论该问题。
布赖恩

8

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

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

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.