可为空引用类型

Tip

开发软件的新手? 从入门教程 开始

是否在其他语言中有经验? 如果你接触过 Kotlin 的可空类型、TypeScript 的 strictNullChecks,或 Swift 的可选类型,那么你对这种模型应该不陌生。 C# 使用静态分析和警告诊断,而不是单独的类型。 先快速浏览使用注释表达意图空状态分析,然后转到教程:使用可为 null 和不可为 null 的引用类型表达设计意图来应用此功能。

可为空引用类型是一组功能,可尽量减少代码抛出 System.NullReferenceException 的可能性。 你需要声明哪些变量用于保存 null,哪些变量不是;当这些声明与代码中的使用方式不一致时,编译器就会发出警告。 程序的运行时行为保持不变。 可空引用类型纯粹是编译时特性。

三个构建模块协同工作:

  • 变量注解stringstring?)用于表明哪些引用可进行 null
  • 空状态分析会跟踪代码中每个位置处的表达式值是不为 null还是可能为 null
  • API 上的属性描述更细微的协定,例如“此参数可以是null,但返回值仅在参数为 null 时才为 null。

编译器将这些信号组合在一起以生成诊断。 关于不可为 null 的变量的警告表示,该变量可能会被赋予 null。 关于可为 null 的变量的警告意味着代码可能在未进行 null 检查的情况下 取消引用 它。 取消引用 意味着使用变量引用的值。 例如,若要对它调用方法(variable.Method())、读取属性(variable.Property或为其variable[0]编制索引)。 对值为 null 的变量进行解引用会在运行时引发异常。 任何一种警告都意味着代码的行为与其说明的设计不匹配。

可为空上下文

使用较新的 .NET 模板创建的项目会在项目文件中设置 <Nullable>enable</Nullable>,因此本文中的说明可直接按文中所述适用。 如果使用的是较旧的项目,请打开 .csproj 并检查 <PropertyGroup> 是否包含以下行;如果缺少,请添加它:

<Nullable>enable</Nullable>

有关迁移大型应用程序的更多信息,请参阅关于 可为 null 的迁移策略 的文章,以了解更多设置和指令。

用注释表达意图

默认情况下,每个引用类型变量 不可为 null 。 追加 ? 以声明 可以为 null 的 引用类型:

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

批注不会更改运行时类型。 stringstring? 都是 System.String? 告知编译器你的设计意图。 该意向塑造编译器生成的警告:

  • 不可为 null 的变量的默认 null 状态为 not-null。 如果分配的值可能是 null,编译器将发出警告。
  • 可为 null 的变量,其默认的 null-statemaybe-null。 如果在未先检查该变量的情况下解引用它,编译器就会发出警告。

使用注解在类型系统中显式标明必需值和可选值。 下面的 Person 类型将 FirstNameLastName 声明为不可为 null——每个人都必须同时具备这两项——并将 MiddleName 声明为可为 null,因为不是每个人都有。

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

批注驱动 ToString实现 。 由于 FirstNameLastName 均不可为 null,因此重写会直接在 插值字符串(将表达式嵌入 {} 占位符中的 $"..." 语法)中使用它们,而不进行 null 检查。 MiddleName 可为 null,因此重写方法会先检查 MiddleName 是否为 null,并且仅在其存在时才将其包含在内。 编译器会强制执行这种区分:如果代码在需要非空值的地方传入一个可能为 null 的值,就会发出警告;如果构造函数没有初始化某个非空成员,也会发出警告。

空状态分析

编译器跟踪每个表达式的 null 状态 。 状态是两个值之一:

  • not-null:已知表达式不是 null
  • maybe-null:表达式可能是 null

当编译器分析代码时,将更新局部变量的 null 状态。 有两项更改: 分配null 检查。 赋值后,变量的 null 状态与右侧的表达式匹配。 如果表达式为 null 或可为 null,则变量可能为 null。 如果表达式是非空字面量,则变量变为非空。 进行 null 检查后,变量的 null 状态会反映所采用的分支。

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

在前面的示例中,第一个取消引用会生成警告,因为message可能为 null。 将其赋值为非 null 字面量后,编译器知道message不为 null,因此第二次解引用是安全的。

空状态分析适用于 if 检查、模式匹配(例如用于测试值形状的 is nullis { } 之类的表达式),以及会循环或提前返回的控制流:

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

分析不会深入到方法体内部。 如果需要一种方法来向其调用方传达 null 状态,请对其签名使用 可为 null 的分析属性

使用 ! 忽略警告

有时,你比编译器更了解。 null 包容运算符!指示表达式非 null,即使分析结果并非如此:

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

请尽量少使用 !。 每出现一次这样的情况,就意味着编译器少了一个无法再为你提供保护的地方。 首选添加 null 检查、重新调整代码或批注相关 API,以便编译器自行得出正确的结论。

描述 API 协定的属性

参数或返回类型的注释并不总是具有足够的表现力。 方法可能接受可能为 null 的参数,但保证非 null 结果。 仅当测试方法的参数不为 null 时,才会返回 true 。 使用 可空性分析特性 表达这些约定:

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

NotNullWhenAttribute会告知编译器:当 IsPresent 返回 true 时,该参数非 null。 在 if 代码块内,编译器会将 value 视为 非 null,无需使用 null 宽恕运算符。 从 .NET 5 起,所有.NET运行时 API 都会进行批注,因此分析有利于调用它们的任何代码。

已知缺陷

两种模式可以保留不可为 null 的引用 null ,而不显示警告。 这两种模式都是静态分析的限制,而不是代码中的 bug。

默认结构

可以使用 defaultnew() 创建一个包含不可为 null 的引用字段的结构体。 此方法使结构字段未初始化:

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

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

字段在运行时保留 null ,但编译器不会发出警告。 如果必须使用结构,首选 必需的成员,这些成员是调用方必须通过对象初始值设定项初始化的成员,或者调用方必须调用的参数化构造函数。

引用和结构的数组

不可为 null 的引用类型的新数组中,所有元素在被逐一赋值之前均为 null

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

同样的陷阱也适用于结构体数组:每个元素的初始值都是该结构体的默认值,因此每个元素中不可为 null 的引用字段的初始值也都是 null

在创建数组时初始化数组元素。 集合表达式[1, 2, 3] 文本语法)和 目标类型 new 化(当编译器可以推断类型时写入 new() )使完全初始化简洁。