C# 编译器解释的 null 状态静态分析的属性

在启用可为 null 的上下文中,编译器对代码执行静态分析,以确定所有引用类型变量的 null 状态:

  • null 状态:静态分析确定变量具有非 null 值。
  • 可能为 null:静态分析无法确定是否为变量分配了非 null 值。

这些状态使编译器能够在你可能取消引用 null 值并引发 System.NullReferenceException 时提供警告。 这些属性根据参数和返回值的状态为编译器提供有关参数、返回值和对象成员的 null 状态的语义信息。 当 API 已正确注释此语义信息时,编译器会提供更准确的警告。

本文提供每个可为 null 引用类型特性的简要说明以及它们的使用方法。

我们从一个示例开始。 假设你的库具有以下用于检索资源字符串的 API。 此方法最初是在忽略可为 null 的上下文中编译的:

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

前面的示例遵循 .NET 中熟悉的 Try* 模式。 此 API 有两个引用参数:keymessage。 此 API 具有与这些参数的 null 状态相关的以下规则:

  • 调用方不应将 null 作为 key 的参数传递。
  • 调用方可以传递值为 null 的变量作为 message 的参数。
  • 如果 TryGetMessage 方法返回 true,则 message 的值不为 null。 如果返回值是 false,,则 message 的值为 null。

key 的规则可以简洁地表示:key 应是不可为 null 的引用类型。 message 参数更复杂。 它允许将 null 变量作为参数,但保证成功时 out 参数不是 null。 对于这些情况,需要使用更丰富的词汇来描述期望。 下文描述的 NotNullWhen 属性描述了用于 message 参数的自变量的 null 状态。

注意

添加这些特性将为编译器提供有关 API 规则的更多信息。 当调用代码在可为 null 的上下文中编译时,编译器将在调用方违反这些规则时发出警告。 这些特性不会启用对实现进行更多检查。

Attribute Category 含义
AllowNull Precondition 不可为 null 的参数、字段或属性可以为 null。
DisallowNull Precondition 可为 null 的参数、字段或属性应永不为 null。
MaybeNull 后置条件 不可为 null 的参数、字段、属性或返回值可能为 null。
NotNull 后置条件 可为 null 的参数、字段、属性或返回值将永不为 null。
MaybeNullWhen 有条件后置条件 当方法返回指定的 bool 值时,不可为 null 的参数可以为 null。
NotNullWhen 有条件后置条件 当方法返回指定的 bool 值时,可以为 null 的参数不会为 null。
NotNullIfNotNull 有条件后置条件 如果指定参数的自变量不为 null,则返回值、属性或自变量不为 null。
MemberNotNull 方法和属性帮助程序方法 当方法返回时,列出的成员不会为 null。
MemberNotNullWhen 方法和属性帮助程序方法 当方法返回指定的 bool 值时,列出的成员不会为 null。
DoesNotReturn 无法访问的代码 方法或属性永远不会返回。 换句话说,它总是引发异常。
DoesNotReturnIf 无法访问的代码 如果关联的 bool 参数具有指定值,则此方法或属性永远不会返回。

上述说明是对每个特性的快速参考。 以下各节更详尽地描述了这些属性的行为和含义。

前置条件:AllowNullDisallowNull

请考虑一个从不返回 null 的读/写属性,因为它具有合理的默认值。 调用方在将 null 设置为该默认值时将其传递给 set 访问器。 例如,假设一个消息系统要求在聊天室中输入一个屏幕名称。 如果未提供任何内容,系统将生成一个随机名称:

public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName;

当你在忽略可为 null 的上下文中编译前面的代码时,一切都是正常的。 启用可为 null 的引用类型后,ScreenName 属性将成为不可为 null 的引用。 这对于 get 访问器是正确的:它从不返回 null。 调用方不需要检查返回的 null 属性。 但现在将属性设置为 null 将生成警告。 若要支持这种类型的代码,请向属性添加 System.Diagnostics.CodeAnalysis.AllowNullAttribute 特性,如下面的代码所示:

[AllowNull]
public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName = GenerateRandomScreenName();

