migrate-nullable-references

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Nullable Reference Migration

可空引用类型迁移

Enable C# nullable reference types (NRTs) in an existing codebase and systematically resolve all warnings. The outcome is a project (or solution) with
<Nullable>enable</Nullable>
, zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information.
在现有代码库中启用C#可空引用类型(NRTs),并系统性地解决所有警告。最终成果是项目(或解决方案)中配置了
<Nullable>enable</Nullable>
,零可空警告,且公共API表面带有准确的注解——为编译器和使用者提供可靠的可空性信息。

When to Use

适用场景

  • Enabling nullable reference types in an existing C# project or solution
  • Systematically resolving CS86xx nullable warnings after enabling the feature
  • Annotating a library's public API surface so consumers get accurate nullability information
  • Upgrading a dependency that has added nullable annotations and new warnings appear
  • Analyzing suppressions in a code base that has already enabled NRTs to determine whether they can be removed
  • 在现有C#项目或解决方案中启用可空引用类型
  • 启用该特性后系统性地解决CS86xx系列可空警告
  • 为类库的公共API表面添加注解,让使用者获得准确的可空性信息
  • 升级依赖项后出现新的可空警告时进行修复
  • 分析已启用NRTs的代码库中的抑制规则,判断是否可移除

When Not to Use

不适用场景

  • The project already has
    <Nullable>enable</Nullable>
    and zero warnings — the migration is done unless the user wants to re-examine suppressions with a view to removing unnecessary ones (see Step 6)
  • The user only wants to suppress warnings without fixing them (recommend against this)
  • The code targets C# 7.3 or earlier, which does not support nullable reference types
  • 项目已配置
    <Nullable>enable</Nullable>
    且无任何警告——迁移已完成,除非用户希望重新检查抑制规则以移除不必要的项(见步骤6)
  • 用户仅想抑制警告而不修复问题(不推荐此做法)
  • 代码目标框架为C# 7.3或更早版本,该版本不支持可空引用类型

Inputs

输入项

InputRequiredDescription
Project or solution pathYesThe
.csproj
,
.sln
, or build entry point to migrate
Migration scopeNo
project-wide
(default) or
file-by-file
— controls the rollout strategy
Build commandNoHow to build the project (e.g.,
dotnet build
,
msbuild
, or a repo-specific build script). Detect from the repo if not provided
Test commandNoHow to run tests (e.g.,
dotnet test
, or a repo-specific test script). Detect from the repo if not provided
输入项是否必填描述
项目或解决方案路径要迁移的
.csproj
.sln
或构建入口文件
迁移范围
project-wide
(默认)或
file-by-file
——控制部署策略
构建命令项目构建方式(例如
dotnet build
msbuild
或仓库自定义构建脚本)。若未提供,将自动从仓库中检测
测试命令测试运行方式(例如
dotnet test
或仓库自定义测试脚本)。若未提供,将自动从仓库中检测

Workflow

工作流程

🛑 Zero runtime behavior changes. NRT migration is strictly a metadata and annotation exercise. The generated IL must not change — no new branches, no new null checks, no changed control flow, no added or removed method calls. The only acceptable changes are nullable annotations (
?
), nullable attributes (
[NotNullWhen]
, etc.),
!
operators (metadata-only), and
#nullable
directives. If you discover a missing runtime null guard or a latent bug during migration, do not fix it inline. Instead, offer to insert a
// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
comment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit.
Commit strategy: Commit at each logical boundary — after enabling
<Nullable>
(Step 2), after fixing dereference warnings (Step 3), after annotating declarations (Step 4), after applying nullable attributes (Step 5), and after cleaning up suppressions (Step 6). This keeps each commit focused and reviewable, and prevents losing work if a later step reveals a design issue that requires rethinking. For file-by-file migrations, commit each file or batch of related files individually.
🛑 零运行时行为变更。NRT迁移严格是元数据和注解操作,生成的IL代码不得变更——不得添加新分支、新空值检查、修改控制流、增删方法调用。仅允许的变更包括:可空注解(
?
)、可空属性(
[NotNullWhen]
等)、
!
运算符(仅元数据层面)和
#nullable
指令。如果在迁移过程中发现缺失的运行时空值防护或潜在Bug,请勿在当前迁移中修复。建议在对应位置插入
// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
注释,让用户在单独的变更中处理。绝对不要将行为修复与注解提交混在一起。
提交策略:在每个逻辑阶段提交代码——启用
<Nullable>
后(步骤2)、修复解引用警告后(步骤3)、完成声明注解后(步骤4)、应用可空属性后(步骤5)、清理抑制规则后(步骤6)。这样每个提交都聚焦明确,便于评审,且避免后续步骤出现设计问题导致工作丢失。对于按文件迁移的场景,逐个文件或相关文件批次提交。

Step 1: Evaluate readiness

步骤1:评估迁移就绪度

Optional: Run
scripts/Get-NullableReadiness.ps1 -Path <project-or-solution>
to automate the checks below. The script reports
<Nullable>
,
<LangVersion>
,
<TargetFramework>
,
<WarningsAsErrors>
settings and counts
#nullable disable
directives,
!
operators, and
#pragma warning disable CS86xx
suppressions. Use
-Json
for machine-readable output.
  1. Identify how the project is built and tested. Look for build scripts (e.g.,
    build.cmd
    ,
    build.sh
    ,
    Makefile
    ), a
    .sln
    file, or individual
    .csproj
    files. If the repo uses a custom build script, use it instead of
    dotnet build
    throughout this workflow.
  2. Run
    dotnet --version
    to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (
    .NET Core 3.0
    /
    .NET Standard 2.1
    or later).
  3. Open the
    .csproj
    (or
    Directory.Build.props
    if properties are set at the repo level) and check the
    <LangVersion>
    and
    <TargetFramework>
    . If the project multi-targets, note all TFMs.
Stop if the language version or target framework is insufficient. If
<LangVersion>
is below 8.0, or the project targets a framework that defaults to C# 7.x (e.g.,
.NET Framework 4.x
without an explicit
<LangVersion>
), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set
<LangVersion>8.0</LangVersion>
or higher, or retarget to
.NET Core 3.0+
/
.NET 5+
), and ask whether they want to make that update and continue, or abort the migration. Do not silently proceed or assume the update is acceptable.
  1. Check whether
    <Nullable>
    is already set. If it is set to
    enable
    , skip to Step 5 to audit remaining warnings.
  2. Determine the project type — this shapes annotation priorities throughout the migration:
    • Library: Focus on public API contracts first. Every
      ?
      on a public parameter or return type is a contract change that consumers depend on. Be precise and conservative.
    • Application (web, console, desktop): Focus on null safety at boundaries — deserialization, database queries, user input, external API responses. Internal plumbing can be annotated more liberally.
    • Test project: Lower priority for annotation precision. Use
      !
      more freely on test setup and assertions where null is never expected. Focus on ensuring test code compiles cleanly.
