mcpField
M
migrate-nullable-references

migrate-nullable-references

github.com/dotnet开发5.8k3.5k 次安装1 天前更新
代码仓库

关于 migrate-nullable-references

用于在现有代码库中启用 C# 可空引用类型并处理相关警告的技能,强调准确标注 API,并在迁移期间保持运行时行为不变。

主要功能

  1. 01评估可空迁移准备情况
  2. 02选择全项目或逐文件推进
  3. 03处理可空解引用警告
  4. 04标注公共 API 的可空性
  5. 05审查现有警告抑制
  6. 06保持运行时行为不变

适用场景

  1. 01在 C# 项目中启用可空引用类型
  2. 02系统地清除 CS86xx 警告
  3. 03完善库 API 的可空性约定
  4. 04审查 NRT 迁移后的抑制项

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill migrate-nullable-references

安装后在 Claude Code 中输入/migrate-nullable-references确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
aspnet-core.md
breaking-changes.md
ef-core.md
nullable-attributes.md
scripts/
Get-NullableReadiness.ps1

详细介绍

可空引用类型迁移

在现有代码库中启用 C# 可空引用类型(NRT),并系统地解决所有警告。最终得到一个启用了 <Nullable>enable</Nullable>、没有可空性警告且公共 API 表面注解准确的项目(或解决方案),让编译器和使用者都能获得可靠的可空性信息。

适用场景

  • 在现有 C# 项目或解决方案中启用可空引用类型
  • 启用此功能后,系统地解决 CS86xx 可空性警告
  • 为库的公共 API 表面添加注解,让使用者获得准确的可空性信息
  • 升级依赖项后,该依赖项新增了可空性注解并出现新的警告
  • 分析已启用 NRT 的代码库中的抑制项,判断是否可以移除

不适用场景

  • 项目已经启用了 <Nullable>enable</Nullable>,且没有警告——迁移已完成;除非用户希望重新检查抑制项,以便移除不必要的抑制项(见步骤 6)
  • 用户只想抑制警告而不修复问题(建议不要这样做)
  • 代码面向 C# 7.3 或更早版本,因为这些版本不支持可空引用类型

输入

输入必填说明
项目或解决方案路径是要迁移的 .csproj、.sln 或构建入口点
迁移范围否project-wide(默认)或 file-by-file——用于控制推广策略
构建命令否如何构建项目(例如 dotnet build、msbuild 或仓库专用的构建脚本)。如果未提供,请从仓库中识别
测试命令否如何运行测试(例如 dotnet test 或仓库专用的测试脚本)。如果未提供,请从仓库中识别

工作流

🛑 不得改变运行时行为。 NRT 迁移严格来说是元数据和注解工作。生成的 IL 不得改变——不得新增分支、空值检查或改变控制流,也不得添加或移除方法调用。唯一可接受的更改是可空性注解(?)、可空性属性([NotNullWhen] 等)、! 运算符(仅影响元数据)和 #nullable 指令。如果迁移过程中发现缺少运行时空值防护或潜在 Bug,不要就地修复。应提出在相应位置插入 // TODO: Consider adding ArgumentNullException.ThrowIfNull(param) 注释,让用户将其作为单独的更改处理。注解提交中绝不可混入行为修复。

提交策略: 在每个逻辑边界处提交——启用 <Nullable> 后(步骤 2)、修复解引用警告后(步骤 3)、为声明添加注解后(步骤 4)、应用可空性属性后(步骤 5),以及清理抑制项后(步骤 6)。这样可以让每次提交都聚焦且易于审查,并防止后续步骤发现需要重新考虑的设计问题时丢失工作成果。对于逐文件迁移,请分别提交每个文件或每批相关文件。

步骤 1:评估准备情况