可能需要为 System.Diagnostics.CodeAnalysis 添加一个 using 指令才能使用本文中讨论的特性和其他特性。 特性应用于属性,而不是 set 访问器。 AllowNull 特性指定前置条件,并且仅适用于参数。 get 访问器有一个返回值,但没有参数。 因此,AllowNull 特性只适用于 set 访问器。

前面的示例演示了在参数上添加 AllowNull 特性时要查找的内容:

  1. 该变量的一般约定是它不应为 null,因此需要一个不可为 null 的引用类型。
  2. 在某些情况下,调用方将 null 作为参数传递,但它们不是最常见的用法。

大多数情况下,属性或 inoutref 参数需要此特性。 当变量通常为非 null 时,AllowNull 属性是最佳选择,但需要允许 null 作为前提条件。

将此与使用 DisallowNull 的方案相比:使用此特性可以指定可为 null 引用类型的参数不应为 null。 请考虑一个特性,其中 null 是默认值,但客户端只能将其设置为非 null 值。 考虑下列代码:

public string ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string _comment;

前面的代码是表达设计的最佳方式,ReviewComment 可以是 null,但不能设置为 null。 代码可识别为 null 后,你就可以使用 System.Diagnostics.CodeAnalysis.DisallowNullAttribute 向调用方更清楚地表达此概念:

[DisallowNull]
public string? ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string? _comment;

在可为 null 的上下文中,ReviewCommentget 访问器可以返回默认值 null。 编译器会警告在访问之前必须进行检查。 此外,它警告调用方,即使它可能是 null,调用方也不应显式地将其设置为 nullDisallowNull 特性还指定了前置条件,它不影响 get 访问器。 当你观察到以下特征时,可以使用 DisallowNull 特性:

  1. 在核心方案中(通常是在首次实例化时),变量可以是 null
  2. 变量不应显式设置为 null

这些情况在忽略 null 的代码中很常见 。 可能是在两个不同的初始化操作中设置了对象属性。 可能只有在某些异步工作完成后才能设置某些属性。

可使用 AllowNullDisallowNull 特性指定变量上的前置条件可能与这些变量上的可为 null 注释不匹配。 这两个特性提供了关于 API 特征的更多细节。 此附加信息有助于调用方正确使用 API。 请记住,可使用以下特性指定前提条件:

  • AllowNull:不可为 null 的参数可以为 null。
  • DisallowNull:可为 null 的参数不应为 null。

后置条件:MaybeNullNotNull

假设你有使用以下签名的方法:

public Customer FindCustomer(string lastName, string firstName)

你可能已经编写了类似的方法,以便在未找到所查找的名称时返回 nullnull 清楚地表明未找到记录。 在本例中,你可能会将返回类型从 Customer 更改为 Customer?。 将返回值声明为可为 null 的引用类型可以清楚地指定此 API 的意图:

public Customer? FindCustomer(string lastName, string firstName)

由于泛型为 null 性中所述的原因,该技术可能无法生成与 API 匹配的静态分析。 你可能具有遵循类似模式的泛型方法:

public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

当找不到所需项时,此方法返回 null。 可以通过将 MaybeNull 注释添加到方法返回来阐明该方法在未找到项目时返回 null

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

前面的代码通知调用方返回值可能实际上为 null。 它还通知编译器该方法可能会返回一个 null 表达式,即使该类型不可为 null。 当你有一个返回其类型参数 T 的实例的泛型方法时,你可以使用 NotNull 属性表示它永远不会返回 null

还可以指定返回值或者参数不为 null,即使该类型是可为 null 的引用类型。 以下方法是一种帮助器方法,如果其第一个参数为 null,则引发该方法:

public static void ThrowWhenNull(object value, string valueExpression = "")
{
    if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}

可以按如下方式调用此例程:

public static void LogMessage(string? message)
{
    ThrowWhenNull(message, $"{nameof(message)} must not be null");

    Console.WriteLine(message.Length);
}

启用 null 引用类型后,需要确保前面的代码在编译时没有警告。 当方法返回时,value 参数保证不为 null。 但是,可以使用 null 引用调用 ThrowWhenNull。 可以将 value 设为可为 null 的引用类型,并将 NotNull 后置条件添加到参数声明中:

public static void ThrowWhenNull([NotNull] object? value, string valueExpression = "")
{
    _ = value ?? throw new ArgumentNullException(nameof(value), valueExpression);
    // other logic elided

前面的代码清楚地表达了现有协定:调用方可以传递具有 null 值的变量,但如果该方法在未引发异常的情况下返回,则保证该参数永远不为 null。

可以使用以下特性指定无条件后置条件:

  • MaybeNull:不可为 null 的返回值可以为 null。
  • NotNull:可为 null 的返回值永远不会为 null。

有条件后置条件:NotNullWhenMaybeNullWhenNotNullIfNotNull

你可能很熟悉 string 方法 String.IsNullOrEmpty(String)。 当参数为 null 或为空字符串时,此方法返回 true。 这是一种 null 检查格式:如果方法返回 false,调用方不需要 null 检查参数。 若要使这样的方法可识别为 null,需要将参数设置为可为 null 的引用类型,并添加 NotNullWhen 特性:

bool IsNullOrEmpty([NotNullWhen(false)] string? value)

这通知编译器,任何返回值为 false 的代码都不需要 null 检查。 添加特性通知编译器的静态分析,IsNullOrEmpty 执行必要的 null 检查:当它返回 false 时,参数不是 null

string? userInput = GetUserInput();
if (!string.IsNullOrEmpty(userInput))
{
    int messageLength = userInput.Length; // no null check needed.
}
// null check needed on userInput here.

注意

前面的示例仅适用于 C# 11 及更高版本。 从 C# 11 开始,nameof expression 可在用于应用于方法的属性时引用参数和类型参数名称。 在 C# 10 及更早版本中,需要使用字符串字面量而不是 nameof 表达式。

对于 .NET Core 3.0,将对 String.IsNullOrEmpty(String) 方法进行注释,如上面所示。 代码库中可能有类似的方法来检查对象的状态是否为 null 值。 编译器无法识别自定义的 null 检查方法,你需要自己添加注释。 添加特性时,编译器的静态分析将知道何时对测试变量进行了 null 检查。

这些特性的另一个用途是 Try* 模式。 refout 参数的后置条件通过返回值进行通信。 考虑前面显示的这个方法(在一个禁用可为 null 的上下文中):

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

前面的方法遵循典型的 .NET 习惯用法:返回值指示是否将 message 设置为已找到的值,或者,如果未找到消息,则为默认值。 如果方法返回 truemessage 的值不为 null;否则,该方法将 message 设置为 null。

在启用可为 null 的上下文中,可以使用 NotNullWhen 属性传达该习惯用法。 注释可为 null 引用类型的参数时,将 message 设为 string? 并添加属性:

bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message is not null;
}

在前面的示例中,message 的值在 TryGetMessage 返回 true 时不为 null。 你应以相同的方式在代码库中注释类似的方法:参数可以等于 null,并且已知在方法返回 true 时不为 null。

还可能需要一个最终特性。 有时,返回值的 null 状态取决于一个或多个参数的 null 状态。 只要某些参数不是 null,这些方法将返回非 null 值。 若要正确地注释这些方法,可以使用 NotNullIfNotNull 特性。 请考虑以下方法:

string GetTopLevelDomainFromFullUrl(string url)

如果 url 参数不为 null,则输出不是 null。 启用可为 null 引用后,如果 API 可以接受 null 参数,则需要添加更多注释。 可以注释返回类型,如以下代码所示:

string? GetTopLevelDomainFromFullUrl(string? url)

这也是可行的,但通常会强制调用方实现额外的 null 检查。 协定是,只有当参数 urlnull 时,返回值才会是 null。 若要表达该协定,你需要注释此方法,如以下代码所示:

[return: NotNullIfNotNull(nameof(url))]
string? GetTopLevelDomainFromFullUrl(string? url)

上一个示例为 url 参数使用 nameof 运算符。 此功能在 C# 11 中提供。 在 C# 11 之前,需要将参数的名称作为为字符串键入。 返回值和参数都用 ? 进行了注释,这表明两者都可以是 null。 该特性进一步阐明了当 url 参数不是 null 时,返回值不会为 null。