可选操作:运行
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。可空引用类型(NRTs)需要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(例如.NET Framework 4.x且未显式设置
<LangVersion>
),则无法直接启用NRTs。需明确告知用户:说明需要修改的配置(设置
<LangVersion>8.0</LangVersion>
或更高版本,或重新定位到.NET Core 3.0+ / .NET 5+),并询问用户是否要进行该更新后继续迁移,还是终止迁移。请勿静默继续或默认用户接受更新。
  1. 检查是否已设置
    <Nullable>
    。如果已设置为
    enable
    ,直接跳至步骤5审核剩余警告。
  2. 确定项目类型——这将决定整个迁移过程中的注解优先级:
    • 类库:优先聚焦公共API契约。公共参数或返回值上的每个
      ?
      都是使用者依赖的契约变更,需精准且保守处理。
    • 应用程序(Web、控制台、桌面):优先聚焦边界处的空值安全——反序列化、数据库查询、用户输入、外部API响应。内部代码的注解可以更灵活。
    • 测试项目:注解精度优先级较低。在测试设置和断言中可更自由地使用
      !
      ,因为此处永远不会出现空值。重点确保测试代码可干净编译。

Step 2: Choose a rollout strategy

步骤2:选择部署策略

Pick one of the following strategies based on codebase size and activity level. Recommend the strategy to the user and confirm before proceeding.
Multi-project solutions: Migrate in dependency order — shared libraries and core projects first, then projects that consume them. Annotating a dependency first eliminates cascading warnings in its consumers and prevents doing work twice.
Regardless of strategy, start at the center and work outward:begin with core domain models, DTOs, and shared utility types that have few dependencies but are used widely. Annotating these first eliminates cascading warnings across the codebase and gives the biggest return on effort. Then move on to higher-level services, controllers, and UI code that depend on the core types. This approach minimizes the number of warnings at each step and prevents getting overwhelmed by a flood of warnings from a large project-wide enable. Prefer to create at least one PR per project, or per layer, to keep changesets reviewable and focused. If there are relatively few annotations needed, a single project-wide enable and single PR may be appropriate.
根据代码库规模和活跃程度选择以下策略之一。向用户推荐策略并确认后再继续。
多项目解决方案:按依赖顺序迁移——先迁移共享类库和核心项目,再迁移依赖它们的项目。先注解依赖项可消除其使用者的级联警告,避免重复工作。
无论选择哪种策略,都要从核心向外推进:先处理依赖少但使用广泛的核心领域模型、DTO和共享工具类。先注解这些类型可消除代码库中的级联警告,投入产出比最高。然后再处理依赖核心类型的高层服务、控制器和UI代码。这种方式可最大限度减少每个步骤的警告数量,避免因项目全局启用而被大量警告淹没。建议至少为每个项目或每个层级创建一个PR,确保变更集便于评审和聚焦。如果需要的注解相对较少,也可采用项目全局启用+单个PR的方式。

Strategy A — Project-wide enable (small to medium projects)

策略A——项目全局启用(中小型项目)

Best when the project has fewer than roughly 50 source files or the team wants to finish in one pass.
  1. Add
    <Nullable>enable</Nullable>
    to the
    <PropertyGroup>
    in the
    .csproj
    .
  2. Build and address all warnings at once.
适用于源文件少于约50个,或团队希望一次性完成迁移的项目。
  1. .csproj
    <PropertyGroup>
    中添加
    <Nullable>enable</Nullable>
  2. 构建项目并一次性处理所有警告。

Strategy B — Warnings-first, then annotations (large or active projects)

策略B——先处理警告,再添加注解(大型或活跃项目)

Best when the codebase is large or under active development by multiple contributors.
  1. Add
    <Nullable>warnings</Nullable>
    to the
    .csproj
    . This enables warnings without changing type semantics.
  2. Build, fix all warnings from Step 3 onward.
  3. Change to
    <Nullable>enable</Nullable>
    to activate annotations — this triggers a second wave of warnings.
  4. Resolve the annotation-phase warnings from Step 4 onward.
适用于代码库规模大,或有多个开发者活跃开发的项目。
  1. .csproj
    中添加
    <Nullable>warnings</Nullable>
    。这将启用警告但不改变类型语义。
  2. 构建项目,从步骤3开始修复所有警告。
  3. 将配置改为
    <Nullable>enable</Nullable>
    以激活注解——这会触发第二轮警告。
  4. 从步骤4开始处理注解阶段的警告。

Strategy C — File-by-file (very large projects)

策略C——按文件迁移(超大型项目)

Best for large legacy codebases where enabling project-wide would produce an unmanageable number of warnings.
  1. Set
    <Nullable>disable</Nullable>
    (or omit it) at the project level.
  2. Add
    #nullable enable
    at the top of each file as it is migrated.
  3. Prioritize files in dependency order: shared utilities and models first, then higher-level consumers.
Build checkpoint: After enabling
<Nullable>
(or adding
#nullable enable
to the first batch of files), do a clean build (e.g.,
dotnet build --no-incremental
, or delete
bin
/
obj
first). Incremental builds only recompile changed files and will hide warnings in untouched files. Record the initial warning count — this is the baseline to work down from. Do not proceed to fixing warnings without first confirming the project still compiles. Use clean builds for all subsequent build checkpoints in this workflow.
适用于大型遗留代码库,全局启用会产生无法管理的大量警告的场景。
  1. 在项目级别设置
    <Nullable>disable</Nullable>
    (或省略该配置)。
  2. 在每个要迁移的文件顶部添加
    #nullable enable
  3. 按依赖顺序优先处理文件:先处理共享工具类和模型,再处理高层使用者。
构建检查点:启用
<Nullable>
后(或为首批文件添加
#nullable enable
后),执行清理构建(例如
dotnet build --no-incremental
,或先删除
bin
/
obj
目录)。增量构建仅重新编译变更的文件,会隐藏未修改文件中的警告。记录初始警告数——这是后续工作的基准。在未确认项目可正常编译前,请勿开始修复警告。在后续工作流程的所有构建检查点中,都要使用清理构建。

Step 3: Fix dereference warnings

步骤3:修复解引用警告

