可为空引用类型

在可为空的未知上下文中,所有引用类型都是可为空。 可为 null 引用类型指的是已知可为 null 的上下文中引入的一组功能,可用于最大程度地降低代码导致运行时引发 System.NullReferenceException 的可能性。 可为 null 引用类型包括三项功能,可帮助避免这些异常,包括将引用类型显式标记为可为 null 的功能:

  • 经过优化的静态流分析,用于在取消引用变量之前确定其是否为 null
  • 属性,用于注释 API 以便流分析确定 null 状态。
  • 变量注释,可供开发人员用于显式声明变量的预期 null 状态。

编译器在编译时跟踪代码中每个表达式的 null 状态null 状态为以下三个值之一:

  • not-null:已知表达式为 not-null
  • maybe-null:表达式可能是 null
  • oblivious:编译器无法确定表达式的 null 状态。

变量批注确定引用类型变量的为 Null 性

  • 不可为 null:如果将值 nullmaybe-null 表达式分配给变量,编译器会发出警告。 不可为 Null 的变量的默认 null 状态 为 not-null
  • 可为 null:可以为变量赋值 nullmaybe-null 表达式。 当变量的 null 状态为 maybe-null 时,如果取消引用变量,编译器会发出警告。 变量的默认 null 状态为 maybe-null
  • oblivious:可以为变量赋值 nullmaybe-null 表达式。 取消引用变量或将 maybe-null 表达式分配给变量时,编译器不会发出警告。

在引入可为 null 的引用类型之前,oblivious null 状态和未知的可为 Null 性与行为匹配。 这些值在迁移期间非常有用,或者当应用使用未启用可为 null 的引用类型的库时非常有用。

对现有项目默认禁用 Null 状态分析和变量注释,这意味着所有引用类型仍为可为 null。 从 .NET 6 开始,默认情况下会为新项目启用这些功能。 有关通过声明可为 null 注释上下文来启用这些功能的信息,请参阅可为 null 上下文

本文的其余部分介绍了当你的代码可能取消引用null 值时,这三个功能区域如何生成警告。 取消引用变量意味着使用 .(点)运算符访问其成员之一,如下例所示:

string message = "Hello, World!";
int length = message.Length; // dereferencing "message"

取消引用值为 null 的变量时,运行时会引发 System.NullReferenceException

本文内容:

  • 编译器的 null 状态分析:编译器如何确定表达式为 not-null 或 maybe-null。
  • 应用于 API 的属性,这些 API 为编译器的 null 状态分析提供更多上下文。
  • 可为 null 的变量注释,用于提供有关变量意向的信息。 注释对于字段在成员方法开头设置默认 null 状态非常有用。
  • 控制泛型类型参数的规则。 添加了新约束,因为类型参数可以是引用类型或值类型。 后缀 ? 针对可为 null 的值类型和可为 null 的引用类型的实现方式不同。
  • 可为 Null 的上下文可帮助你迁移大型项目。 你可以在迁移时在应用的一部分启用可为 null 的上下文或警告。 解决更多警告后,可以为整个项目启用可为 null 的引用类型。

最后,了解 struct 类型和数组中 null 状态分析的已知陷阱。

还可以通过关于 C# 中可为 Null 的安全性的学习模块了解这些概念。

null 状态分析

启用可以为 null 的引用类型时,Null 状态分析将跟踪引用的 null 状态。 表达式为“not-null”或“maybe-null”。 编译器通过两种方式确定变量是否非 null:

  1. 已为该变量分配一个已知为 not-null 的值。
  2. 已检查该变量是否为 null,并且该变量自该检查后未进行过修改。

如果未启用可为 null 的引用类型,则所有表达式的 null 状态都为 oblivious。 本部分的其余部分描述了启用可为 null 引用类型时的行为。

编译器未确定为非 null 的任何变量均视为“可能为 null” 。 如果意外取消引用 null 值,分析会发出警告。 编译器根据 null 状态生成警告。

  • 变量为 not-null 时,可安全地取消引用该变量。
  • 变量可能为 null 时,必须先检查该变量,确保其不为 null,然后才能取消引用它。

请考虑以下示例:

string message = null;

// warning: dereference null.
Console.WriteLine($"The length of the message is {message.Length}");

var originalMessage = message;
message = "Hello, World!";

// No warning. Analysis determined "message" is not-null.
Console.WriteLine($"The length of the message is {message.Length}");

// warning!
Console.WriteLine(originalMessage.Length);