**可选:**运行 scripts/Get-NullableReadiness.ps1 -Path <project-or-solution>,自动执行以下检查。该脚本会报告 <Nullable>、<LangVersion>、<TargetFramework>、<WarningsAsErrors> 设置,并统计 #nullable disable 指令、! 运算符和 #pragma warning disable CS86xx 抑制项的数量。使用 -Json 可输出机器可读的结果。

  1. 确定项目的构建和测试方式。查找构建脚本(例如 build.cmd、build.sh、Makefile)、.sln 文件或各个 .csproj 文件。如果仓库使用自定义构建脚本,请在整个工作流中使用该脚本,而不要使用 dotnet build。
  2. 运行 dotnet --version,确认已安装 SDK。可空引用类型(NRT)要求使用 C# 8.0 或更高版本(.NET Core 3.0 / .NET Standard 2.1 或更高版本)。
  3. 打开 .csproj(如果在仓库级别设置属性,则打开 Directory.Build.props),检查 <LangVersion> 和 <TargetFramework>。如果项目面向多个目标框架,请记录所有 TFM。

**如果语言版本或目标框架不满足要求,请停止。**如果 <LangVersion> 低于 8.0,或者项目面向默认使用 C# 7.x 的框架(例如,未显式设置 <LangVersion> 的 .NET Framework 4.x),则无法直接启用 NRT。请明确告知用户需要进行哪些更改(设置 <LangVersion>8.0</LangVersion> 或更高版本,或者将目标框架改为 .NET Core 3.0+ / .NET 5+),并询问他们是要进行该更新并继续,还是中止迁移。不要默默继续,也不要假定用户接受该更新。

  1. 检查是否已设置 <Nullable>。如果已设置为 enable,请跳到步骤 5,审核剩余警告。
  2. 确定项目类型——这会影响整个迁移过程中的注释优先级:
    • **库:**优先关注公共 API 契约。公共参数或返回类型上的每个 ? 都会改变消费者依赖的契约。务必谨慎、准确。
    • **应用程序(Web、控制台、桌面):**优先确保边界处的空值安全——反序列化、数据库查询、用户输入、外部 API 响应。内部管道代码可以更宽松地添加注释。
    • **测试项目:**注释精度的优先级较低。在测试设置和断言中,如果确定不会出现空值,可以更自由地使用 !。重点是确保测试代码能够干净地编译。
步骤 2:选择推广策略

根据代码库的规模和活跃程度,选择以下一种策略。向用户推荐该策略,并在继续之前征得确认。

**多项目解决方案:**按依赖顺序迁移——先迁移共享库和核心项目,再迁移依赖它们的项目。先注释依赖项,可以消除其使用方的连带警告,避免重复工作。

无论采用哪种策略,都要从中心向外推进:从核心领域模型、DTO 和共享实用类型着手,它们依赖项较少,但被广泛使用。先注释这些类型,可以消除整个代码库中的连带警告,并以最少投入获得最大收益。然后再处理依赖核心类型的高层服务、控制器和 UI 代码。这种方法可以最大限度地减少每一步中的警告数量,避免因整个大型项目一次性启用而被大量警告淹没。优先为每个项目或每个层至少创建一个 PR,使变更集便于审查且重点明确。如果需要添加的注释相对较少,一次性在整个项目中启用并创建单个 PR 也可能合适。

策略 A——在整个项目中启用(小型到中型项目)

适用于源文件大约少于 50 个的项目,或团队希望一次性完成的情况。

  1. 在 .csproj 的 <PropertyGroup> 中添加 <Nullable>enable</Nullable>。
  2. 构建项目并一次性处理所有警告。
策略 B — 先处理警告,再添加注解(大型或活跃项目)

适用于代码库庞大,或由多位贡献者积极开发的项目。

  1. 在 .csproj 中添加 <Nullable>warnings</Nullable>。这会启用警告,但不会改变类型语义。
  2. 构建项目,从步骤 3 开始修复所有警告。
  3. 将其改为 <Nullable>enable</Nullable> 以启用注解——这会触发第二波警告。
  4. 从步骤 4 开始解决注解阶段的警告。
策略 C — 逐文件迁移(超大型项目)

