migrate-nullable-references
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNullable 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 , zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information.
<Nullable>enable</Nullable>在现有代码库中启用C#可空引用类型(NRTs),并系统性地解决所有警告。最终成果是项目(或解决方案)中配置了,零可空警告,且公共API表面带有准确的注解——为编译器和使用者提供可靠的可空性信息。
<Nullable>enable</Nullable>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 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)
<Nullable>enable</Nullable> - 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
- 项目已配置且无任何警告——迁移已完成,除非用户希望重新检查抑制规则以移除不必要的项(见步骤6)
<Nullable>enable</Nullable> - 用户仅想抑制警告而不修复问题(不推荐此做法)
- 代码目标框架为C# 7.3或更早版本,该版本不支持可空引用类型
Inputs
输入项
| Input | Required | Description |
|---|---|---|
| Project or solution path | Yes | The |
| Migration scope | No | |
| Build command | No | How to build the project (e.g., |
| Test command | No | How to run tests (e.g., |
| 输入项 | 是否必填 | 描述 |
|---|---|---|
| 项目或解决方案路径 | 是 | 要迁移的 |
| 迁移范围 | 否 | |
| 构建命令 | 否 | 项目构建方式(例如 |
| 测试命令 | 否 | 测试运行方式(例如 |
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 (?, etc.),[NotNullWhen]operators (metadata-only), and!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#nullablecomment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit.// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
Commit strategy: Commit at each logical boundary — after enabling(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.<Nullable>
🛑 零运行时行为变更。NRT迁移严格是元数据和注解操作,生成的IL代码不得变更——不得添加新分支、新空值检查、修改控制流、增删方法调用。仅允许的变更包括:可空注解()、可空属性(?等)、[NotNullWhen]运算符(仅元数据层面)和!指令。如果在迁移过程中发现缺失的运行时空值防护或潜在Bug,请勿在当前迁移中修复。建议在对应位置插入#nullable注释,让用户在单独的变更中处理。绝对不要将行为修复与注解提交混在一起。// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
提交策略:在每个逻辑阶段提交代码——启用后(步骤2)、修复解引用警告后(步骤3)、完成声明注解后(步骤4)、应用可空属性后(步骤5)、清理抑制规则后(步骤6)。这样每个提交都聚焦明确,便于评审,且避免后续步骤出现设计问题导致工作丢失。对于按文件迁移的场景,逐个文件或相关文件批次提交。<Nullable>
Step 1: Evaluate readiness
步骤1:评估迁移就绪度
Optional: Runto automate the checks below. The script reportsscripts/Get-NullableReadiness.ps1 -Path <project-or-solution>,<Nullable>,<LangVersion>,<TargetFramework>settings and counts<WarningsAsErrors>directives,#nullable disableoperators, and!suppressions. Use#pragma warning disable CS86xxfor machine-readable output.-Json
- Identify how the project is built and tested. Look for build scripts (e.g., ,
build.cmd,build.sh), aMakefilefile, or individual.slnfiles. If the repo uses a custom build script, use it instead of.csprojthroughout this workflow.dotnet build - Run to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (
dotnet --version/.NET Core 3.0or later)..NET Standard 2.1 - Open the (or
.csprojif properties are set at the repo level) and check theDirectory.Build.propsand<LangVersion>. If the project multi-targets, note all TFMs.<TargetFramework>
Stop if the language version or target framework is insufficient. Ifis below 8.0, or the project targets a framework that defaults to C# 7.x (e.g.,<LangVersion>without an explicit.NET Framework 4.x), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set<LangVersion>or higher, or retarget to<LangVersion>8.0</LangVersion>/.NET Core 3.0+), 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..NET 5+
- Check whether is already set. If it is set to
<Nullable>, skip to Step 5 to audit remaining warnings.enable - 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.
!
- Library: Focus on public API contracts first. Every
可选操作:运行自动完成以下检查。该脚本会报告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 - 运行确认已安装SDK。可空引用类型(NRTs)需要C# 8.0+(对应.NET Core 3.0 / .NET Standard 2.1或更高版本)。
dotnet --version - 打开文件(若在仓库级别配置属性则打开
.csproj),检查Directory.Build.props和<LangVersion>配置。如果项目多目标框架,请记录所有TFM。<TargetFramework>
若语言版本或目标框架不满足要求,请终止迁移。如果低于8.0,或项目目标框架默认使用C# 7.x(例如.NET Framework 4.x且未显式设置<LangVersion>),则无法直接启用NRTs。需明确告知用户:说明需要修改的配置(设置<LangVersion>或更高版本,或重新定位到.NET Core 3.0+ / .NET 5+),并询问用户是否要进行该更新后继续迁移,还是终止迁移。请勿静默继续或默认用户接受更新。<LangVersion>8.0</LangVersion>
- 检查是否已设置。如果已设置为
<Nullable>,直接跳至步骤5审核剩余警告。enable - 确定项目类型——这将决定整个迁移过程中的注解优先级:
- 类库:优先聚焦公共API契约。公共参数或返回值上的每个都是使用者依赖的契约变更,需精准且保守处理。
? - 应用程序(Web、控制台、桌面):优先聚焦边界处的空值安全——反序列化、数据库查询、用户输入、外部API响应。内部代码的注解可以更灵活。
- 测试项目:注解精度优先级较低。在测试设置和断言中可更自由地使用,因为此处永远不会出现空值。重点确保测试代码可干净编译。
!
- 类库:优先聚焦公共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.
- Add to the
<Nullable>enable</Nullable>in the<PropertyGroup>..csproj - Build and address all warnings at once.
适用于源文件少于约50个,或团队希望一次性完成迁移的项目。
- 在的
.csproj中添加<PropertyGroup>。<Nullable>enable</Nullable> - 构建项目并一次性处理所有警告。
Strategy B — Warnings-first, then annotations (large or active projects)
策略B——先处理警告,再添加注解(大型或活跃项目)
Best when the codebase is large or under active development by multiple contributors.
- Add to the
<Nullable>warnings</Nullable>. This enables warnings without changing type semantics..csproj - Build, fix all warnings from Step 3 onward.
- Change to to activate annotations — this triggers a second wave of warnings.
<Nullable>enable</Nullable> - Resolve the annotation-phase warnings from Step 4 onward.
适用于代码库规模大,或有多个开发者活跃开发的项目。
- 在中添加
.csproj。这将启用警告但不改变类型语义。<Nullable>warnings</Nullable> - 构建项目,从步骤3开始修复所有警告。
- 将配置改为以激活注解——这会触发第二轮警告。
<Nullable>enable</Nullable> - 从步骤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.
- Set (or omit it) at the project level.
<Nullable>disable</Nullable> - Add at the top of each file as it is migrated.
#nullable enable - Prioritize files in dependency order: shared utilities and models first, then higher-level consumers.
Build checkpoint: After enabling(or adding<Nullable>to the first batch of files), do a clean build (e.g.,#nullable enable, or deletedotnet build --no-incremental/binfirst). 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.obj
适用于大型遗留代码库,全局启用会产生无法管理的大量警告的场景。
- 在项目级别设置(或省略该配置)。
<Nullable>disable</Nullable> - 在每个要迁移的文件顶部添加。
#nullable enable - 按依赖顺序优先处理文件:先处理共享工具类和模型,再处理高层使用者。
构建检查点:启用后(或为首批文件添加<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:
| Warning | Meaning | Typical fix |
|---|---|---|
| CS8602 | Dereference of a possibly null reference | Prefer annotation-only fixes: make the upstream type nullable ( |
| CS8600 | Converting possible null to non-nullable type | Add |
| CS8603 | Possible null reference return | Change the return type to nullable ( |
| CS8604 | Possible null reference argument | Mark the parameter as nullable if null is valid, or use |
❌ Do not useas a quick fix for dereference warnings. Replacing?.withobj.Method()silently changes runtime behavior — the call is skipped instead of throwing. Only useobj?.Method()when you intentionally want to tolerate null.?.
❌ Do not sprinkleto silence warnings. Each!is a claim that the value is never null. If that claim is wrong, you have hidden a!. Add a null check or make the type nullable instead.NullReferenceException
❌ Never useto keep a return type non-nullable. If a method returnsreturn null!, the return type must benull. WritingT?hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and getreturn null!at runtime. This applies toNullReferenceException,null!, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use ofdefault!on a return value is when the value is provably never null but the compiler cannot see why.!
⚠️ Do not addto value types unless you intend to change the runtime type. For reference types,?is metadata-only. For value types (?, enums, structs),intchanges the type to?, altering the method signature, binary layout, and boxing behavior.Nullable<T>
Decision flowchart for each warning:
- 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.
- Yes → add
- 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").
- Yes, with a code path the compiler can't see → add
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 , which narrows null state after the guard call. After
[NotNull], the compiler already narrowsGuard.Against.NullOrEmpty(value, nameof(value))tostring?— do not add a redundantstringat the subsequent assignment. Check whether the guard method uses!before assuming the compiler needs help.[NotNull] - When a method legitimately returns null, change the return type to — do not hide nulls behind a non-nullable signature.
T? - acts as a null-state hint to the compiler just like an
Debug.Assert(x != null)check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequentifoperators in that scope. Note:!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 orDebug.Assert.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., implies
if (IsValid(x))), prefer addingx != nullto the helper's parameter over using[NotNullWhen(true)]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 method, or a builder pattern), prefer
Init()on the field declaration over adding= null!at every use site. A field accessed 50 times should have one!, not fifty= null!assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair withfield!on the initializing method when possible.[MemberNotNull] - For generic methods returning on an unconstrained type parameter (e.g.,
default), useFirstOrDefault<T>rather than[return: MaybeNull] T. WritingT?on an unconstrained generic changes value-type signatures toT?, altering the method signature and binary layout.Nullable<T>preserves the original signature while communicating that the return may be null for reference types.[return: MaybeNull] - LINQ's does not narrow
Where(x => x != null)toT?— the compiler cannot track nullability through lambdas passed to generic methods. UseTto filter nulls with correct type narrowing.source.OfType<T>()
Build checkpoint: After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings.
优先级:按依赖顺序处理文件——先处理其他代码依赖的核心模型和共享工具类,再处理高层使用者。在每个文件中,先修复公共和受保护成员(这些定义了契约),再处理内部和私有成员。此顺序可最大限度减少级联警告:修复核心类型的注解通常会自动解决其使用者的警告。
构建项目并处理解引用警告。最常见的警告如下:
| 警告代码 | 含义 | 典型修复方式 |
|---|---|---|
| CS8602 | 解引用可能为空的引用 | 优先选择仅注解的修复方式:如果空值是合法的,将上游类型设为可空( |
| CS8600 | 将可能为空的引用转换为非可空类型 | 如果空值合法,为目标类型添加 |
| CS8603 | 可能返回空引用 | 如果方法确实可能返回空,将返回类型改为可空( |
| CS8604 | 可能为空的引用参数 | 如果空值合法,将参数标记为可空;如果可验证参数永远不为空,使用 |
❌ 请勿使用快速修复解引用警告。将?.替换为obj.Method()会静默改变运行时行为——调用会被跳过而非抛出异常。仅当你有意要容忍空值时才使用obj?.Method()。?.
❌ 请勿随意使用来抑制警告。每个!都是声明该值永远不为空。如果该声明错误,你就隐藏了一个!。请添加空值检查或将类型设为可空。NullReferenceException
❌ 永远不要使用来保持返回类型为非可空。如果方法返回return null!,返回类型必须是null。编写T?会将空值隐藏在非可空签名后——调用者会信任该签名,跳过空值检查,最终在运行时出现return null!。这一规则适用于NullReferenceException、null!以及任何让编译器接受非可空位置为空值的强制转换。仅当值绝对不可能为空但编译器无法识别时,才可以在返回值上使用default!。!
⚠️ 除非有意改变运行时类型,否则请勿为值类型添加。对于引用类型,?仅为元数据。对于值类型(?、枚举、结构体),int会将类型改为?,改变方法签名、二进制布局和装箱行为。Nullable<T>
每个警告的决策流程图:
- 此处空值是设计允许的合法值吗?
- 是 → 在声明中添加(设为可空)。
? - 否 → 进入步骤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运算符。注意:!仅告知编译器,在Release构建中会被移除——无法在运行时防护空值。对于公共API边界,优先使用显式空值检查或Debug.Assert。ArgumentNullException - 如果布尔返回值的辅助方法结果可保证可空参数不为空(例如意味着
if (IsValid(x))),优先为辅助方法的参数添加x != null,而非在每个调用点使用[NotNullWhen(true)]。这是仅元数据的变更(无行为变更),可消除下游的!运算符,同时为编译器提供真实的流信息。! - 对于在构造后才会被设置的字段(例如由框架、方法或构建器模式设置),优先在字段声明中使用
Init(),而非在每个使用点添加= null!。一个被访问50次的字段应该只使用一次!,而非50次= null!断言。这样既可以在类型系统中保持字段为非可空,又能说明其延迟初始化的情况。可能的话,配合在初始化方法上使用field!。[MemberNotNull] - 对于无约束类型参数返回的泛型方法(例如
default),使用FirstOrDefault<T>而非[return: MaybeNull] T。在无约束泛型上使用T?会将值类型签名改为T?,改变方法签名和二进制布局。Nullable<T>可保留原签名,同时告知引用类型的返回值可能为空。[return: MaybeNull] - LINQ的不会将
Where(x => x != null)缩小为T?——编译器无法跟踪传递给泛型方法的Lambda表达式中的空值性。使用T过滤空值可实现正确的类型缩小。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 addingto 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. Addingeverywhere 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 removingfrom a return type because the implementation uses?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:default!
- Methods named
(*OrDefault,FirstOrDefault,SingleOrDefault) → return type must be nullable (FindOrDefault,T?,object?) because "or default" means "or null" for reference types.dynamic? and similar database methods → return type must beExecuteScalarbecause the result can beobject?or null when no rows match.DBNull.Value ,Find(out parameter), and lookup methods → return type should be nullable when the item may not exist.TryGet*- Any method documented or designed to return null on failure, not-found, or empty-input → nullable return type.
The compiler cannot catch a missingon a return type when the implementation hides null behind?or!. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading todefault!at runtime.NullReferenceException
⚠️ Do not remove existingchecks. 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, orArgumentNullExceptioncan still pass null.!
⚠️ Flag public API methods missing runtime null validation — but do not add checks. While annotating, check eachandpublicmethod: if a parameter is non-nullable (protected, notT), there should be a runtime null check (e.g.,T?orArgumentNullException.ThrowIfNull(param)). Without one, a null passed at runtime causes aif (param is null) throw new ArgumentNullException(...)deep in the method body instead of a clearNullReferenceExceptionat 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 aArgumentNullExceptioncomment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled.// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
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, notT?. The BCL follows this convention:Treturns null for null input, whilePath.GetPathRoot(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.Path.GetFullPath(string)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:
| Warning | Meaning | Typical fix |
|---|---|---|
| CS8618 | Non-nullable field/property not initialized in constructor | Initialize the member, make it nullable ( |
| CS8625 | Cannot convert null literal to non-nullable type | Make the target nullable or provide a non-null value |
| CS8601 | Possible null reference assignment | Same 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 (C# 11+).
required - No, but it is set after the constructor (e.g., by a framework method, a builder, or a two-phase init pattern) → use 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
= null!at every use site — a field accessed 50 times would need 50!operators instead of one!. If the initialization is done by a specific method, also consider= null!on that method.[MemberNotNull(nameof(field))]
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 afile (or equivalent) and record every public API change that could affect consumers. While addingnullable-breaking-changes.mdto 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
toT(consumers must now handle null)T?- Parameters changed from
toT?(consumers can no longer pass null)T- Parameters changed from
toT(existing null checks in callers become unnecessary — low impact but worth noting)T? added to a value type parameter or return (changes?toT— binary-breaking)Nullable<T>- New
guards added where none existedArgumentNullException- 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 (C# 11+),
required(.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[JsonRequired]; treating a domain model as a DTO leads to unnecessary null checks everywhere.NullReferenceException - Event handlers and delegates: The pattern is idiomatic.
EventHandler? handler = SomeEvent; handler?.Invoke(...) - Struct reference-type fields: Reference-type fields in structs are null when using . If
default(T)is valid usage for the struct, those fields must be nullable. Ifdefaultis never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks.default - Post-Dispose state: If a field or property is non-null for the entire useful lifetime of the object but may become null after , keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case.
Dispose - 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 , you can declare the override as returning
T?. Parameter types must match the base exactly.T - 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 ), annotate the return as
Object.ToString()— callers need to know. If null overrides are vanishingly rare (likeT?), annotate asException.Message. When in doubt for broadly overridden virtuals, preferT.T? - and
IEquatable<T>: Reference types should implementIComparable<T>andIEquatable<T?>(with nullableIComparable<T?>), because callers commonly pass null toTandEquals.CompareTo - overrides: Add
Equals(object?)to the parameter of[NotNullWhen(true)]overrides — ifEquals(object? obj)returnsEquals, the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test.true
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?),因为“or default”对于引用类型意味着“or null”。dynamic? 及类似数据库方法 → 返回类型必须是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。添加空值防护是运行时行为变更,不得包含在NRT迁移中。相反,询问用户是否要在对应位置插入ArgumentNullException注释。对于类库而言这一点尤其重要,因为其调用者可能未启用NRTs。// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)
如果方法对空值输入有明确处理逻辑,则应接受可空参数。如果方法可优雅处理空值输入——返回空、返回默认值或返回失败结果而非抛出异常,则参数应为而非T?。BCL遵循此约定:T为空输入返回空,而Path.GetPathRoot(string?)会抛出异常。仅当空值会导致异常时,才使用非可空参数。如果方法实际可容忍空值却标记为非可空参数,会迫使调用者在调用前添加不必要的空值检查。Path.GetFullPath(string)灰色区域:当参数未被验证、未被清理且未文档化空值处理时,可考虑:(1) 你的代码库中是否曾传递过空值?如果是 → 设为可空。(2) 调用者是否可能将空值用作“默认”或无操作占位符?如果是 → 设为可空。(3) 同一领域的类似方法是否接受空值?如果是 → 为了一致性设为可空。(4) 如果方法对空值无感知且碰巧能工作,但空值不符合API的语义目的 → 设为非可空。当参数的可空性存在疑问时,优先设为可空——更安全,且后续可收紧约束。
解引用警告解决后,处理注解警告:
| 警告代码 | 含义 | 典型修复方式 |
|---|---|---|
| CS8618 | 非可空字段/属性未在构造函数中初始化 | 初始化成员,将其设为可空( |
| CS8625 | 无法将空字面量转换为非可空类型 | 将目标类型设为可空,或提供非空值 |
| CS8601 | 可能为空的引用赋值 | 与CS8600的修复技术相同 |
对于每个类型,需确定:该成员是否可能为空?
- 是 → 在声明中添加。
? - 否 → 确保在所有构造函数路径中都已初始化,或标记为(C# 11+)。
required - 否,但在构造后才会被设置(例如由框架方法、构建器或两阶段初始化模式设置)→ 在字段声明中使用。这样在所有使用点字段类型都为非可空,同时告知编译器“我保证在访问前会设置该字段”。这远比在每个使用点添加
= null!更可取——一个被访问50次的字段只需一个!,而非50次= null!运算符。如果初始化由特定方法完成,还可考虑在该方法上使用!。[MemberNotNull(nameof(field))]
优先为公共和受保护API添加注解——这些定义了使用者依赖的契约。内部和私有代码可更自由地使用,因为不会影响外部调用者。
!公共类库:跟踪破坏性变更。如果项目是供他人使用的类库,请创建文件(或等效文件),记录所有可能影响使用者的公共API变更。虽然为引用类型添加nullable-breaking-changes.md仅为元数据变更,不会导致二进制兼容性问题,但对已启用NRTs的使用者而言是源代码破坏性变更——他们会收到新的警告或错误。需要记录的关键变更:?
- 返回类型从
改为T(使用者现在必须处理空值)T?- 参数从
改为T?(使用者不能再传递空值)T- 参数从
改为T(调用者现有的空值检查变得不必要——影响较小,但仍需记录)T?- 值类型参数或返回值添加
(将?改为T——二进制破坏性变更)Nullable<T>- 新增了之前不存在的
防护ArgumentNullException- 注解过程中发现并修复的任何行为变更(例如之前静默接受空值的方法现在会抛出异常)
将该文件提交给用户评审。它也可作为发布说明的基础。
需特别注意以下场景:
- DTO与领域模型:根据类的角色应用不同的可空性策略。DTO和序列化模型跨越信任边界(JSON、表单、外部API)——默认情况下其属性应为可空,除非序列化器强制要求非空,因为反序列化的数据无论声明类型如何都可能为空。使用(C# 11+)、
required(.NET 7+)或运行时验证来强制非空约束。领域模型代表内部不变量——优先使用构造函数强制的非可空属性,让无效状态无法被表示。这是迁移中最容易出错的地方:将DTO当作领域模型会导致运行时[JsonRequired];将领域模型当作DTO会导致到处都有不必要的空值检查。NullReferenceException - 事件处理程序和委托:是惯用写法。
EventHandler? handler = SomeEvent; handler?.Invoke(...) - 结构体中的引用类型字段:使用创建结构体时,其中的引用类型字段为空。如果
default(T)是结构体的合法使用场景,这些字段必须设为可空。如果default从未被预期(仅通过特定API创建结构体),则保持非可空,避免给每个使用者添加不必要的空值检查负担。default - 释放后的状态:如果字段或属性在对象的整个有效生命周期内都不为空,但在后可能为空,保持其为非可空。释放后使用对象是契约违反——不要为这种情况弱化注解。
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 — see references/nullable-attributes.md for the full attribute table (, , , , , , etc.) with usage guidance for each.
?System.Diagnostics.CodeAnalysis[NotNullWhen][MaybeNullWhen][MemberNotNull][AllowNull][DisallowNull][DoesNotReturn]Build checkpoint: After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones.
当简单的注解无法表达空契约时,应用中的属性——详见references/nullable-attributes.md中的完整属性表(、、、、、等)及每个属性的使用指导。
?System.Diagnostics.CodeAnalysis[NotNullWhen][MaybeNullWhen][MemberNotNull][AllowNull][DisallowNull][DoesNotReturn]构建检查点:应用可空属性后,构建项目验证属性是否解决了目标警告,且未引入新警告。
Step 6: Clean up suppressions
步骤6:清理抑制规则
Optional: Re-runto get current counts ofscripts/Get-NullableReadiness.ps1directives,#nullable disableoperators, and!suppressions across the project.#pragma warning disable CS86xx
- Search for any directives or
#nullable disableoperators that were added as temporary workarounds.! - For each one, determine whether the suppression is still needed.
- Remove suppressions that are no longer necessary. For any that remain, add a comment explaining why.
- Search for to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead.
#pragma warning disable CS86
Build checkpoint: After removing suppressions, build again — removing aor#nullable disablemay surface new warnings that need fixing.!
可选操作:重新运行,获取项目中scripts/Get-NullableReadiness.ps1指令、#nullable disable运算符和!抑制规则的当前数量。#pragma warning disable CS86xx
- 查找所有作为临时 workaround 添加的指令或
#nullable disable运算符。! - 对每个抑制规则,确定是否仍需要它。
- 移除不再需要的抑制规则。对于保留的抑制规则,添加注释说明原因。
- 查找以找到被抑制的可空警告,评估是否可修复底层问题而非抑制。
#pragma warning disable CS86
构建检查点:移除抑制规则后,再次构建项目——移除或#nullable disable可能会出现需要修复的新警告。!
Step 7: Validate
步骤7:验证
- Build the project and confirm zero nullable warnings.
- Add to the project file (or
<WarningsAsErrors>nullable</WarningsAsErrors>for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent ofDirectory.Build.props.dotnet build /warnaserror:nullable - Run existing tests to confirm no regressions.
- 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 , parameters that reject null are
T?).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 — confirmannotations 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?.checks were removed; (4) check thatArgumentNullExceptionoperators are rare and each has a justifying comment.!
- 构建项目并确认无任何可空警告。
- 在项目文件(或整个仓库的)中添加
Directory.Build.props,永久防止可空性回归。这相当于在项目文件中配置<WarningsAsErrors>nullable</WarningsAsErrors>。dotnet build /warnaserror:nullable - 运行现有测试确认无回归。
- 如果是类库,检查公共API表面,验证可空注解是否与预期契约一致(接受空值的参数为,拒绝空值的参数为
T?)。T
在宣布迁移完成前进行验证。仅无警告并不意味着迁移正确。在报告成功前:(1) 抽查公共API签名——确认注解匹配实际设计意图,而非仅仅让编译器静默;(2) 验证未添加改变运行时行为的?运算符(在差异中搜索?.);(3) 确认未移除?.检查;(4) 检查ArgumentNullException运算符是否很少见,且每个都有合理的注释。!
Validation
验证清单
- Project file(s) contain (or
<Nullable>enable</Nullable>per-file for file-by-file strategy)#nullable enable - Build produces zero CS86xx warnings
- added to project file to prevent regressions
<WarningsAsErrors>nullable</WarningsAsErrors> - Tests pass with no regressions
- No directives remain unless justified with a comment
#nullable disable - Null-forgiving operators () are rare, each with a justifying comment
! - Public API signatures accurately reflect null contracts
- For public libraries: breaking changes documented in and reviewed by the user
nullable-breaking-changes.md
- 项目文件包含(或按文件迁移策略中每个文件添加
<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:
- 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.?. - 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?
? - Review unchanged APIs in scope: enabling 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.
<Nullable>enable</Nullable>
可空性迁移变更比普通差异需要更全面的评审:
- 验证无行为变更:确认仅添加了和
?——无意外添加的!,无移除的空值检查,无新分支。除了可空元数据,生成的IL应无变化。?. - 评审显式注解变更:对于每个添加到参数或返回值的,确认其与预期设计一致。方法真的接受空值吗?真的可能返回空值吗?
? - 评审范围内未变更的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
常见陷阱
| Pitfall | Solution |
|---|---|
Sprinkling | The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead |
Marking everything | Over-annotating with |
| Constructor does not initialize all non-nullable members | Initialize fields and properties in every constructor, use |
| Serialization bypasses constructors — non-nullable ≠ runtime safety | Serializers 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 warnings | Generated files are excluded from nullable analysis automatically if they contain |
| Multi-target projects and older TFMs | NRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like |
| Warnings reappear after upgrading a dependency | The dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5 |
| Accidentally changing behavior while annotating | Adding |
Adding | For reference types, |
| Removing existing null argument validation | Non-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing |
| When using |
| Consuming unannotated (nullable-oblivious) libraries | When 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, |
| 陷阱 | 解决方案 |
|---|---|
到处添加 | 空宽恕运算符会隐藏Bug。请添加空值检查或将类型设为可空 |
为了快速消除警告而将所有类型标记为 | 过度使用 |
| 构造函数未初始化所有非可空成员 | 在所有构造函数中初始化字段和属性,使用 |
| 序列化绕过构造函数——非可空≠运行时安全 | 序列化器无需调用构造函数即可创建对象,因此非可空DTO属性在运行时仍可能为空。详见步骤4中的“DTO与领域模型”部分的详细指导 |
| 生成的代码产生警告 | 如果生成的文件包含 |
| 多目标项目与旧TFM | NRT注解可在旧TFM(例如.NET Standard 2.0)上配合C# 8.0+编译,但 |
| 升级依赖项后警告重现 | 依赖项添加了可空注解。这是预期且有益的——按照步骤3–5修复新警告 |
| 注解时意外改变行为 | 为类型添加 |
为值类型(枚举、结构体)添加 | 对于引用类型, |
| 移除现有的空值参数验证 | 非可空注解仅为编译时检查——调用者仍可在运行时传递空值。请保留现有的 |
| 使用 |
| 使用未注解(可空感知缺失)的类库 | 当依赖项未启用可空注解时,编译器会将其所有类型视为“感知缺失”——解引用或赋值空值时不会收到警告。这会带来虚假的安全感。将感知缺失API的返回值视为可能为空,尤其是对于概念上可能返回空的方法(字典查找、 |
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
更多信息
- Nullable reference types — overview of the feature, nullable contexts, and compiler analysis
- Nullable reference types (C# reference) — language reference for nullable annotation and warning contexts
- Nullable migration strategies
- Embracing Nullable Reference Types — Mads Torgersen's guidance on adoption timing and ecosystem considerations
- Resolve nullable warnings
- Attributes for nullable static analysis
- ! (null-forgiving) operator — language reference for the operator and when to use it
- EF Core and nullable reference types
- .NET Runtime nullable annotation guidelines — the annotation principles used when annotating the .NET libraries themselves
- Nullable reference types — 特性概述、可空上下文和编译器分析
- Nullable reference types (C# reference) — 可空注解和警告上下文的语言参考
- Nullable migration strategies
- Embracing Nullable Reference Types — Mads Torgersen关于采用时机和生态系统考量的指导
- Resolve nullable warnings
- Attributes for nullable static analysis
- ! (null-forgiving) operator — 运算符的语言参考及使用场景
- EF Core and nullable reference types
- .NET Runtime nullable annotation guidelines — .NET类库自身注解所遵循的原则