在上例中,编译器在打印第一条消息时确定 message 是否可能为 null。 对于第二条消息,没有警告。 originalMessage 可能为 null,因此最后一行代码发出警告。 下面的示例演示了一个更实际的用途,即遍历节点树直到根,并在遍历过程中处理每个节点:

void FindRoot(Node node, Action<Node> processNode)
{
    for (var current = node; current != null; current = current.Parent)
    {
        processNode(current);
    }
}

上述代码不会因取消引用变量 current 而生成任何警告。 静态分析确定当 current 可能为 null 时永不会被取消引用。 访问 current.Parent 以及将 current 传递给 ProcessNode 操作之前,会检查变量 current 是否为 null。 上述示例演示了编译器如何在初始化、分配或与 null 比较时确定局部变量的 null 状态。

null 状态分析不会跟踪到调用的方法。 因此,构造函数调用的常见帮助程序方法中初始化的字段将使用以下模板生成警告:

在退出构造函数时,不可为 null 的属性“name”必须包含非 null 值。

可以通过以下两种方式之一消除这些警告:帮助程序方法上的构造函数链接或可以为 null 的属性。 下面的代码就是删除两种空格的示例。 Person 类使用由所有其他构造函数调用的通用构造函数。 Student 类具有使用 System.Diagnostics.CodeAnalysis.MemberNotNullAttribute 特性进行批注的帮助程序方法:


using System.Diagnostics.CodeAnalysis;

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public Person() : this("John", "Doe") { }
}

public class Student : Person
{
    public string Major { get; set; }

    public Student(string firstName, string lastName, string major)
        : base(firstName, lastName)
    {
        SetMajor(major);
    }

    public Student(string firstName, string lastName) :
        base(firstName, lastName)
    {
        SetMajor();
    }

    public Student()
    {
        SetMajor();
    }

    [MemberNotNull(nameof(Major))]
    private void SetMajor(string? major = default)
    {
        Major = major ?? "Undeclared";
    }
}

注意

C# 10 中添加了对明确赋值和 Null 状态分析的许多改进。 升级到 C# 10 后,便会发现误报的可为 Null 警告更少。 可以详细了解明确赋值改进的功能规范中的改进。

可为 null 的状态分析和编译器生成的警告有助于通过取消引用 null 来避免程序错误。 有关解决可为 null 的警告的文章提供了用于更正代码中可能看到的警告的技术。

API 签名上的属性

null 状态分析需要开发人员的提示才能理解 API 的语义。 某些 API 提供 null 检查,它们应将变量的 null 状态从“可能为 null”更改为“非 null” 。 其他 API 返回非 null 或可能为 null 的表达式,具体取决于输入参数的 null 状态 。 例如,请考虑以下以大写形式显示消息的代码:

void PrintMessageUpper(string? message)
{
    if (!IsNull(message))
    {
        Console.WriteLine($"{DateTime.Now}: {message.ToUpper()}");
    }
}

bool IsNull(string? s) => s == null;

根据检查,所有开发人员都认为此代码安全,不应生成警告。 但是,编译器不知道 IsNull 提供 null 检查,并且将针对 message.ToUpper() 语句发出警告,认为 message 是一个 maybe-null 变量。 若要解决此问题,可使用 NotNullWhen 特性:

bool IsNull([NotNullWhen(false)] string? s) => s == null;

此属性会通知编译器,如果 IsNull 返回 false,则参数 s 不为 null。 编译器在 if (!IsNull(message)) {...} 块内将 messagenull 状态更改为 not-null。 未发出任何警告。

属性详细说明了用于调用成员的对象实例的参数、返回值和成员的 null 状态。 若要详细了解每个属性,可查看关于可为 null 的引用属性的语言参考文章。 从 .NET 5 起,所有 .NET 运行时 API 都会进行批注。 可通过注释 API 提供有关参数和返回值的 null 状态的语义信息来优化静态分析。

可为 null 的变量注释

null 状态分析为本地变量提供可靠的分析。 编译器需要你提供有关成员变量的更多信息。 编译器需要更多信息才能在成员的左括号中设置所有字段的 null 状态。 可使用任何可访问的构造函数来初始化对象。 如果某个成员字段曾设为 null,则编译器必须在每个方法开始时假定其 null 状态是“可能为 null” 。