适用于大型旧代码库,在整个项目中启用可空引用类型会产生数量多到难以管理的警告。

  1. 在项目级别设置 <Nullable>disable</Nullable>(或省略此设置)。
  2. 迁移每个文件时,在文件顶部添加 #nullable enable。
  3. 按依赖顺序确定文件优先级:先处理共享实用工具和模型,再处理更高层的使用者。

构建检查点:启用 <Nullable> 后(或为第一批文件添加 #nullable enable 后),执行一次干净构建(例如运行 dotnet build --no-incremental,或先删除 bin/obj)。增量构建只会重新编译已更改的文件,因此未改动文件中的警告会被隐藏。记录初始警告数量,将其作为后续逐步减少的基准。在确认项目仍能编译之前,不要开始修复警告。在此工作流后续的所有构建检查点中,都应执行干净构建。

步骤 3:修复解引用警告

**优先级:**按依赖顺序处理文件——先处理其他代码所依赖的核心模型和共享实用工具,再处理更高层的使用者。每个文件内,先修复公共成员和受保护成员(它们定义了契约),再处理内部成员和私有成员。这样的顺序可最大限度减少连锁警告:修复核心类型的注解通常也会自动解决其使用者中的警告。

构建项目并逐一处理解引用警告。以下是最常见的警告:

警告含义典型修复方法
CS8602解引用可能为 null 的引用优先只修改注解:如果 null 是有效值,则将上游类型设为可空(T?);如果能确认此处的值绝不会为 null,则使用 !。添加 null 检查或 ?. 会改变运行时行为——应将这些修改留到单独的提交中(参见上文的零行为变更规则)
CS8600将可能为 null 的值转换为不可空类型如果 null 是有效值,则在目标类型后添加 ?;如果能确认此值绝不会为 null,则使用 !。添加 null 保护会改变运行时行为
CS8603可能返回 null 引用如果方法确实可能返回 null,则将返回类型改为可空(T?)。如果方法确实可能返回 null,切勿使用 ! 抑制警告——应改为修复返回类型。这是 NRT 迁移中最重要的一条规则:不可空返回类型向每个调用方承诺永远不会返回 null
CS8604参数可能是 null 引用如果 null 是有效值,则将参数标记为可空;如果能确认参数绝不会为 null,则使用 !。传入参数前添加 null 检查会改变运行时行为

❌ **不要将 ?. 当作快速修复解引用警告的方法。**将 obj.Method() 替换为 obj?.Method() 会悄然改变运行时行为——它会跳过调用,而不是抛出异常。只有在有意容忍 null 时才使用 ?.。

❌ 不要到处添加 ! 来消除警告。每个 ! 都是在断言该值绝不会为 null。如果断言有误,你就隐藏了一个 NullReferenceException。请改为添加 null 检查,或将类型设为可空。 ❌ 切勿使用 return null! 来让返回类型保持非可空。 如果方法返回 null,返回类型必须是 T?。编写 return null! 会在非可空签名下隐藏一个 null 值——调用方信任该签名,跳过 null 检查,最终在运行时遇到 NullReferenceException。这同样适用于 null!、default!,以及任何让编译器接受在非可空位置使用 null 的强制转换。返回值上唯一可以接受的 ! 用法,是该值确实永远不会为 null,但编译器无法看出原因。

⚠️ 除非你打算更改运行时类型,否则不要给值类型添加 ?。 对于引用类型,? 仅为元数据。对于值类型(int、枚举、结构体),? 会将类型更改为 Nullable<T>,从而改变方法签名、二进制布局和装箱行为。

每条警告的决策流程图:

  1. 按设计而言,null 在此处是有效值吗?
    • 是 → 在声明中添加 ?(使其可空)。
    • 否 → 转到第 2 步。
    • 不确定 → 继续之前先询问用户。
  2. 你能证明此处的值永远不会为 null 吗?
    • 能,通过编译器看不到的代码路径 → 添加 !,并附上注释说明原因。
    • 能,通过添加保护条件 → 添加 null 检查(if、??、is not null)。
    • 不能 → 类型应为可空(返回第 1 步——答案是“是”)。

指南:

  • 优先使用显式空值检查(if、is not null、??),而不是空值抑制运算符(!)。
  • 只有在能证明值绝不为 null、但编译器无法证明时,才使用空值抑制运算符,并添加注释说明原因。
  • 守卫子句库(例如 Ardalis.GuardClauses、Dawn.Guard)通常会为参数添加 [NotNull],在守卫调用后收窄空值状态。在调用 Guard.Against.NullOrEmpty(value, nameof(value)) 后,编译器已会将 string? 收窄为 string——后续赋值时不要再添加多余的 !。在认定编译器需要额外提示前,请检查守卫方法是否使用了 [NotNull]。
  • 当方法确实可能返回 null 时,将返回类型改为 T?——不要用非 null 类型签名掩盖 null。
  • Debug.Assert(x != null) 与 if 检查一样,可向编译器提供空值状态提示。在方法或代码块开头使用它,告知流分析器相关不变量,并消除该作用域内后续的 ! 运算符。注意:Debug.Assert 会向编译器提供提示,但在 Release 构建中会被移除——它无法在运行时防止 null。对于公共 API 边界,优先使用显式空值检查或 ArgumentNullException。
  • 如果你发现自己需要在内部方法的每个调用位置都添加 !,可以考虑改为将该参数声明为可为 null。仅在编译器确实无法证明非 null 时使用 !。
  • 当布尔值辅助方法的返回结果能保证可为 null 的参数非 null 时(例如,if (IsValid(x)) 意味着 x != null),优先为该辅助方法的参数添加 [NotNullWhen(true)],而不是在每个调用位置使用 !。这只是元数据变更(不会改变行为),能消除下游的 ! 运算符,同时为编译器提供实际的流信息。
  • 对于构造后始终会被赋值的字段(例如由框架、Init() 方法或构建器模式赋值),优先在字段声明处使用 = null!,而不是在每个使用位置添加 !。一个被访问 50 次的字段应只有一个 = null!,而不是 50 个 field! 断言。这样既能让字段在类型系统中保持非 null,又能体现延迟初始化的情况。在可行时,为初始化方法配合使用 [MemberNotNull]。
  • 对于在无约束类型参数上返回 default 的泛型方法(例如 FirstOrDefault<T>),使用 [return: MaybeNull] T,而不是 T?。在无约束泛型上写 T? 会将值类型签名改为 Nullable<T>,从而改变方法签名和二进制布局。[return: MaybeNull] 能保留原始签名,同时表明对于引用类型,返回值可能为 null。
  • LINQ 的 Where(x => x != null) 不会将 T? 收窄为 T——编译器无法跟踪传递给泛型方法的 Lambda 中的空值状态。使用 source.OfType<T>(),以正确收窄类型的方式筛除 null。

构建检查点: 修复解引用警告后,进行构建并确认不再有 CS8602/CS8600/CS8603/CS8604 警告,然后再处理注解警告。

步骤 4:为声明添加注解

首先根据每个成员的设计用途,确定其预期的空值状态——此参数是否应接受 null?此返回值是否可能为 null?据此添加注解,然后处理由此产生的警告。不要让警告左右注解选择;否则会导致过度添加 ?,或到处使用 ! 来消除编译器警告。

**何时向用户询问:**不要猜测 API 契约。不要仅凭使用频率或命名约定推断对可空性的意图——如果代码或文档中没有明确说明意图,就询问用户。具体来说,以下情况请先询问:(1)将公共方法的返回类型改为可空,或为公共参数添加 ?——这会改变使用者所依赖的 API 契约;(2)设计意图不明确时,决定属性应为可空还是必需;(3)无法从上下文判断 null 是否为有效状态时,在 null 检查和 ! 之间做选择。对于答案可从用法中明确判断的内部/私有成员,无需询问,直接处理。

❌ **不要让警告左右注解。**先决定每个成员预期的可空性,再添加注解。为了消除警告而到处添加 ? 会适得其反——调用方就不得不添加不必要的 null 检查。到处添加 ! 则会掩盖错误。

⚠️ **返回类型必须反映语义上的可空性,而不只是满足编译器要求。**一个常见错误是,因为实现使用了 default! 或满足编译器要求的强制转换,就从返回类型中移除 ?。如果方法按设计可能返回 null,其返回类型就必须可空——无论编译器是否发出警告。常见模式:

  • 名称包含 *OrDefault 的方法(FirstOrDefault、SingleOrDefault、FindOrDefault)→ 返回类型必须可空(T?、object?、dynamic?),因为对于引用类型,“或默认值”意味着“或 null”。
  • ExecuteScalar 等数据库方法 → 返回类型必须是 object?,因为没有匹配行时,结果可能是 DBNull.Value 或 null。
  • Find、TryGet*(输出参数)和查找方法 → 如果目标项可能不存在,返回类型应为可空类型。
  • 任何在文档中说明或设计为在失败、未找到或输入为空时返回 null 的方法 → 返回类型必须可空。

如果实现通过 ! 或 default! 隐藏了 null,编译器无法发现返回类型漏掉了 ?。这会导致注解对调用方而言不正确——他们会相信非可空签名而跳过 null 检查,最终在运行时触发 NullReferenceException。

⚠️ **不要移除现有的 ArgumentNullException 检查。**非可空参数注解只是编译时提示——它不会阻止运行时出现 null。使用旧版 C#、其他 .NET 语言、反射或 ! 的调用方仍然可以传入 null。

⚠️ **标出缺少运行时 null 验证的公共 API 方法,但不要添加检查。**添加注解时,检查每个 public 和 protected 方法:如果参数为非可空(T,而非 T?),就应有运行时 null 检查(例如 ArgumentNullException.ThrowIfNull(param) 或 if (param is null) throw new ArgumentNullException(...))。如果没有这样的检查,运行时传入 null 会在方法体深处导致 NullReferenceException,而不是在入口处清晰地抛出 ArgumentNullException。添加 null 防护会改变运行时行为,不得作为 NRT 迁移的一部分。请改为询问用户,是否希望在相应位置插入 // TODO: Consider adding ArgumentNullException.ThrowIfNull(param) 注释。对于调用方可能未启用 NRT 的库,这一点尤其重要。 对于已定义 null 行为的方法,应接受可为 null 的参数。 如果方法能妥善处理 null 输入——返回 null、返回默认值,或返回失败结果而不是抛出异常——参数就应使用 T?,而不是 T。BCL 遵循这一惯例:Path.GetPathRoot(string?) 在输入为 null 时返回 null,而 Path.GetFullPath(string) 会抛出异常。只有在 null 会导致异常时,才使用不可为 null 的参数。如果方法实际上可以容忍 null,却将参数标记为不可为 null,就会迫使调用方在调用前添加不必要的 null 检查。

模糊情况: 如果参数既未针对 null 进行验证或清理,也没有说明 null 的处理方式,请考虑:(1)你自己的代码库中是否会传入 null?如果会 → 可为 null。(2)调用方是否可能将 null 用作“默认值”或无操作占位符?如果可能 → 可为 null。(3)同一区域中的类似方法是否接受 null?如果接受 → 为保持一致,应可为 null。(4)如果方法基本上不会处理 null,只是碰巧能够正常工作,但从 API 的用途来看,null 在语义上没有意义 → 不可为 null。对于参数应可为 null 还是不可为 null,如果拿不准,优先选择可为 null——这样更安全,之后也可以收紧限制。

解决解引用警告后,再处理注解警告:

警告含义典型修复方法
CS8618不可为 null 的字段/属性未在构造函数中初始化初始化成员、将其设为可为 null(?),或使用 required(C# 11+)。对于构造后总会设置、但在构造函数之外设置的字段(例如由框架生命周期方法、Init() 调用或构建器模式设置),使用 = null! 表明意图,同时确保该字段在每个使用位置都不可为 null。如果辅助方法会初始化字段,请为其添加 [MemberNotNull(nameof(field))],以便编译器知道调用后该字段不可为 null
CS8625无法将 null 字面量转换为不可为 null 的类型将目标设为可为 null,或提供非 null 值
CS8601可能存在 null 引用赋值使用与 CS8600 相同的技巧

对于每种类型,请判断:此成员是否应该为 null?

  • 是 → 在其声明中添加 ?。
  • 否 → 确保在每条构造函数路径中都对其进行初始化,或将其标记为 required(C# 11+)。
  • 否,但它在构造函数之后才设置(例如由框架方法、构建器或两阶段初始化模式设置)→ 在字段声明中使用 = null!。这样字段在所有使用位置的类型仍为不可为 null,同时也告诉编译器:“我保证在访问前会设置此字段。”这远优于在每个使用位置添加 !——如果一个字段被访问 50 次,就需要 50 个 ! 运算符,而不是一个 = null!。如果由特定方法完成初始化,也可以考虑在该方法上添加 [MemberNotNull(nameof(field))]。

优先处理公共 API 和受保护 API 的注解——这些 API 定义了使用者所依赖的契约。对于内部和私有代码,可以更灵活地使用 !,因为它不会影响外部调用方。

公共库:跟踪破坏性变更。 如果项目是供他人使用的库,请创建 nullable-breaking-changes.md 文件(或同等文件),记录所有可能影响使用者的公共 API 变更。为引用类型添加 ? 仅会更改元数据,不会破坏二进制兼容性,但会破坏启用了 NRT 的使用者的源代码兼容性——他们会收到新的警告或错误。需要记录的关键变更:

  • 返回类型从 T 改为 T?(使用者现在必须处理 null)
  • 参数从 T? 改为 T(使用者不能再传入 null)
  • 参数从 T 改为 T?(调用方现有的 null 检查变得不必要——影响较小,但值得记录)
  • 为值类型参数或返回值添加 ?(将 T 改为 Nullable<T>——会破坏二进制兼容性)
  • 在原本没有的地方新增 ArgumentNullException 防护
  • 标注过程中发现并修复的任何行为变更(例如,某个方法过去会静默接受 null,现在则会抛出异常)

将此文件提交给用户审核。它也可以作为发布说明的依据。

请特别注意:

  • DTO 与领域模型:根据类的用途采用不同的可空性策略。DTO 和序列化模型跨越信任边界(JSON、表单、外部 API)——其属性默认应为可空,除非序列化器会强制执行非空约束,因为无论声明的类型是什么,反序列化的数据都可能为 null。使用 required(C# 11+)、[JsonRequired](.NET 7+)或运行时验证来强制执行非空约束。领域模型表示内部不变量——优先使用由构造函数强制约束的非空属性,让无效状态无法表示。迁移时最常在这方面出错:将 DTO 当作领域模型会导致运行时 NullReferenceException;将领域模型当作 DTO 则会导致各处都出现不必要的 null 检查。
  • 事件处理程序和委托:EventHandler? handler = SomeEvent; handler?.Invoke(...) 这种模式符合惯用写法。
  • 结构体中的引用类型字段:使用 default(T) 时,结构体中的引用类型字段为 null。如果 default 是该结构体的有效用法,这些字段必须为可空。如果从不预期使用 default(该结构体只通过特定 API 创建),则保持为非空,以免让每个使用者都承担不必要的 null 检查。
  • Dispose 后的状态:如果某个字段或属性在对象的整个有效生命周期内都非空,但在 Dispose 后可能变为 null,则保持为非空。释放后继续使用对象违反了契约——不要因此放宽标注。
  • 重写和接口实现:重写方法可以返回比基类方法声明更严格(非空)的类型。如果你的实现从不返回 null,但基类或接口返回 T?,则可以将重写方法声明为返回 T。参数类型必须与基类完全一致。
  • 被广泛重写的虚拟返回类型:对于有许多类重写的虚拟/抽象方法,请考虑现有重写是否实际会返回 null。如果它们通常会返回 null(例如 Object.ToString()),则将返回类型标注为 T?——调用方需要知道这一点。如果返回 null 的重写极少见(例如 Exception.Message),则标注为 T。对于被广泛重写的虚拟方法,如有疑问,优先选择 T?。
  • IEquatable<T> 和 IComparable<T>:引用类型应实现 IEquatable<T?> 和 IComparable<T?>(使用可空的 T),因为调用方通常会向 Equals 和 CompareTo 传入 null。
  • Equals(object?) 重写:为 Equals(object? obj) 重写中的参数添加 [NotNullWhen(true)]——如果 Equals 返回 true,则保证参数非空。这样调用方在相等性测试后即可跳过多余的 null 检查。

构建检查点: 标注声明后,进行构建并确认不再有 CS8618/CS8625/CS8601 警告,然后再继续处理可空性特性。

步骤 5:为高级场景应用可空性特性

当简单的 ? 注释无法表达空值约定时,请应用 System.Diagnostics.CodeAnalysis 中的特性 — 有关完整的特性表([NotNullWhen]、[MaybeNullWhen]、[MemberNotNull]、[AllowNull]、[DisallowNull]、[DoesNotReturn] 等)及每项特性的用法指南,请参阅 references/nullable-attributes.md。

构建检查点: 应用可空性特性后,请进行构建,验证这些特性是否解决了目标警告,且未引入新警告。

步骤 6:清理抑制项

可选: 重新运行 scripts/Get-NullableReadiness.ps1,查看项目中 #nullable disable 指令、! 运算符和 #pragma warning disable CS86xx 抑制项的当前数量。

  1. 搜索临时添加的 #nullable disable 指令或 ! 运算符。
  2. 对每一项,判断是否仍需要抑制。
  3. 移除不再需要的抑制项。对于仍需保留的抑制项,请添加注释说明原因。
  4. 搜索 #pragma warning disable CS86,查找被抑制的可空性警告,并评估是否可以修复根本问题。

构建检查点: 移除抑制项后,请再次构建 — 移除 #nullable disable 或 ! 可能会产生需要修复的新警告。

步骤 7:验证
  1. 构建项目并确认没有可空性警告。
  2. 在项目文件中添加 <WarningsAsErrors>nullable</WarningsAsErrors>(若要应用于整个代码库,则添加到 Directory.Build.props),以永久防止可空性回归。这相当于项目文件中的 dotnet build /warnaserror:nullable。
  3. 运行现有测试,确认没有回归。
  4. 如果项目是库,请检查公共 API 表面,确认可空性注释符合预期约定(接受 null 的参数为 T?,拒绝 null 的参数为 T)。

在宣称迁移完成前请先验证。 警告为零并不代表迁移正确。报告成功前,请:(1) 抽查公共 API 签名 — 确认 ? 注释符合实际设计意图,而不只是让编译器不再报错;(2) 确认没有添加会改变运行时行为的 ?. 运算符(在差异中搜索 ?.);(3) 确认没有移除任何 ArgumentNullException 检查;(4) 检查 ! 运算符是否很少使用,并且每处都有说明理由的注释。

验证

  • 项目文件包含 <Nullable>enable</Nullable>(或者采用逐文件策略时,每个文件都包含 #nullable enable)
  • 构建不产生任何 CS86xx 警告
  • 已在项目文件中添加 <WarningsAsErrors>nullable</WarningsAsErrors>,以防止回归
  • 测试通过,且没有回归
  • 没有遗留 #nullable disable 指令,除非有注释说明其必要性
  • 空值抑制运算符(!)很少使用,且每处都有说明理由的注释
  • 公共 API 签名准确反映空值约定
  • 对于公共库:在 nullable-breaking-changes.md 中记录破坏性变更,并由用户审核
代码审查清单

可空性迁移变更所需的审查范围比一般差异更广:

  1. 确认没有行为变更:确认新增内容仅为 ? 和 ! — 没有意外添加 ?.,没有移除空值检查,也没有新增分支。除可空性元数据外,生成的 IL 应保持不变。
  2. 审查显式注释变更:对于每个新增到参数或返回类型上的 ?,确认其符合预期设计。该方法是否真的接受 null?是否真的可能返回 null?
  3. 审查范围内未变更的 API:启用 <Nullable>enable</Nullable> 后,该范围内所有未添加注释的引用类型都会隐式变为不可空。扫描未变更的公共成员,检查是否有实际接受 null 但未添加注释的参数。

NRT 注解带来的重大变更(库)

对于库,请参阅 references/breaking-changes.md——NRT 注解属于公共 API 契约的一部分,错误的注解会导致使用者遇到源代码兼容性破坏。

常见陷阱

陷阱解决方案
到处添加 ! 来消除警告null-forgiving 运算符会掩盖 Bug。添加 null 检查,或将类型改为可空类型
为了快速消除警告而将所有内容标记为 T?过度使用 ? 会适得其反——调用方不得不添加不必要的 null 检查。只有当 null 是有效值时才使用 ?
构造函数未初始化所有非可空成员在每个构造函数中初始化字段和属性,使用 required(C# 11+),或将成员设为可空
序列化会绕过构造函数——非可空 ≠ 运行时安全序列化程序创建对象时不会调用构造函数,因此非可空 DTO 属性在运行时仍可能为 null。详细指导请参阅步骤 4 中的“DTO 与领域模型”
生成的代码产生警告如果生成的文件包含 <auto-generated> 注释,系统会自动将其排除在可空性分析之外。如果警告仍然存在,请在生成的文件顶部添加 #nullable disable,或在 .editorconfig 中配置 generated_code = true
多目标项目和较旧的 TFMNRT 注解可在 C# 8.0+ 的较旧 TFM(例如 .NET Standard 2.0)上编译,但 [NotNullWhen] 等可空性属性可能不存在。使用 NuGet 中名为 Nullable 的 polyfill 包,或在内部定义这些属性
升级依赖项后警告重新出现该依赖项添加了可空性注解。这是预期且有益的——按照步骤 3–5 修复新增的警告
添加注解时意外改变行为为类型添加 ? 或为表达式添加 ! 仅会影响元数据,不会改变生成的 IL。但将 obj.Method() 替换为 obj?.Method()(null 条件运算符)会改变运行时行为——调用会被静默跳过,而不是抛出异常。只有在确实希望容忍 null 时才使用 ?.,不要将其作为快速消除警告的办法
为值类型(枚举、结构体)添加 ?对于引用类型,? 是不会影响运行时的元数据注解。对于 int 或枚举等值类型,? 会将类型变为 Nullable<T>,从而改变方法签名、二进制布局和装箱行为。除非确实要将值类型设为可空,否则请确认只为引用类型添加 ?
移除现有的 null 参数验证非可空注解仅在编译时生效——调用方在运行时仍可传入 null。保留现有的 ArgumentNullException 检查。详情请参阅步骤 4
var 会根据赋值表达式推断可空性使用 var 时,推断出的类型会包含赋值表达式的可空性,与显式声明 T 或 T? 相比,这可能会让人意外。此后,流分析会确定实际的 null 状态,但推断出的声明类型可能带有意料之外的可空性。如果声明时的可空性必须精确,请使用显式类型而不是 var
使用未注解的(可空性不明的)库如果依赖项未启用可空性注解,编译器会将其所有类型视为“不明”——解引用或赋值 null 时都不会发出警告。这会造成安全性的错觉。请将不明 API 的返回值视为可能为 null,尤其是理论上可能返回 null 的方法(字典查找、FirstOrDefault 类调用)。尽可能升级依赖项或封装调用

Entity Framework Core 注意事项

如果项目使用 EF Core,请参阅 references/ef-core.md——启用 NRT 可能会改变数据库架构推断和迁移输出。

ASP.NET Core 注意事项

如果项目使用 ASP.NET Core,请参阅 references/aspnet-core.md——启用 NRT 可能会改变 MVC 模型验证和 JSON 序列化行为。

更多信息

评论

支持 Markdown