可空迁移策略

Tip

要开始新项目了吗? 使用 .NET 6 或更高版本模板创建的新项目已设置了 <Nullable>enable</Nullable>。 你不需要迁移策略:直接跳到解决可为 null 警告

维护现有代码库? 请先阅读可为 null 的引用类型,以了解上下文、注释和 null 状态。 本文假设你熟悉这些概念,并准备好计划推出。

当你在一个早于可空引用类型引入之前就已开始的大型项目中启用可空引用类型时,编译器会一下子产生大量警告。 迁移的关键在于安排工作的顺序:选择一个默认上下文,按文件或按节逐步显示警告,并最终让整个项目统一采用 <Nullable>enable</Nullable>。 合适的顺序取决于代码库的活跃程度,以及你在一次处理过程中能够承受多大的风险。

在每种情况下,结束状态都是相同的:项目集 <Nullable>enable</Nullable> 且不包含 #nullable 预处理器指令。

选择默认上下文

可为 null 上下文包含两个相互独立的标志:注解? 是否声明可为 null 的引用类型)和 警告(编译器是否发出诊断信息)。 将它们合并设置为一个 <Nullable> 值:

默认值 Annotations 警告 最适用于
disable (隐式) 本轮中不会开展新功能开发的稳定库。
enable on on 包含频繁的新文件的活动代码库。 新代码开始选择加入。
warnings on 两阶段迁移:首先解决警告问题,稍后进行批注。
annotations on 在修复内部警告之前,先为公共 API 添加注解。

选择最符合项目迁移目标的策略:

  • 禁用为默认值。 在迁移每个文件时,设置 <Nullable>disable</Nullable>,并在其顶部添加 #nullable enable。 现有文件会一直处于忽略可空性的状态,直到你修改它们为止。 此选项对稳定库具有最低的摩擦,因为新功能工作很少见。
  • 启用为默认值。 设置 <Nullable>enable</Nullable>,并在每个你尚未迁移的文件顶部添加 #nullable disable。 每个新文件从一开始就可识别为 null,因此迁移积压工作只能收缩。 当开发处于活动状态时,此选项更好。
  • 警告为默认值。 设置 <Nullable>warnings</Nullable>。 对于两阶段迁移,请选择此默认值:处理警告,而每个引用类型仍被视为忽略,然后打开批注。 两阶段拆分使每个步骤保持差异的焦点。
  • 将批注作为默认值。 设置 <Nullable>annotations</Nullable>。 先为公共 API 中允许 null 的成员添加 ?,再去处理警告。 编译器尚未给出警告,因此你可以在不受干扰的情况下敲定 API 接口范围。

项目文件控制全局默认值。 #nullable 预处理器指令 替代代码区域的默认值:

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

在源文件中,该指令可为某个区域启用或禁用项目的可空性设置:

#nullable disable
public static class LegacyHelper
{
    // This file is nullable-oblivious. Reference types use the legacy rules.
    public static string GetGreeting(string name) =>
        name == null ? "hello" : $"hello {name}";
}
#nullable restore

#nullable enable
public static class MigratedHelper
{
    // This file is fully migrated. Reference types are non-nullable by default.
    public static string GetGreeting(string? name) =>
        name is null ? "hello" : $"hello {name}";
}
#nullable restore

逐个迁移文件

迁移大型项目时,最稳妥的方式是逐个文件启用警告或注解。 无论选择哪个默认值,模式都是相同的:

  1. 选择一个文件。 从依赖项关系图中的最深叶类型开始,然后向外移动。 为类型添加注解会在其调用方中产生新的警告,因此,采用自下而上的方式可以最大限度减少返工。
  2. 添加使该文件采用新行为的 #nullable 指令。 如果需要这两个标志,请使用 #nullable enable 。 仅将 #nullable enable warnings 用于警告。
  3. 使用 “解决可为空警告”中的技术解决文件中的警告。
  4. 对下一个文件重复此操作。
  5. 当项目中的每个文件都有其指令时,请删除指令并在项目级别设置 <Nullable>enable</Nullable>