可以使用以下特性指定条件的后置条件:

  • MaybeNullWhen:当方法返回指定的 bool 值时,不可为 null 的参数可以为 null。
  • NotNullWhen:当方法返回指定的 bool 值时,可以为 null 的参数不会为 null。
  • NotNullIfNotNull:如果指定参数的参数不为 null,则返回值不为 null。

帮助程序方法:MemberNotNullMemberNotNullWhen

这些特性指定了将构造函数中的公共代码重构为帮助程序方法时的意图。 C# 编译器分析构造函数和字段初始值设定项,以确保在每个构造函数返回之前,所有不可为 null 的引用字段都已初始化。 然而,C# 编译器不会通过所有帮助程序方法跟踪字段赋值。 当字段没有在构造函数中直接初始化,而在帮助程序方法中初始化时,编译器会发出警告 CS8618。 可以将 MemberNotNullAttribute 添加到方法声明中,并指定在方法中初始化为非 NULL 值的字段。 例如,考虑以下情况:

public class Container
{
    private string _uniqueIdentifier; // must be initialized.
    private string? _optionalMessage;

    public Container()
    {
        Helper();
    }

    public Container(string message)
    {
        Helper();
        _optionalMessage = message;
    }

    [MemberNotNull(nameof(_uniqueIdentifier))]
    private void Helper()
    {
        _uniqueIdentifier = DateTime.Now.Ticks.ToString();
    }
}

可以指定多个字段名称作为 MemberNotNull 特性构造函数的参数。

MemberNotNullWhenAttributebool 参数。 在帮助程序方法返回指明帮助程序方法是否初始化了字段的 bool 的情况下,可以使用 MemberNotNullWhen

调用的方法引发时停止可为 null 分析

某些方法(通常是异常帮助程序或其他实用工具方法)始终通过引发异常来退出。 或者,帮助程序可以基于布尔参数的值引发异常。

在第一种情况下,可以将 DoesNotReturnAttribute 特性添加到方法声明中。 编译器的 null 状态分析不会检查调用带有 DoesNotReturn 注释的方法之后的方法中的任何代码。 请考虑此方法:

[DoesNotReturn]
private void FailFast()
{
    throw new InvalidOperationException();
}

public void SetState(object containedField)
{
    if (containedField is null)
    {
        FailFast();
    }

    // containedField can't be null:
    _field = containedField;
}

调用 FailFast 后,编译器不会发出任何警告。

在第二种情况下,需将 System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute 特性添加到方法的布尔参数中。 你可以修改前面的示例,如下所示:

private void FailFastIf([DoesNotReturnIf(true)] bool isNull)
{
    if (isNull)
    {
        throw new InvalidOperationException();
    }
}

public void SetFieldState(object? containedField)
{
    FailFastIf(containedField == null);
    // No warning: containedField can't be null here:
    _field = containedField;
}

当参数的值与 DoesNotReturnIf 构造函数的值匹配时,编译器不会在该方法之后执行任何 null 状态分析。

总结

添加可为 null 的引用类型提供了一个初始词汇表,用于描述 API 对可能为 null 的变量的期望。 这些特性提供了更丰富的词汇来将变量的 null 状态描述为前置条件和后置条件。 这些特性更清楚地描述了你的期望,并为使用 API 的开发人员提供了更好的体验。

在为可为 null 的上下文中更新库时,添加这些特性可指导用户正确使用 API。 这些特性有助于你全面描述参数和返回值的 null 状态。

  • AllowNull:不可为 null 的字段、参数或属性可以为 null。
  • DisallowNull:可为 null 的字段、参数或属性应永不为 null。
  • MaybeNull:不可为 null 的字段、参数、属性或返回值可以为 null。
  • NotNull:可为 null 的字段、参数、属性或返回值将永不为 null。
  • MaybeNullWhen:当方法返回指定的 bool 值时,不可为 null 的参数可以为 null。
  • NotNullWhen:当方法返回指定的 bool 值时,可以为 null 的参数不会为 null。
  • NotNullIfNotNull:如果指定参数的自变量不为 null,则参数、属性或返回值不为 null。
  • DoesNotReturn:方法或属性永远不会返回。 换句话说,它总是引发异常。
  • DoesNotReturnIf:如果关联的 bool 参数具有指定值,则此方法或属性永远不会返回。