关于 migrate-nullable-references
用于在现有代码库中启用 C# 可空引用类型并处理相关警告的技能,强调准确标注 API,并在迁移期间保持运行时行为不变。
主要功能
- 01评估可空迁移准备情况
- 02选择全项目或逐文件推进
- 03处理可空解引用警告
- 04标注公共 API 的可空性
- 05审查现有警告抑制
- 06保持运行时行为不变
适用场景
- 01在 C# 项目中启用可空引用类型
- 02系统地清除 CS86xx 警告
- 03完善库 API 的可空性约定
- 04审查 NRT 迁移后的抑制项
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill migrate-nullable-references安装后在 Claude Code 中输入/migrate-nullable-references确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/aspnet-core.mdbreaking-changes.mdef-core.mdnullable-attributes.mdscripts/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可输出机器可读的结果。
- 确定项目的构建和测试方式。查找构建脚本(例如
build.cmd、build.sh、Makefile)、.sln文件或各个.csproj文件。如果仓库使用自定义构建脚本,请在整个工作流中使用该脚本,而不要使用dotnet build。 - 运行
dotnet --version,确认已安装 SDK。可空引用类型(NRT)要求使用 C# 8.0 或更高版本(.NET Core 3.0/.NET Standard 2.1或更高版本)。 - 打开
.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+),并询问他们是要进行该更新并继续,还是中止迁移。不要默默继续,也不要假定用户接受该更新。
- 检查是否已设置
<Nullable>。如果已设置为enable,请跳到步骤 5,审核剩余警告。 - 确定项目类型——这会影响整个迁移过程中的注释优先级:
- **库:**优先关注公共 API 契约。公共参数或返回类型上的每个
?都会改变消费者依赖的契约。务必谨慎、准确。 - **应用程序(Web、控制台、桌面):**优先确保边界处的空值安全——反序列化、数据库查询、用户输入、外部 API 响应。内部管道代码可以更宽松地添加注释。
- **测试项目:**注释精度的优先级较低。在测试设置和断言中,如果确定不会出现空值,可以更自由地使用
!。重点是确保测试代码能够干净地编译。
- **库:**优先关注公共 API 契约。公共参数或返回类型上的每个
步骤 2:选择推广策略
根据代码库的规模和活跃程度,选择以下一种策略。向用户推荐该策略,并在继续之前征得确认。
**多项目解决方案:**按依赖顺序迁移——先迁移共享库和核心项目,再迁移依赖它们的项目。先注释依赖项,可以消除其使用方的连带警告,避免重复工作。
无论采用哪种策略,都要从中心向外推进:从核心领域模型、DTO 和共享实用类型着手,它们依赖项较少,但被广泛使用。先注释这些类型,可以消除整个代码库中的连带警告,并以最少投入获得最大收益。然后再处理依赖核心类型的高层服务、控制器和 UI 代码。这种方法可以最大限度地减少每一步中的警告数量,避免因整个大型项目一次性启用而被大量警告淹没。优先为每个项目或每个层至少创建一个 PR,使变更集便于审查且重点明确。如果需要添加的注释相对较少,一次性在整个项目中启用并创建单个 PR 也可能合适。
策略 A——在整个项目中启用(小型到中型项目)
适用于源文件大约少于 50 个的项目,或团队希望一次性完成的情况。
- 在
.csproj的<PropertyGroup>中添加<Nullable>enable</Nullable>。 - 构建项目并一次性处理所有警告。
策略 B — 先处理警告,再添加注解(大型或活跃项目)
适用于代码库庞大,或由多位贡献者积极开发的项目。
- 在
.csproj中添加<Nullable>warnings</Nullable>。这会启用警告,但不会改变类型语义。 - 构建项目,从步骤 3 开始修复所有警告。
- 将其改为
<Nullable>enable</Nullable>以启用注解——这会触发第二波警告。 - 从步骤 4 开始解决注解阶段的警告。
策略 C — 逐文件迁移(超大型项目)
适用于大型旧代码库,在整个项目中启用可空引用类型会产生数量多到难以管理的警告。
- 在项目级别设置
<Nullable>disable</Nullable>(或省略此设置)。 - 迁移每个文件时,在文件顶部添加
#nullable enable。 - 按依赖顺序确定文件优先级:先处理共享实用工具和模型,再处理更高层的使用者。
构建检查点:启用
<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>,从而改变方法签名、二进制布局和装箱行为。
每条警告的决策流程图:
- 按设计而言,null 在此处是有效值吗?
- 是 → 在声明中添加
?(使其可空)。 - 否 → 转到第 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抑制项的当前数量。
- 搜索临时添加的
#nullable disable指令或!运算符。 - 对每一项,判断是否仍需要抑制。
- 移除不再需要的抑制项。对于仍需保留的抑制项,请添加注释说明原因。
- 搜索
#pragma warning disable CS86,查找被抑制的可空性警告,并评估是否可以修复根本问题。
构建检查点: 移除抑制项后,请再次构建 — 移除
#nullable disable或!可能会产生需要修复的新警告。
步骤 7:验证
- 构建项目并确认没有可空性警告。
- 在项目文件中添加
<WarningsAsErrors>nullable</WarningsAsErrors>(若要应用于整个代码库,则添加到Directory.Build.props),以永久防止可空性回归。这相当于项目文件中的dotnet build /warnaserror:nullable。 - 运行现有测试,确认没有回归。
- 如果项目是库,请检查公共 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中记录破坏性变更,并由用户审核
代码审查清单
可空性迁移变更所需的审查范围比一般差异更广:
- 确认没有行为变更:确认新增内容仅为
?和!— 没有意外添加?.,没有移除空值检查,也没有新增分支。除可空性元数据外,生成的 IL 应保持不变。 - 审查显式注释变更:对于每个新增到参数或返回类型上的
?,确认其符合预期设计。该方法是否真的接受 null?是否真的可能返回 null? - 审查范围内未变更的 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 |
| 多目标项目和较旧的 TFM | NRT 注解可在 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 序列化行为。
更多信息
- 可空引用类型 — 功能概述、可空上下文和编译器分析
- 可空引用类型(C# 参考) — 可空注解和警告上下文的语言参考
- 可空迁移策略
- 拥抱可空引用类型 — Mads Torgersen 关于采用时机和生态系统考量的建议
- 解决可空警告
- 用于可空静态分析的特性
- !(null-forgiving)运算符 — 该运算符及其使用时机的语言参考
- EF Core 和可空引用类型
- .NET Runtime 可空注解指南 — 为 .NET 库本身添加注解时采用的注解原则