如果你的代码库中已经有了 <Nullable>enable</Nullable>,那你就是在朝着 相反 的方向前进。 在你准备就绪之前,抑制未迁移文件中的警告。 使用 #nullable disable 将文件排除在外,然后每次删除一个抑制项。

分两个阶段迁移

两阶段迁移将可为 null 引用类型涉及的两种工作分开。 你可以按任一方式对阶段进行排序,具体取决于哪种形式的稳定性对你而言更重要。

首先发出警告,然后批注

当修复潜在 System.NullReferenceException 缺陷是首要任务时,优先显示警告:

  1. 阶段 1:处理警告。 将项目默认设置为 warnings. 引用类型仍不区分是否可为 null,因此类型系统暂时不会发生变化。 编译器会在现有代码可能已引发 System.NullReferenceException的任何位置发出警告。 添加 null 检查、重构控制流或添加属性,直到项目不再有警告。 每次修复都使得生产代码在批注存在之前更具弹性。
  2. 阶段 2:添加批注。 将项目默认设置切换为 enable。 引用类型现在默认不可为 null,而 var 局部变量变为可为 null。 新的警告反映了与变量的使用方式不匹配的声明。 为应允许 null 的类型添加 ?。 加强对应该要求非空输入的 API 的约束。

首先注释,然后发出警告

当稳定公共 API 表面是首要任务时,应优先使用注解。 这种顺序很适合用于库:你可以先发布带注解的签名,让使用方看到正确的契约,然后再按自己的时间安排处理内部警告。

  1. 阶段 1:添加批注。 将项目默认设置为 annotations. 默认情况下,引用类型变为非可为空,但编译器不会发出警告,因此这些噪声不会干扰你。 遍历公共 API,并为每个可能合法地返回或接受 null 的成员添加 ?。 收紧不应该的签名。 由于警告已禁用,因此你可以通过几次专注的提交来敲定 API 的形态,而不必同时理清实现。
  2. 阶段 2:处理警告。 将项目默认设置切换为 enable。 你在第 1 阶段添加的注释现在会用于 null 状态分析,因此编译器一开始发出的警告质量就更高:每条警告都指向其行为与你已经发布的约定不一致的代码。 使用 解决可为 null 警告中的技巧来解决这些警告。

选择排序方式

每种排序方式都会将各个阶段拆分为更小、更便于审查的差异集。 一个阶段仅更改行为,另一个阶段仅更改类型。 缺点是访问每个文件两次。 对于每次改动都伴随风险的成熟且稳定的代码来说,进行两遍处理通常是值得的。 当你最想加固正在运行的代码时,请优先选择警告。 当你最希望发布稳定契约时,请先选择注解

已生成代码已排除

编译器会将标记为生成的文件视为已禁用可为 null 的上下文,无论项目如何设置。 如果满足以下任一条件,则会将文件视为生成:

  • .editorconfig 规则为该文件设置 generated_code = true
  • 文件中的第一个注释包含 <auto-generated><auto-generated/>
  • 文件名以 TemporaryGeneratedFile_. 开头。
  • 文件名以 .designer.cs.generated.cs.g.cs.g.i.cs 结尾。

能够生成可空性感知输出的生成器可以通过在生成文件顶部输出 #nullable enable 来重新启用该功能。

完成后

在每个文件都采用项目默认设置并设置了 <Nullable>enable</Nullable> 元素后:

  • 删除源内容中的每个 #nullable 指令。
  • 删除你在迁移期间仅为消除警告而添加的 null!default! 初始化程序。 将其替换为正确的初始化,或将类型设置为可为 null 的引用类型。
  • 抽查公共 API。 返回或接受 null 的每个成员都应使用 ?注释。 批注是包交付后合同的一部分。

你现在处于与新项目相同的状态:可以为 null 的引用类型属于类型系统,任何新警告都反映了声明和代码之间的实际不匹配。 使用 解决可为空的警告 在出现时解决它们。