可使用能够声明变量是可为 null 引用类型还是不可为 null 引用类型的注释 。 这些注释对变量的 null 状态进行重要声明:

  • 引用不应为 null。 不可为 Null 的引用变量的默认状态为 not-null。 编译器会强制执行规则,确保即使不先检查变量是否为 null,也能安全地取消引用这些变量:
    • 必须将变量初始化为非 null 值。
    • 变量永远不能赋值为 null。 当代码将可能为 null 的表达式分配给不应为 null 的变量时,编译器会发出警告。
  • 引用可为 null。 可为 Null 的引用变量的默认状态为 maybe-null。 编译器会强制执行规则,来确保已正确检查 null 引用:
    • 只有当编译器可保证该值不为 null 时,才可取消引用该变量。
    • 这些变量可以使用默认的 null 值进行初始化,也可以在其他代码中赋值为 null
    • 代码将 maybe-null 表达式分配给可能为 null 的变量时,编译器不会发出警告。

不可为 Null 的引用变量默认的 null 状态not-null。 任何可能为 null 的引用变量的最初 null 状态都为 maybe-null

使用与可为空值类型相同的语法记录可为空引用类型:将 ? 附加到变量的类型。 例如,以下变量声明表示可为空的字符串变量 name

string? name;

为启用可能为 null 的引用类型时,未将 ? 附加到类型名称的任何变量都是“不可为 null 的引用类型”。 这包括启用此功能时现有代码中的所有引用类型变量。 不过,任何隐式类型本地变量(使用 var 声明)都是可为 null 引用类型。 如上述部分所示,静态分析确定局部变量的 null 状态,从而在取消引用前确定其是否为 maybe-null

有时,当知道变量不为 null,但编译器确定其 null 状态是“可能为 null”时,必须覆盖警告 。 可在变量名称后使用 null 容忍操作符! 来将 null 状态强制为非 null。 例如,如果知道 name 变量不为 null,但编译器仍发出警告,你可编写以下代码来覆盖编译器的分析:

name!.Length;

可为 null 的引用类型和可为 null 的值类型提供类似的语义概念:变量可表示值或对象,或者该变量可以为 null。 但可为 null 引用类型和可为 null 值类型的实现方式不同:可为 null 值类型是使用 System.Nullable<T> 实现的,而可为 null 引用类型是使用编译器读取的属性实现的。 例如,string?string 由同一类型表示:System.String。 但 int?int 分别由 System.Nullable<System.Int32>System.Int32 表示。

可为 null 的引用类型是编译时功能。 这意味着调用方可以忽略警告,故意使用 null 作为预期不可为 null 引用的方法的参数。 库作者应纳入针对 null 参数值的运行时检查。 这是 ArgumentNullException.ThrowIfNull 在运行时针对 null 检查参数的首选项。

重要

启用可为 null 的注释后,可以更改 Entity Framework Core 确定是否需要数据成员的方式。 你可在 Entity Framework Core 基础知识:使用可为 null 的引用类型一文中了解更多详细信息。

泛型

泛型需要通过详细的规则来处理任何类型参数 TT?。 由于历史原因以及可为 Null 的值类型和可为 Null 的引用类型的实现各不相同,这些规则必须详细。 可为 Null 的值类型是使用 System.Nullable<T> 结构实现的。 可为 Null 的引用类型实现为向编译器提供语义规则的类型注释。

  • 如果 T 的类型参数为引用类型,则 T? 会引用相应的可为 Null 的引用类型。 例如,如果 Tstring,则 T?string?
  • 如果 T 的类型参数是值类型,则 T? 将引用相同的值类型 T。 例如,如果 Tint,则 T? 也是 int
  • 如果 T 的类型参数是可为 Null 的引用类型,则 T? 将引用相同的可为 Null 的引用类型。 例如,如果 Tstring?,则 T? 也是 string?
  • 如果 T 的类型参数是可为 Null 的值类型,则 T? 将引用相同的可为 Null 的值类型。 例如,如果 Tint?,则 T? 也是 int?

对于返回值,T? 等效于 [MaybeNull]T;对于参数值,T? 等效于 [AllowNull]T。 有关详细信息,请参阅语言参考中有关 null-state 分析的属性的文章。