Prioritization: Work through files in dependency order — start with core models and shared utilities that other code depends on, then move to higher-level consumers. Within each file, fix public and protected members first (these define the contract), then internal and private members. This order minimizes cascading warnings: fixing a core type's annotations often resolves warnings in its consumers automatically.
Build the project and work through dereference warnings. These are the most common:
WarningMeaningTypical fix
CS8602Dereference of a possibly null referencePrefer annotation-only fixes: make the upstream type nullable (
T?
) if null is valid, or use
!
if you can verify the value is never null at this point. Adding a null check or
?.
changes runtime behavior — reserve those for a separate commit (see zero-behavior-change rule above)
CS8600Converting possible null to non-nullable typeAdd
?
to the target type if null is valid, or use
!
if you can verify the value is never null. Adding a null guard changes runtime behavior
CS8603Possible null reference returnChange the return type to nullable (
T?
) if the method can genuinely return null. Do not suppress with
!
if the method can genuinely return null
— fix the return type instead. This is the single most important rule in NRT migration: a non-nullable return type is a promise to every caller that null will never be returned
CS8604Possible null reference argumentMark the parameter as nullable if null is valid, or use
!
if the argument is verifiably non-null. Adding a null check before passing changes runtime behavior
Do not use
?.
as a quick fix for dereference warnings.
Replacing
obj.Method()
with
obj?.Method()
silently changes runtime behavior — the call is skipped instead of throwing. Only use
?.
when you intentionally want to tolerate null.
Do not sprinkle
!
to silence warnings.
Each
!
is a claim that the value is never null. If that claim is wrong, you have hidden a
NullReferenceException
. Add a null check or make the type nullable instead.
Never use
return null!
to keep a return type non-nullable.
If a method returns
null
, the return type must be
T?
. Writing
return null!
hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and get
NullReferenceException
at runtime. This applies to
null!
,
default!
, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use of
!
on a return value is when the value is provably never null but the compiler cannot see why.
⚠️ Do not add
?
to value types unless you intend to change the runtime type.
For reference types,
?
is metadata-only. For value types (
int
, enums, structs),
?
changes the type to
Nullable<T>
, altering the method signature, binary layout, and boxing behavior.
Decision flowchart for each warning:
  1. Is null a valid value here by design?
    • Yes → add
      ?
      to the declaration (make it nullable).
    • No → go to step 2.
    • Unsure → ask the user before proceeding.
  2. Can you prove the value is never null at this point?
    • Yes, with a code path the compiler can't see → add
      !
      with a comment explaining why.
    • Yes, by adding a guard → add a null check (
      if
      ,
      ??
      ,
      is not null
      ).
    • No → the type should be nullable (go back to step 1 — the answer is "Yes").
Guidance:
  • Prefer explicit null checks (
    if
    ,
    is not null
    ,
    ??
    ) over the null-forgiving operator (
    !
    ).
  • Use the null-forgiving operator only when you can prove the value is never null but the compiler cannot, and add a comment explaining why.
  • Guard clause libraries (e.g., Ardalis.GuardClauses, Dawn.Guard) often decorate parameters with
    [NotNull]
    , which narrows null state after the guard call. After
    Guard.Against.NullOrEmpty(value, nameof(value))
    , the compiler already narrows
    string?
    to
    string
    — do not add a redundant
    !
    at the subsequent assignment. Check whether the guard method uses
    [NotNull]
    before assuming the compiler needs help.
  • When a method legitimately returns null, change the return type to
    T?
    — do not hide nulls behind a non-nullable signature.
  • Debug.Assert(x != null)
    acts as a null-state hint to the compiler just like an
    if
    check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequent
    !
    operators in that scope. Note:
    Debug.Assert
    informs the compiler but is stripped from Release builds — it does not protect against null at runtime. For public API boundaries, prefer an explicit null check or
    ArgumentNullException
    .
  • If you find yourself adding
    !
    at every call site of an internal method, consider making that parameter nullable instead. Reserve
    !
    for cases where the compiler genuinely cannot prove non-nullness.
  • When a boolean-returning helper method's result guarantees a nullable parameter is non-null (e.g.,
    if (IsValid(x))
    implies
    x != null
    ), prefer adding
    [NotNullWhen(true)]
    to the helper's parameter over using
    !
    at every call site. This is a metadata-only change (no behavior change) that eliminates
    !
    operators downstream while giving the compiler real flow information.
  • For fields that are always set after construction (e.g., by a framework, an
    Init()
    method, or a builder pattern), prefer
    = null!
    on the field declaration over adding
    !
    at every use site. A field accessed 50 times should have one
    = null!
    , not fifty
    field!
    assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair with
    [MemberNotNull]
    on the initializing method when possible.
  • For generic methods returning
    default
    on an unconstrained type parameter (e.g.,
    FirstOrDefault<T>
    ), use
    [return: MaybeNull] T
    rather than
    T?
    . Writing
    T?
    on an unconstrained generic changes value-type signatures to
    Nullable<T>
    , altering the method signature and binary layout.
    [return: MaybeNull]
    preserves the original signature while communicating that the return may be null for reference types.
  • LINQ's
    Where(x => x != null)
    does not narrow
    T?
    to
    T
    — the compiler cannot track nullability through lambdas passed to generic methods. Use
    source.OfType<T>()
    to filter nulls with correct type narrowing.
Build checkpoint: After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings.
优先级:按依赖顺序处理文件——先处理其他代码依赖的核心模型和共享工具类,再处理高层使用者。在每个文件中,先修复公共和受保护成员(这些定义了契约),再处理内部和私有成员。此顺序可最大限度减少级联警告:修复核心类型的注解通常会自动解决其使用者的警告。
构建项目并处理解引用警告。最常见的警告如下:
警告代码含义典型修复方式
CS8602解引用可能为空的引用优先选择仅注解的修复方式:如果空值是合法的,将上游类型设为可空(
T?
);如果可验证此处值永远不为空,使用
!
。添加空值检查或
?.
会改变运行时行为——请保留到单独的提交中(遵循上述零行为变更规则)
CS8600将可能为空的引用转换为非可空类型如果空值合法,为目标类型添加
?
;如果可验证值永远不为空,使用
!
。添加空值防护会改变运行时行为
CS8603可能返回空引用如果方法确实可能返回空,将返回类型改为可空(
T?
)。如果方法确实可能返回空,请勿使用
!
抑制警告
——请修复返回类型。这是NRT迁移中最重要的规则:非可空返回类型是对所有调用者的承诺,永远不会返回空值
CS8604可能为空的引用参数如果空值合法,将参数标记为可空;如果可验证参数永远不为空,使用
!
。在传递前添加空值检查会改变运行时行为
请勿使用
?.
快速修复解引用警告
。将
obj.Method()
替换为
obj?.Method()
会静默改变运行时行为——调用会被跳过而非抛出异常。仅当你有意要容忍空值时才使用
?.
请勿随意使用
!
来抑制警告
。每个
!
都是声明该值永远不为空。如果该声明错误,你就隐藏了一个
NullReferenceException
。请添加空值检查或将类型设为可空。
永远不要使用
return null!
来保持返回类型为非可空
。如果方法返回
null
,返回类型必须是
T?
。编写
return null!
会将空值隐藏在非可空签名后——调用者会信任该签名,跳过空值检查,最终在运行时出现
NullReferenceException
。这一规则适用于
null!
default!
以及任何让编译器接受非可空位置为空值的强制转换。仅当值绝对不可能为空但编译器无法识别时,才可以在返回值上使用
!
⚠️ 除非有意改变运行时类型,否则请勿为值类型添加
?
。对于引用类型,
?
仅为元数据。对于值类型(
int
、枚举、结构体),
?
会将类型改为
Nullable<T>
,改变方法签名、二进制布局和装箱行为。
每个警告的决策流程图
  1. 此处空值是设计允许的合法值吗?
    • → 在声明中添加
      ?
      (设为可空)。
    • → 进入步骤2。
    • 不确定 → 先询问用户再继续。
  2. 你能证明此处值永远不为空吗?
    • 是,但编译器无法识别代码路径 → 添加
      !
      并附上注释说明原因。
    • 是,可通过添加防护实现 → 添加空值检查(
      if
      ??
      is not null
      )。
    • → 该类型应该设为可空(回到步骤1,答案为“是”)。
