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



考虑以下代码:

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

有人能解释这种行为吗?

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

这个答案在某种程度上是叙事形式,而不仅仅是"结论如下"。。。我希望这样更有用。

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

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?,则该参数在方法开始时的可为null状态为"maybe null";如果参数为string,则该状态为"not null"。

简单条件返回

现在,让我们检查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"。

带有局部变量的条件返回

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

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参数,它们无条件返回——但引入与null的比较将状态从"非null"更改为"可能为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"(因为如果我们成功执行该表达式,它就不可能是null)。因此,text参数由于其类型而从"not null"开始,由于null比较而变为"maybe null",然后在text2.Length之后再次变为"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将始终为真,但它并不满足规范中使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——静态分析工具可以拥有各种启发式,这些启发式可以随着时间的推移而发展,但对于语言规范来说就不那么好了。

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

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

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

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

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

我对流检查器的实现没有具体的了解,但在过去研究过类似代码的实现后,我可以做出一些有根据的猜测。流检查器很可能在假阳性的情况下推断出两件事:(1)_test可能为null,因为如果它不为null,你就不会首先进行比较;(2)isNull可能为true或false,因为如果不能,你就无法在if中进行比较。但是return _test;只有在_test不为空时才运行的连接,该连接没有建立。

这是一个令人惊讶的棘手问题,您应该预计编译器需要一段时间才能达到专家多年工作的工具的复杂程度。例如,Coverity流量检查器在推断您的两个变体都没有零回报时完全没有问题,但Coverity流程检查器为公司客户花费了大量资金。

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

所有其他答案都非常正确。

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

有一件事没有提到,那就是我们如何决定是否应该将空检查视为"纯粹",因为如果你这样做,我们应该认真考虑空检查是否有可能。C#中有很多"偶然"的null检查,在做其他事情的过程中,你可以测试null,所以我们决定将检查范围缩小到我们确信人们是故意做的检查。我们提出的启发式方法是"包含单词null",这就是为什么x != nullx is object会产生不同的结果。

从C#8.0开始,您可以使用;零容忍运算符";在你的台词末尾。(感叹号),以便删除警告。像这样:

...
else
return _test!;

最新更新