可以使用约束指定不同的行为:

  • class 约束意味着 T 必须是不可为 Null 的引用类型(例如 string)。 如果使用可为 Null 的引用类型(例如,为 T 使用 string?),编译器会生成警告。
  • class? 约束意味着 T 必须是引用类型,可以是不可为 Null 的引用类型 (string),也可以是可为 Null 的引用类型(例如 string?)。 当类型参数是可为 Null 的引用类型(例如 string?)时,T? 的表达式将引用相同的可为 Null 的引用类型(例如 string?)。
  • notnull 约束意味着 T 必须是不可为 null 引用类型或不可为 null 值类型。 如果为类型参数使用可为 Null 的引用类型或可为 Null 的值类型,编译器会生成警告。 此外,当 T 是值类型时,返回值是该值类型,而不是相应的可为 Null 的值类型。

这些约束帮助为编译器提供有关如何使用 T 的更多信息。 这有助于开发人员为 T 选择类型,并且可在使用泛型类型的实例时提供更好的 null 状态分析。

可为空上下文

对于小型项目,可以启用可为 null 的引用类型、修复警告并继续。 但是,对于大型项目和多项目解决方案,可能会生成大量警告。 可以使用 pragma 在开始使用可为 null 的引用类型时逐文件启用可为 null 的引用类型。 在现有代码库中,防止引发 System.NullReferenceException 的新功能在启用后可能会导致服务中断:

  • 所有显式类型引用变量都均解释为不可为 null 引用类型。
  • 泛型中 class 约束的含义已更改为表示不可为 null 引用类型。
  • 由于这些新规则,将生成新警告。

可为 null 注释上下文决定了编译器的行为。 可为 null 注释上下文有 4 个值:

  • 禁用:代码为 nullable-oblivious禁用 与启用可为 null 引用类型之前的行为匹配,但新语法生成警告而不是错误。
    • 禁用可为 null 警告。
    • 所有引用类型变量都是可为 null 引用类型。
    • 使用 ? 后缀来声明可为 null 引用类型会生成警告。
    • 可以使用 null 容忍运算符 !,但它不起任何作用。
  • 启用:编译器启用所有 null 引用分析和所有语言功能。
    • 启用所有新的可为 null 警告。
    • 可使用 ? 后缀来声明可为 null 引用类型。
    • 没有 ? 后缀的引用类型变量都是不可为 null 的引用类型。
    • null 容忍运算符禁止对可能的 null 赋值发出警告。
  • 警告:当代码可能取消引用 null 时,编译器会执行所有 null 分析并发出警告。
    • 启用所有新的可为 null 警告。
    • 使用 ? 后缀来声明可为 null 引用类型会生成警告。
    • 所有引用类型变量均可为 null。 但是,除非使用 ? 后缀声明成员,否则成员在所有方法的左大括号处都具有非 null 的 null 状态 。
    • 可以使用 null 容忍运算符 !
  • 批注:当代码可能取消引用 null 或你为不可为 null 变量分配 maybe-null 变量时,编译器不会发出警告。
    • 禁用所有新的可为 null 警告。
    • 可使用 ? 后缀来声明可为 null 引用类型。
    • 没有 ? 后缀的引用类型变量都是不可为 null 的引用类型。
    • 可以使用 null 容忍运算符 !,但它不起任何作用。

可以使用 .csproj 文件中的 <Nullable> 元素为项目设置可为 null 注释上下文和可为 null 警告上下文。 此元素配置编译器如何解释类型的为 Null 性以及发出哪些警告。 下表显示了允许的值并汇总了它们指定的上下文。

上下文 取消引用警告 赋值警告 引用类型 ? 后缀 ! 运算符
disable 已禁用 已禁用 全部可为 null 生成警告 没有作用
enable Enabled Enabled 不可为 null,除非使用 ? 声明 声明可为 null 的类型 禁止为可能的 null 赋值显示警告
warnings Enabled 不适用 所有成员都可为 null,但在方法的左大括号处,成员被视为 not-null 生成警告 禁止为可能的 null 赋值显示警告
annotations 已禁用 已禁用 不可为 null,除非使用 ? 声明 声明可为 null 的类型 没有作用

对于已禁用的上下文中编译的代码中的引用类型变量,其为 Null 性未知。 可将 null 文本或 maybe-null 变量分配给 Null 性未知的变量。 但是,nullable-oblivious 变量的默认状态为 not-null 。

可选择最适合你的项目的设置:

  • 对于根据诊断或新功能不想更新的旧项目,请选择“禁用”。
  • 选择“警告”,确定代码可能引发 System.NullReferenceException 的位置。 可先处理这些警告,然后修改代码来启用不可为 null 引用类型。
  • 选择“注释”来说明设计意图,然后启用警告。
  • 对于希望避免出现 null 引用异常的新项目和活动项目,请选择“启用”。