指导原则:
  • 优先使用显式空值检查(
    if
    is not null
    ??
    )而非空宽恕运算符(
    !
    )。
  • 仅当你能证明值永远不为空但编译器无法识别时,才使用空宽恕运算符,并添加注释说明原因。
  • 防护子句库(如Ardalis.GuardClauses、Dawn.Guard)通常会为参数添加
    [NotNull]
    属性,这会在防护调用后缩小空值状态。在
    Guard.Against.NullOrEmpty(value, nameof(value))
    之后,编译器会自动将
    string?
    缩小为
    string
    ——无需在后续赋值中添加冗余的
    !
    。在假设编译器需要帮助前,请先检查防护方法是否使用了
    [NotNull]
  • 当方法确实会返回空值时,将返回类型改为
    T?
    ——不要将空值隐藏在非可空签名后。
  • Debug.Assert(x != null)
    if
    检查一样,会向编译器提供空值状态提示。在方法或代码块顶部使用它,可告知流分析器不变量,消除该范围内后续的
    !
    运算符。注意:
    Debug.Assert
    仅告知编译器,在Release构建中会被移除——无法在运行时防护空值。对于公共API边界,优先使用显式空值检查或
    ArgumentNullException
  • 如果布尔返回值的辅助方法结果可保证可空参数不为空(例如
    if (IsValid(x))
    意味着
    x != null
    ),优先为辅助方法的参数添加
    [NotNullWhen(true)]
    ,而非在每个调用点使用
    !
    。这是仅元数据的变更(无行为变更),可消除下游的
    !
    运算符,同时为编译器提供真实的流信息。
  • 对于在构造后才会被设置的字段(例如由框架、
    Init()
    方法或构建器模式设置),优先在字段声明中使用
    = null!
    ,而非在每个使用点添加
    !
    。一个被访问50次的字段应该只使用一次
    = null!
    ,而非50次
    field!
    断言。这样既可以在类型系统中保持字段为非可空,又能说明其延迟初始化的情况。可能的话,配合在初始化方法上使用
    [MemberNotNull]
  • 对于无约束类型参数返回
    default
    的泛型方法(例如
    FirstOrDefault<T>
    ),使用
    [return: MaybeNull] T
    而非
    T?
    。在无约束泛型上使用
    T?
    会将值类型签名改为
    Nullable<T>
    ,改变方法签名和二进制布局。
    [return: MaybeNull]
    可保留原签名,同时告知引用类型的返回值可能为空。
  • LINQ的
    Where(x => x != null)
    不会将
    T?
    缩小为
    T
    ——编译器无法跟踪传递给泛型方法的Lambda表达式中的空值性。使用
    source.OfType<T>()
    过滤空值可实现正确的类型缩小。
构建检查点:修复解引用警告后,构建项目并确认CS8602/CS8600/CS8603/CS8604警告已全部消除,再处理注解警告。

Step 4: Annotate declarations

步骤4:为声明添加注解

Start by deciding the intended nullability of each member based on its design purpose — should this parameter accept null? Can this return value ever be null? Annotate accordingly, then address any resulting warnings. Do not let warnings drive your annotations; that leads to over-annotating with
?
or scattering
!
to silence the compiler.
When to ask the user: Do not guess API contracts. Never infer nullability intent from usage frequency or naming conventions alone — if intent is not explicit in code or documentation, ask the user. Specifically, ask before: (1) changing a public method's return type to nullable or adding
?
to a public parameter — this changes the API contract consumers depend on; (2) deciding whether a property should be nullable vs. required when the design intent is unclear; (3) choosing between a null check and
!
when you cannot determine from context whether null is a valid state. For internal/private members where the answer is obvious from usage, proceed without asking.
Do not let warnings drive annotations. Decide the intended nullability of each member first, then annotate. Adding
?
everywhere to make warnings disappear defeats the purpose — callers must then add unnecessary null checks. Adding
!
everywhere hides bugs.
⚠️ Return types must reflect semantic nullability, not just compiler satisfaction. A common mistake is removing
?
from a return type because the implementation uses
default!
or a cast that satisfies the compiler. If the method can return null by design, its return type must be nullable — regardless of whether the compiler warns. Key patterns:
  • Methods named
    *OrDefault
    (
    FirstOrDefault
    ,
    SingleOrDefault
    ,
    FindOrDefault
    ) → return type must be nullable (
    T?
    ,
    object?
    ,
    dynamic?
    ) because "or default" means "or null" for reference types.
  • ExecuteScalar
    and similar database methods → return type must be
    object?
    because the result can be
    DBNull.Value
    or null when no rows match.
  • Find
    ,
    TryGet*
    (out parameter), and lookup methods → return type should be nullable when the item may not exist.
  • Any method documented or designed to return null on failure, not-found, or empty-input → nullable return type.
The compiler cannot catch a missing
?
on a return type when the implementation hides null behind
!
or
default!
. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading to
NullReferenceException
at runtime.
⚠️ Do not remove existing
ArgumentNullException
checks.
A non-nullable parameter annotation is a compile-time hint only — it does not prevent null at runtime. Callers using older C# versions, other .NET languages, reflection, or
!
can still pass null.
⚠️ Flag public API methods missing runtime null validation — but do not add checks. While annotating, check each
public
and
protected
method: if a parameter is non-nullable (
T
, not
T?
), there should be a runtime null check (e.g.,
ArgumentNullException.ThrowIfNull(param)
or
if (param is null) throw new ArgumentNullException(...)
). Without one, a null passed at runtime causes a
NullReferenceException
deep in the method body instead of a clear
ArgumentNullException
at the entry point. Adding a null guard is a runtime behavior change and must not be part of the NRT migration. Instead, ask the user whether they want a
// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
comment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled.
Methods with defined behavior for null should accept nullable parameters. If a method handles null input gracefully — returning null, returning a default, or returning a failure result instead of throwing — the parameter should be
T?
, not
T
. The BCL follows this convention:
Path.GetPathRoot(string?)
returns null for null input, while
Path.GetFullPath(string)
throws. Only use a non-nullable parameter when null causes an exception. Marking a parameter as non-nullable when the method actually tolerates null forces callers to add unnecessary null checks before calling.
Gray areas: When a parameter is neither validated, sanitized, nor documented for null, consider: (1) Is null ever passed in your own codebase? If yes → nullable. (2) Is null likely used as a "default" or no-op placeholder by callers? If yes → nullable. (3) Do similar methods in the same area accept null? If yes → nullable for consistency. (4) If the method is largely oblivious to null and just happens to work, but null makes no semantic sense for the API's purpose → non-nullable. When in doubt between nullable and non-nullable for a parameter, prefer nullable — it is safer and can be tightened later.
After dereference warnings are resolved, address annotation warnings:
WarningMeaningTypical fix
CS8618Non-nullable field/property not initialized in constructorInitialize the member, make it nullable (
?
), or use
required
(C# 11+). For fields that are always set after construction but outside the constructor (e.g., by a framework lifecycle method, an
Init()
call, or a builder pattern), use
= null!
to declare intent while keeping the field non-nullable at every use site. If a helper method initializes fields, decorate it with
[MemberNotNull(nameof(field))]
so the compiler knows the field is non-null after the call
CS8625Cannot convert null literal to non-nullable typeMake the target nullable or provide a non-null value
CS8601Possible null reference assignmentSame techniques as CS8600
For each type, decide: should this member ever be null?
  • Yes → add
    ?
    to its declaration.
  • No → ensure it is initialized in every constructor path, or mark it
    required
    (C# 11+).
  • No, but it is set after the constructor (e.g., by a framework method, a builder, or a two-phase init pattern) → use
    = null!
    on the field declaration. This keeps the field's type non-nullable everywhere it is used, while telling the compiler "I guarantee this will be set before access." This is far preferable to adding
    !
    at every use site — a field accessed 50 times would need 50
    !
    operators instead of one
    = null!
    . If the initialization is done by a specific method, also consider
    [MemberNotNull(nameof(field))]
    on that method.
Focus annotation effort on public and protected APIs first — these define the contract that consumers depend on. Internal and private code can tolerate
!
more liberally since it does not affect external callers.
Public libraries: track breaking changes. If the project is a library consumed by others, create a
nullable-breaking-changes.md
file (or equivalent) and record every public API change that could affect consumers. While adding
?
to a reference type is metadata-only and not binary-breaking, it IS source-breaking for consumers who have NRTs enabled — they will get new warnings or errors. Key changes to document:
  • Return types changed from
    T
    to
    T?
    (consumers must now handle null)
  • Parameters changed from
    T?
    to
    T
    (consumers can no longer pass null)
  • Parameters changed from
    T
    to
    T?
    (existing null checks in callers become unnecessary — low impact but worth noting)
  • ?
    added to a value type parameter or return (changes
    T
    to
    Nullable<T>
    — binary-breaking)
  • New
    ArgumentNullException
    guards added where none existed
  • Any behavioral changes discovered and fixed during annotation (e.g., a method that silently accepted null now throws)
Present this file to the user for review. It may also serve as the basis for release notes.
Pay special attention to:
  • DTOs vs domain models: Apply different nullability strategies depending on the role of the class. DTOs and serialization models cross trust boundaries (JSON, forms, external APIs) — their properties should be nullable by default unless enforced by the serializer, because deserialized data can always be null regardless of the declared type. Use
    required
    (C# 11+),
    [JsonRequired]
    (.NET 7+), or runtime validation to enforce non-null constraints. Domain models represent internal invariants — prefer non-nullable properties with constructor enforcement, making invalid state unrepresentable. This distinction is where migrations most often go wrong: treating a DTO as a domain model leads to runtime
    NullReferenceException
    ; treating a domain model as a DTO leads to unnecessary null checks everywhere.
  • Event handlers and delegates: The pattern
    EventHandler? handler = SomeEvent; handler?.Invoke(...)
    is idiomatic.
  • Struct reference-type fields: Reference-type fields in structs are null when using
    default(T)
    . If
    default
    is valid usage for the struct, those fields must be nullable. If
    default
    is never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks.
  • Post-Dispose state: If a field or property is non-null for the entire useful lifetime of the object but may become null after
    Dispose
    , keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case.
  • Overrides and interface implementations: An override can return a stricter (non-nullable) type than the base method declares. If your implementation never returns null but the base/interface returns
    T?
    , you can declare the override as returning
    T
    . Parameter types must match the base exactly.
  • Widely-overridden virtual return types: For virtual/abstract methods that many classes override, consider whether existing overrides actually return null. If they commonly do (like
    Object.ToString()
    ), annotate the return as
    T?
    — callers need to know. If null overrides are vanishingly rare (like
    Exception.Message
    ), annotate as
    T
    . When in doubt for broadly overridden virtuals, prefer
    T?
    .
  • IEquatable<T>
    and
    IComparable<T>
    : Reference types should implement
    IEquatable<T?>
    and
    IComparable<T?>
    (with nullable
    T
    ), because callers commonly pass null to
    Equals
    and
    CompareTo
    .
  • Equals(object?)
    overrides
    : Add
    [NotNullWhen(true)]
    to the parameter of
    Equals(object? obj)
    overrides — if
    Equals
    returns
    true
    , the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test.
Build checkpoint: After annotating declarations, build and confirm zero CS8618/CS8625/CS8601 warnings remain before moving to nullable attributes.
首先根据设计目的确定每个成员的预期可空性——该参数是否接受空值?返回值是否可能为空?据此添加注解,然后处理产生的警告。请勿让警告主导你的注解,否则会导致过度使用
?
或随意添加
!
来抑制编译器警告。
何时询问用户:请勿猜测API契约。永远不要仅根据使用频率或命名约定推断可空性意图——如果代码或文档中未明确说明意图,请询问用户。特别是在以下情况前必须询问:(1) 将公共方法的返回类型改为可空,或为公共参数添加
?
——这会改变使用者依赖的API契约;(2) 当设计意图不明确时,决定属性应为可空还是必填;(3) 当无法从上下文确定空值是否为合法状态时,选择空值检查还是
!
。对于内部/私有成员,如果从使用方式可明确判断意图,可直接处理无需询问。
请勿让警告主导注解。先确定每个成员的预期可空性,再添加注解。为了消除警告而到处添加
?
会违背NRT的初衷——调用者必须添加不必要的空值检查。到处添加
!
会隐藏Bug。
⚠️ 返回类型必须反映语义可空性,而非仅仅满足编译器要求。常见错误是因为实现使用了
default!
或强制转换满足编译器要求,就移除返回类型的
?
。如果方法设计上可能返回空值,无论编译器是否警告,返回类型都必须是可空的。关键模式:
  • 命名为
    *OrDefault
    的方法(
    FirstOrDefault
    SingleOrDefault
    FindOrDefault
    )→ 返回类型必须是可空的(
    T?
    object?
    dynamic?
    ),因为“or default”对于引用类型意味着“or null”。
  • ExecuteScalar
    及类似数据库方法 → 返回类型必须是
    object?
    ,因为结果可能是
    DBNull.Value
    或无匹配行时返回空。
  • Find
    TryGet*
    (输出参数)和查找方法 → 当项可能不存在时,返回类型应为可空。
  • 任何文档或设计中说明在失败、未找到或空输入时返回空的方法 → 可空返回类型。
当实现使用
!
default!
隐藏空值时,编译器无法检测到返回类型缺少
?
。这会导致注解对使用者错误——他们信任非可空签名,跳过空值检查,最终在运行时出现
NullReferenceException
⚠️ 请勿移除现有的
ArgumentNullException
检查
。非可空注解仅为编译时提示——调用者仍可在运行时传递空值。
⚠️ 标记缺少运行时空值验证的公共API方法,但请勿添加检查。在注解时,检查每个
public
protected
方法:如果参数是非可空的(
T
而非
T?
),则应存在运行时空值检查(例如
ArgumentNullException.ThrowIfNull(param)
if (param is null) throw new ArgumentNullException(...)
)。如果没有,运行时传递的空值会在方法内部深处导致
NullReferenceException
,而非在入口点抛出清晰的
ArgumentNullException
。添加空值防护是运行时行为变更,不得包含在NRT迁移中。相反,询问用户是否要在对应位置插入
// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
注释。对于类库而言这一点尤其重要,因为其调用者可能未启用NRTs。
如果方法对空值输入有明确处理逻辑,则应接受可空参数。如果方法可优雅处理空值输入——返回空、返回默认值或返回失败结果而非抛出异常,则参数应为
T?
而非
T
。BCL遵循此约定:
Path.GetPathRoot(string?)
为空输入返回空,而
Path.GetFullPath(string)
会抛出异常。仅当空值会导致异常时,才使用非可空参数。如果方法实际可容忍空值却标记为非可空参数,会迫使调用者在调用前添加不必要的空值检查。
灰色区域:当参数未被验证、未被清理且未文档化空值处理时,可考虑:(1) 你的代码库中是否曾传递过空值?如果是 → 设为可空。(2) 调用者是否可能将空值用作“默认”或无操作占位符?如果是 → 设为可空。(3) 同一领域的类似方法是否接受空值?如果是 → 为了一致性设为可空。(4) 如果方法对空值无感知且碰巧能工作,但空值不符合API的语义目的 → 设为非可空。当参数的可空性存在疑问时,优先设为可空——更安全,且后续可收紧约束。
解引用警告解决后,处理注解警告:
警告代码含义典型修复方式
CS8618非可空字段/属性未在构造函数中初始化初始化成员,将其设为可空(
?
),或使用
required
(C# 11+)。对于在构造后但在构造函数外设置的字段(例如由框架生命周期方法、
Init()
调用或构建器模式设置),使用
= null!
声明意图,同时保持字段在所有使用点为非可空。如果辅助方法初始化字段,为该方法添加
[MemberNotNull(nameof(field))]
,让编译器知道调用该方法后字段不为空
CS8625无法将空字面量转换为非可空类型将目标类型设为可空,或提供非空值
CS8601可能为空的引用赋值与CS8600的修复技术相同
对于每个类型,需确定:该成员是否可能为空?
  • → 在声明中添加
    ?
  • → 确保在所有构造函数路径中都已初始化,或标记为
    required
    (C# 11+)。
  • 否,但在构造后才会被设置(例如由框架方法、构建器或两阶段初始化模式设置)→ 在字段声明中使用
    = null!
    。这样在所有使用点字段类型都为非可空,同时告知编译器“我保证在访问前会设置该字段”。这远比在每个使用点添加
    !
    更可取——一个被访问50次的字段只需一个
    = null!
    ,而非50次
    !
    运算符。如果初始化由特定方法完成,还可考虑在该方法上使用
    [MemberNotNull(nameof(field))]
优先为公共和受保护API添加注解——这些定义了使用者依赖的契约。内部和私有代码可更自由地使用
!
,因为不会影响外部调用者。
公共类库:跟踪破坏性变更。如果项目是供他人使用的类库,请创建
nullable-breaking-changes.md
文件(或等效文件),记录所有可能影响使用者的公共API变更。虽然为引用类型添加
?
仅为元数据变更,不会导致二进制兼容性问题,但对已启用NRTs的使用者而言是源代码破坏性变更——他们会收到新的警告或错误。需要记录的关键变更:
  • 返回类型从
    T
    改为
    T?
    (使用者现在必须处理空值)
  • 参数从
    T?
    改为
    T
    (使用者不能再传递空值)
  • 参数从
    T
    改为
    T?
    (调用者现有的空值检查变得不必要——影响较小,但仍需记录)
  • 值类型参数或返回值添加
    ?
    (将
    T
    改为
    Nullable<T>
    ——二进制破坏性变更)
  • 新增了之前不存在的
    ArgumentNullException
    防护
  • 注解过程中发现并修复的任何行为变更(例如之前静默接受空值的方法现在会抛出异常)
将该文件提交给用户评审。它也可作为发布说明的基础。
需特别注意以下场景:
  • DTO与领域模型:根据类的角色应用不同的可空性策略。DTO和序列化模型跨越信任边界(JSON、表单、外部API)——默认情况下其属性应为可空,除非序列化器强制要求非空,因为反序列化的数据无论声明类型如何都可能为空。使用
    required
    (C# 11+)、
    [JsonRequired]
    (.NET 7+)或运行时验证来强制非空约束。领域模型代表内部不变量——优先使用构造函数强制的非可空属性,让无效状态无法被表示。这是迁移中最容易出错的地方:将DTO当作领域模型会导致运行时
    NullReferenceException
    ;将领域模型当作DTO会导致到处都有不必要的空值检查。
  • 事件处理程序和委托
    EventHandler? handler = SomeEvent; handler?.Invoke(...)
    是惯用写法。
  • 结构体中的引用类型字段:使用
    default(T)
    创建结构体时,其中的引用类型字段为空。如果
    default
    是结构体的合法使用场景,这些字段必须设为可空。如果
    default
    从未被预期(仅通过特定API创建结构体),则保持非可空,避免给每个使用者添加不必要的空值检查负担。
  • 释放后的状态:如果字段或属性在对象的整个有效生命周期内都不为空,但在
    Dispose
    后可能为空,保持其为非可空。释放后使用对象是契约违反——不要为这种情况弱化注解。
  • 重写和接口实现:重写方法的返回类型可以比基方法更严格(非可空)。如果你的实现永远不会返回空,但基类/接口返回
    T?
    ,可以将重写方法的返回类型声明为
    T
    。参数类型必须与基类完全匹配。
  • 被广泛重写的虚返回类型:对于被许多类重写的虚/抽象方法,考虑现有重写方法是否实际返回空。如果通常会返回空(如
    Object.ToString()
    ),将返回类型注解为
    T?
    ——调用者需要知道这一点。如果返回空的重写极少(如
    Exception.Message
    ),注解为
    T
    。当对广泛重写的虚方法存在疑问时,优先设为
    T?
  • IEquatable<T>
    IComparable<T>
    :引用类型应实现
    IEquatable<T?>
    IComparable<T?>
    (使用可空
    T
    ),因为调用者通常会向
    Equals
    CompareTo
    传递空值。
  • Equals(object?)
    重写
    :为重写的
    Equals(object? obj)
    的参数添加
    [NotNullWhen(true)]
    ——如果
    Equals
    返回
    true
    ,则参数保证不为空。这可让调用者在相等性测试后跳过冗余的空值检查。
构建检查点:完成声明注解后,构建项目并确认CS8618/CS8625/CS8601警告已全部消除,再处理可空属性。

Step 5: Apply nullable attributes for advanced scenarios

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

When a simple
?
annotation cannot express the null contract, apply attributes from
System.Diagnostics.CodeAnalysis
— see references/nullable-attributes.md for the full attribute table (
[NotNullWhen]
,
[MaybeNullWhen]
,
[MemberNotNull]
,
[AllowNull]
,
[DisallowNull]
,
[DoesNotReturn]
, etc.) with usage guidance for each.
Build checkpoint: After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones.
当简单的
?
注解无法表达空契约时,应用
System.Diagnostics.CodeAnalysis
中的属性——详见references/nullable-attributes.md中的完整属性表(
[NotNullWhen]
[MaybeNullWhen]
[MemberNotNull]
[AllowNull]
[DisallowNull]
[DoesNotReturn]
等)及每个属性的使用指导。
构建检查点:应用可空属性后,构建项目验证属性是否解决了目标警告,且未引入新警告。

Step 6: Clean up suppressions

步骤6:清理抑制规则

Optional: Re-run
scripts/Get-NullableReadiness.ps1
to get current counts of
#nullable disable
directives,
!
operators, and
#pragma warning disable CS86xx
suppressions across the project.
  1. Search for any
    #nullable disable
    directives or
    !
    operators that were added as temporary workarounds.
  2. For each one, determine whether the suppression is still needed.
  3. Remove suppressions that are no longer necessary. For any that remain, add a comment explaining why.
  4. Search for
    #pragma warning disable CS86
    to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead.
Build checkpoint: After removing suppressions, build again — removing a
#nullable disable
or
!
may surface new warnings that need fixing.
可选操作:重新运行
scripts/Get-NullableReadiness.ps1
,获取项目中
#nullable disable
指令、
!
运算符和
#pragma warning disable CS86xx
抑制规则的当前数量。
  1. 查找所有作为临时 workaround 添加的
    #nullable disable
    指令或
    !
    运算符。
  2. 对每个抑制规则,确定是否仍需要它。
  3. 移除不再需要的抑制规则。对于保留的抑制规则,添加注释说明原因。
  4. 查找
    #pragma warning disable CS86
    以找到被抑制的可空警告,评估是否可修复底层问题而非抑制。
构建检查点:移除抑制规则后,再次构建项目——移除
#nullable disable
!
可能会出现需要修复的新警告。

Step 7: Validate

步骤7:验证

  1. Build the project and confirm zero nullable warnings.
  2. Add
    <WarningsAsErrors>nullable</WarningsAsErrors>
    to the project file (or
    Directory.Build.props
    for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent of
    dotnet build /warnaserror:nullable
    .
  3. Run existing tests to confirm no regressions.
  4. If the project is a library, inspect the public API surface to verify that nullable annotations match the intended contracts (parameters that accept null are
    T?
    , parameters that reject null are
    T
    ).
Verify before claiming the migration is complete. Zero warnings alone does not mean the migration is correct. Before reporting success: (1) spot-check public API signatures — confirm
?
annotations match actual design intent, not just compiler silence; (2) verify no
?.
operators were added that change runtime behavior (search for
?.
in the diff); (3) confirm no
ArgumentNullException
checks were removed; (4) check that
!
operators are rare and each has a justifying comment.
  1. 构建项目并确认无任何可空警告。
  2. 在项目文件(或整个仓库的
    Directory.Build.props
    )中添加
    <WarningsAsErrors>nullable</WarningsAsErrors>
    ,永久防止可空性回归。这相当于在项目文件中配置
    dotnet build /warnaserror:nullable
  3. 运行现有测试确认无回归。
  4. 如果是类库,检查公共API表面,验证可空注解是否与预期契约一致(接受空值的参数为
    T?
    ,拒绝空值的参数为
    T
    )。
在宣布迁移完成前进行验证。仅无警告并不意味着迁移正确。在报告成功前:(1) 抽查公共API签名——确认
?
注解匹配实际设计意图,而非仅仅让编译器静默;(2) 验证未添加改变运行时行为的
?.
运算符(在差异中搜索
?.
);(3) 确认未移除
ArgumentNullException
检查;(4) 检查
!
运算符是否很少见,且每个都有合理的注释。

Validation

验证清单

  • Project file(s) contain
    <Nullable>enable</Nullable>
    (or
    #nullable enable
    per-file for file-by-file strategy)
  • Build produces zero CS86xx warnings
  • <WarningsAsErrors>nullable</WarningsAsErrors>
    added to project file to prevent regressions
  • Tests pass with no regressions
  • No
    #nullable disable
    directives remain unless justified with a comment
  • Null-forgiving operators (
    !
    ) are rare, each with a justifying comment
  • Public API signatures accurately reflect null contracts
  • For public libraries: breaking changes documented in
    nullable-breaking-changes.md
    and reviewed by the user
  • 项目文件包含
    <Nullable>enable</Nullable>
    (或按文件迁移策略中每个文件添加
    #nullable enable
  • 构建无CS86xx警告
  • 项目文件中添加了
    <WarningsAsErrors>nullable</WarningsAsErrors>
    以防止回归
  • 测试通过,无回归
  • 无未注释的
    #nullable disable
    指令(除非有注释说明原因)
  • 空宽恕运算符(
    !
    )很少见,且每个都有合理的注释
  • 公共API签名准确反映空契约
  • 对于公共类库:
    nullable-breaking-changes.md
    中记录了破坏性变更并经用户评审

Code review checklist

代码评审清单

Nullable migration changes require broader review than a typical diff:
  1. Verify no behavior changes: confirm that
    ?
    and
    !
    are the only additions — no accidental
    ?.
    , no removed null checks, no new branches. The generated IL should be unchanged except for nullable metadata.
  2. Review explicit annotation changes: for every
    ?
    added to a parameter or return type, confirm it matches the intended design. Does the method really accept null? Can it really return null?
  3. Review unchanged APIs in scope: enabling
    <Nullable>enable</Nullable>
    implicitly makes every unannotated reference type in that scope non-nullable. Scan unchanged public members for parameters that actually do accept null but were not annotated.
可空性迁移变更比普通差异需要更全面的评审:
  1. 验证无行为变更:确认仅添加了
    ?
    !
    ——无意外添加的
    ?.
    ,无移除的空值检查,无新分支。除了可空元数据,生成的IL应无变化。
  2. 评审显式注解变更:对于每个添加到参数或返回值的
    ?
    ,确认其与预期设计一致。方法真的接受空值吗?真的可能返回空值吗?
  3. 评审范围内未变更的API:启用
    <Nullable>enable</Nullable>
    会隐式将范围内所有未注解的引用类型设为非可空。扫描未变更的公共成员,确认是否存在实际接受空值但未被注解的参数。

Breaking Changes from NRT Annotations (Libraries)

可空注解导致的破坏性变更(类库)

For libraries, see references/breaking-changes.md — NRT annotations are part of the public API contract and incorrect annotations are source-breaking changes for consumers.
对于类库,详见references/breaking-changes.md——NRT注解是公共API契约的一部分,错误的注解会对使用者造成源代码破坏性变更。

Common Pitfalls

常见陷阱

PitfallSolution
Sprinkling
!
everywhere to silence warnings
The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead
Marking everything
T?
to eliminate warnings quickly
Over-annotating with
?
defeats the purpose — callers must add unnecessary null checks. Only use
?
when null is a valid value
Constructor does not initialize all non-nullable membersInitialize fields and properties in every constructor, use
required
(C# 11+), or make the member nullable
Serialization bypasses constructors — non-nullable ≠ runtime safetySerializers create objects without calling constructors, so non-nullable DTO properties can still be null at runtime. See "DTOs vs domain models" in Step 4 for detailed guidance
Generated code produces warningsGenerated files are excluded from nullable analysis automatically if they contain
<auto-generated>
comments. If warnings persist, add
#nullable disable
at the top of the generated file or configure
.editorconfig
with
generated_code = true
Multi-target projects and older TFMsNRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like
[NotNullWhen]
may not exist. Use a polyfill package such as
Nullable
from NuGet, or define the attributes internally
Warnings reappear after upgrading a dependencyThe dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5
Accidentally changing behavior while annotatingAdding
?
to a type or
!
to an expression is metadata-only and does not change generated IL. But replacing
obj.Method()
with
obj?.Method()
(null-conditional) changes runtime behavior — the call is silently skipped instead of throwing. Only use
?.
when you intentionally want to tolerate null, not as a quick fix for a warning
Adding
?
to a value type (enum, struct)
For reference types,
?
is a metadata annotation with no runtime effect. For value types like
int
or an enum,
?
changes the type to
Nullable<T>
, altering the method signature, binary layout, and boxing behavior. Double-check that you are only adding
?
to reference types unless you truly intend to make a value type nullable
Removing existing null argument validationNon-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing
ArgumentNullException
checks. See Step 4 for details
var
infers nullability from the assigned expression
When using
var
, the inferred type includes nullability from the assigned expression, which can be surprising compared to explicitly declaring
T
vs
T?
. Flow analysis determines the actual null-state from that point forward, but the inferred declaration type may carry nullability you did not expect. If precise nullability at the declaration matters, use an explicit type instead of
var
Consuming unannotated (nullable-oblivious) librariesWhen a dependency has not opted into nullable annotations, the compiler treats all its types as "oblivious" — you get no warnings for dereferencing or assigning null. This gives a false sense of safety. Treat return values from oblivious APIs as potentially null, especially for methods that could conceptually return null (dictionary lookups,
FirstOrDefault
-style calls). Upgrade dependencies or wrap calls when possible
陷阱解决方案
到处添加
!
来抑制警告
空宽恕运算符会隐藏Bug。请添加空值检查或将类型设为可空
为了快速消除警告而将所有类型标记为
T?
过度使用
?
会违背NRT初衷——调用者必须添加不必要的空值检查。仅当空值是合法值时才使用
?
构造函数未初始化所有非可空成员在所有构造函数中初始化字段和属性,使用
required
(C# 11+),或将成员设为可空
序列化绕过构造函数——非可空≠运行时安全序列化器无需调用构造函数即可创建对象,因此非可空DTO属性在运行时仍可能为空。详见步骤4中的“DTO与领域模型”部分的详细指导
生成的代码产生警告如果生成的文件包含
<auto-generated>
注释,会自动被排除在可空性分析之外。如果仍有警告,在生成文件顶部添加
#nullable disable
,或在
.editorconfig
中配置
generated_code = true
多目标项目与旧TFMNRT注解可在旧TFM(例如.NET Standard 2.0)上配合C# 8.0+编译,但
[NotNullWhen]
等可空属性可能不存在。使用NuGet上的
Nullable
填充包,或在内部定义这些属性
升级依赖项后警告重现依赖项添加了可空注解。这是预期且有益的——按照步骤3–5修复新警告
注解时意外改变行为为类型添加
?
或为表达式添加
!
仅为元数据变更,不会改变生成的IL。但将
obj.Method()
替换为
obj?.Method()
(空条件运算符)会改变运行时行为——调用会被静默跳过而非抛出异常。仅当有意容忍空值时才使用
?.
,而非作为警告的快速修复方式
为值类型(枚举、结构体)添加
?
对于引用类型,
?
是元数据注解,无运行时影响。对于
int
或枚举等值类型,
?
会将类型改为
Nullable<T>
,改变方法签名、二进制布局和装箱行为。请仔细检查,仅为引用类型添加
?
,除非确实要将值类型设为可空
移除现有的空值参数验证非可空注解仅为编译时检查——调用者仍可在运行时传递空值。请保留现有的
ArgumentNullException
检查。详见步骤4的详细说明
var
从赋值表达式推断可空性
使用
var
时,推断的类型包含赋值表达式的可空性,这与显式声明
T
vs
T?
可能不同。从该点开始,流分析会确定实际的空值状态,但推断的声明类型可能带有意外的可空性。如果声明处的精确可空性很重要,请使用显式类型而非
var
使用未注解(可空感知缺失)的类库当依赖项未启用可空注解时,编译器会将其所有类型视为“感知缺失”——解引用或赋值空值时不会收到警告。这会带来虚假的安全感。将感知缺失API的返回值视为可能为空,尤其是对于概念上可能返回空的方法(字典查找、
FirstOrDefault
类调用)。尽可能升级依赖项或封装调用

Entity Framework Core Considerations

Entity Framework Core 注意事项

If the project uses EF Core, see references/ef-core.md — enabling NRTs can change database schema inference and migration output.
如果项目使用EF Core,详见references/ef-core.md——启用NRTs会改变数据库架构推断和迁移输出。

ASP.NET Core Considerations

ASP.NET Core 注意事项

If the project uses ASP.NET Core, see references/aspnet-core.md — enabling NRTs can change MVC model validation and JSON serialization behavior.
如果项目使用ASP.NET Core,详见references/aspnet-core.md——启用NRTs会改变MVC模型验证和JSON序列化行为。

More Info

更多信息