示例

<Nullable>enable</Nullable>

你还可使用指令在源代码的任何位置设置这些相同的上下文。 这些指令在迁移大型代码库时最有用。

  • #nullable enable:将可为 null 的注释上下文和可为 null 的警告上下文设置为“启用”。
  • #nullable disable:将可为 null 的注释上下文和可为 null 的警告上下文设置为“禁用”。
  • #nullable restore:将可为空注释上下文和可为空警告上下文还原到项目设置。
  • #nullable disable warnings:将可为 null 的警告上下文设置为“禁用”。
  • #nullable enable warnings:将可为 null 的警告上下文设置为“启用”。
  • #nullable restore warnings:将可为空警告上下文还原到项目设置。
  • #nullable disable annotations:将可为 null 的注释上下文设置为“禁用”。
  • #nullable enable annotations:将可为 null 的注释上下文设置为“启用”。
  • #nullable restore annotations:将注释警告上下文还原到项目设置。

对于任何代码行,可设置以下任意组合:

警告上下文 注释上下文 用途
项目默认 项目默认 默认
enable disable 修复分析警告
enable 项目默认 修复分析警告
项目默认 enable 添加类型注释
enable enable 已迁移的代码
disable enable 在修复警告之前注释代码
disable disable 将旧代码添加到已迁移的项目
项目默认 disable 很少
disable 项目默认 很少

通过这九种组合,可精细控制编译器为代码发出的诊断。 你可在正在更新的任何区域中启用更多功能,而不显示尚未准备好解决的其他警告。

重要

全局可为空上下文不适用于生成的代码文件。 在这两种策略下,都会针对标记为“已生成”的任何源文件禁用可为空上下文。 这意味着生成的文件中的所有 API 都没有批注。 可采用四种方法将文件标记为“已生成”:

  1. 在 .editorconfig 中,在应用于该文件的部分中指定 generated_code = true
  2. <auto-generated><auto-generated/> 放在文件顶部的注释中。 它可以位于该注释中的任意行上,但注释块必须是该文件中的第一个元素。
  3. 文件名以 TemporaryGeneratedFile_ 开头
  4. 文件名用以 .designer.cs、.generated.cs、.g.cs 或 .g.i.cs 结尾 。

生成器可以选择使用 #nullable 预处理器指令。

默认情况下,可为空注释和警告上下文处于禁用状态。 这意味着无需更改现有代码即可进行编译,并且不会生成任何新警告。 从 .NET 6 开始,新项目在所有项目模板中都包含 <Nullable>enable</Nullable> 元素。

这些选项提供两种不同的策略来更新现有代码库以使用可为 null 的引用类型。

已知缺陷

包含引用类型的数组和结构是可为 null 引用中以及确定 null 安全性的静态分析中的已知缺陷。 在这两种情况下,不可为 null 的引用均可初始化为 null,且不会生成警告。

结构

包含不可为 null 的引用类型的结构允许为其分配 default,而不会出现任何警告。 请考虑以下示例:

using System;

#nullable enable

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static class Program
{
    public static void PrintStudent(Student student)
    {
        Console.WriteLine($"First name: {student.FirstName.ToUpper()}");
        Console.WriteLine($"Middle name: {student.MiddleName?.ToUpper()}");
        Console.WriteLine($"Last name: {student.LastName.ToUpper()}");
    }

    public static void Main() => PrintStudent(default);
}

在前面的示例中,不可为 null 引用类型 FirstNameLastName 为 null 时,PrintStudent(default) 中未出现警告。

另一种较为常见的情况是处理泛型结构。 请考虑以下示例:

#nullable enable

public struct S<T>
{
    public T Prop { get; set; }
}

public static class Program
{
    public static void Main()
    {
        string s = default(S<string>).Prop;
    }
}

在上述示例中,属性 Prop 的运行时类型为 null。 它被分配到不可为 null 的字符串,且不会生成任何警告。

数组

数组也是可为 null 的引用类型中的已知缺陷。 请考虑以下示例,它不会生成任何警告:

using System;

#nullable enable

public static class Program
{
    public static void Main()
    {
        string[] values = new string[10];
        string s = values[0];
        Console.WriteLine(s.ToUpper());
    }
}

在前面的示例中,数组的声明显示它保留不可为 null 的字符串,而其元素都已初始化为 null。 然后,为变量 s 分配一个 null 值(数组的第一个元素)。 最后,取消引用变量 s,从而导致运行时异常。

请参阅