dotnet/runtime 可空性(Nullability)注解规范:API 契约、注解决策与可空属性完全指南
dotnet/runtime 可空性Nullability注解规范API 契约、注解决策与可空属性完全指南【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime本文基于 dotnet/runtime 官方编码指南 docs/coding-guidelines/api-guidelines/nullability.md系统讲解 .NET 类库如何采纳 C# 8/9 引入的引用类型可空性reference type nullability特性为什么要在实现代码中做可空性注解、如何决定一个参数或返回值该不该标?、[NotNullWhen]、[DoesNotReturn]等可空性属性何时使用以及启用可空性检查时代码评审应遵循的三遍评审流程。读完本文你将掌握为公共 API 设计可空性契约的完整决策框架并能理解编译器警告背后的语义与底层 IL 影响。为什么 .NET 类库要全面采纳可空性注解自 C# 8 起并在 C# 9 中增强C# 语言提供了一个可选特性opt-in feature让编译器能够跟踪引用类型的可空性从而捕获潜在的 null 解引用问题。dotnet/runtime 已经从技术栈最底层System.Private.CoreLib开始逐层向上为全部类库代码启用该特性新库开发也一律启用。这样做出于三个主要目的按重要性排序为 .NET 的 API 表面添加恰当的可空性注解。这件事理论上可以只写在引用程序集reference assemblies里但在实现代码中做注解可以帮助验证所选注解的正确性。帮助验证可空性特性本身。.NET 拥有数百万行 C# 代码是一个非常大且健壮的代码库非常适合用来试验特性、发现其亮点和可改进之处。文档中特别指出.NET Core 3.0 期间对 System.Private.CoreLib 的注解工作帮助改善了 C# 8 中最终发布的特性设计.NET 5 期间对其余核心库的注解则进一步充实了 C# 9 的设计。在 .NET Runtime 自身中发现 null 相关缺陷。由于这些代码库测试充分、历史悠久实际发现的有效缺陷不多但对新代码而言注解确实能帮助高亮出 null 值可能被错误解引用的位置避免在测试尚不充分的地方出现NullReferenceException。在仓库中可以直观看到这一采纳的成果核心库源码中大量方法签名使用了可空性标记。例如 src/coreclr/System.Private.CoreLib/src/System/ValueType.cs 和 src/coreclr/System.Private.CoreLib/src/System/RuntimeHandles.cs 中都出现了[NotNullWhen(true)]标注的Try系列方法。Breaking Change 指导注解为什么以后还会变团队的目标是注解“一次做对”并为此尽到应有的审慎义务。但官方也承认未来可能需要增补或修改某些注解原因有三错误Mistakes。被审阅和注解的 API 数量庞大难免出错团队希望能纠正这些错误让长期客户获得最大收益。广度Breadth。注解需要时间因此注解是按时间逐步覆盖整个技术栈的而不是一次性全部完成。反馈Feedback。某些“灰色地带”关于参数或返回类型应为可空还是不可空的决策可能需要重新审视文档后文有详细说明。任何此类注解的新增或变更都可能影响已启用可空性分析和警告的消费代码所收到的警告。即便如此至少在可预见的未来团队仍会这样做并且会非常慎重地考虑变更的时机与方式。注解核心原则注解表达的是意图Intent而非当前实现可空性注解被视为表达意图它代表成员的可空性契约nullability contract。实现层面若偏离这一意图应当被视为实现缺陷implementation bug编译器会通过流分析和可空性警告帮助降低此类缺陷出现的概率。但同时必须清醒认识到编译器所做的验证并不完美。它既可能产生误报明明不会为 null 却提示可能为 null也可能漏报可能对 null 的引用被解引用却不警告。编译器无法保证一个声明返回非空引用的 API 永远不会返回 null正如它也无法验证一个声明接受 null 的实现拿到 null 后一定行为正确。因此在决定如何注解 API 时应当考虑的是期望的契约而不是当前实现也就是说优先按期望方式注解 API 表面然后再去解决代码库中由此产生的警告而不是让警告反向驱动 API 表面的注解。由此衍生出的基本规则DO按期望契约注解所有新 API。CONSIDER在压倒性的实际用法表明存在另一事实契约de facto contract时修改该契约。这一点在库中定义的虚方法/抽象方法/接口方法上尤为相关因为并非所有实现都在你的控制之下派生实现可能并未遵循原始意图。DO尊重已记录的行为documented behaviors。可空性注解是对契约的编码化而文档是对契约的描述。例如若基类的抽象方法或虚方法文档写明传入null会抛异常那么该参数就应当是不可空的。DO继续验证所有参数就像启用可空性警告之前一样。特别地如果你原本会对参数做null检查并在为null时抛ArgumentNullException即使参数被声明为非空也应继续这样做。许多消费者并没有启用可空性检查——可能因为他们使用 C# 以外的语言、使用旧版本 C#、没有选择加入可空性分析、显式抑制了警告或者编译器根本无法检测到误用。DO NOT在为现有 public/protected API 做注解时移除已有的参数验证。如果在注解过程中发现 internal/private 表面在 null 不可能出现的情况下做了多余的 null 检查即发现了死代码这类用法可以移除或替换为断言。AVOID在做注解时对生成 IL 有实质影响的任何修改例如把some.Method()改成some?.Method()。此类变更应作为 bug fix 经过彻底分析和评审。某些情况下此类变更是合理的但它是一个红旗red flag应被严格审查。参数注解规则判断string还是string?API 中绝大多数引用类型的用法在“该不该可空”上比较清晰。对参数而言以下通用准则覆盖了大多数情形DO若方法会检查null并在传入null时抛Argument{Null}Exception无论是在该方法中显式检查还是隐式经由其调用的某个方法检查使得null不可能被传入并让方法成功返回则把参数定义为不可空。DO若方法未检查null但传入null一定会导致解引用并抛NullReferenceException则把参数定义为不可空。DO若文档明确说明对null实参可能抛出异常则把参数定义为不可空。DO若参数被文档明确说明接受null则把参数定义为可空。DO若方法检查参数是否为null并做了“抛异常以外”的处理则把参数定义为可空。这可能包括对输入的规范化处理例如把null当作string.Empty处理。DO若参数是可选参数且默认值为null则把参数定义为可空。DO把Exception派生类型的string message和Exception innerException参数定义为可空。Exception派生类型的其他引用类型参数通常也应为可空除非为了兼容性必须如此。DO当上述准则之间存在分歧时优先选择可空。例如若一个非虚方法的文档暗示不接受null但实现却显式检查、规范化并接受了null输入则该参数应定义为可空。参数注解的灰色地带逐案分析存在一些灰色区域需要逐案分析来确定意图。特别是当参数既未被验证、又未被规范化、也未在文档中就null行为作说明但在某些情况下只是恰好被忽略、传入null目前不会引发问题时应当综合考虑以下因素来决定是否标null我们自己的代码库中是否有传入null的情况如果有很可能应标为可空。知名第三方代码库中是否有传入null的情况如果有很可能应标为可空。调用方是否很可能把null解释为默认值 / nop 占位符如果是很可能应标为可空。同一代码库中类似区域或类似目的的其他方法是否接受null如果是很可能应标为可空。如果方法基本对null无感知oblivious只是碰巧传入null时仍能工作并且该 API 的目的在null参与下毫无意义则参数很可能应标为不可空。返回值与 out 参数注解规则看返回值以及out参数通常更容易因为它们主要可以由 API 的实现能力来决定DO若null在任何情况下都可能被返回则把返回值或 out/ref 参数定义为可空。DO其他所有返回值或 out 参数一律定义为不可空。DO把事件的类型定义为可空。唯一极罕见的例外是实现保证该事件始终至少注册了一个委托。CONSIDER对结构体上的公共属性和字段做特殊处理。默认情况下结构体的引用类型字段是 null如果这些字段是 public 的或某个属性直接暴露了这样的字段而未做进一步验证或加工那么从技术上说它们必须可空注解时也应先按可空处理。但同时也必须考虑预期用法——这种结构体的默认值是否应当被使用。在有限的情形下团队可能决定虽然技术上default(Struct)可以创建默认实例、其暴露的字段可为 null、因而本应可空但这种用法被视为无效的注解时不应当它为准。文档给出了一谱系两端的两个例子System.Threading.CancellationToken它有一个CancellationTokenSource字段而default(CancellationToken)被认为是完全合法的可用实例其CancellationTokenSource字段必须可空。System.Reflection.InterfaceMapping它只是对四个 public 引用类型字段的简单包装技术上这四个字段都应可空。但直接拿来用default(InterfaceMapping)被视为无效的——没人会这么用因为InterfaceMapping只会从某些反射方法调用返回而这些调用产生的正确实例的这些字段都被初始化为非 null。把它们标为可空虽然在技术上正确却会以牺牲 100% 正确用法的体验为代价去迁就 0% 的无效用法。DO NOT关心对象Dispose之后注解的有效性。.NET 中一般在对象释放后再使用它是对其契约的违反同理如果对象本来就不允许释放后使用我们一般也不认为可空性注解在释放之后仍有意义。例如若某属性在构造函数中初始化、在释放前始终非 null、但释放后可能返回 null它仍应注解为不可空。DO NOT关心枚举器在调用MoveNext之前、或MoveNext返回false之后其Current注解的有效性。与 Dispose 一样此类用法被视为无效的我们不想为了迁就错误用法而损害正确消费Current的体验。把一个返回类型注解为不可空等价于记录了一个“它永远不会返回null”的保证。违反这一保证属于应当修复的实现 bug。但这里存在一个巨大的缺口那就是——可重写成员overridable members。虚方法、抽象方法与接口对于虚成员把返回类型注解为不可空等于对所有重写都提出了满足同样保证的要求——正如虚方法的其他已记录行为一样适用于所有重写无论这些保证能否由编译器强制。不遵守这些保证的重写就是 bug。对于已记录“非空返回”保证的现有虚 API期望其返回类型被注解为不可空派生类型必须继续尊重这一保证——只是现在有了编译器的协助。但对于那些没有强保证文档、意图上返回值却应为非null的现有虚 API则是更灰色的区域。最准确的返回类型是T?而基于意图的返回类型是T。选T?的优点是准确反映null可能出现缺点是——明知null永不出现的消费者在解引用时不得不使用!或某种抑制。选T的优点是准确地向重写者传达意图让消费者免于任何形式的抑制但讽刺的缺点是消费者不再验证返回值非null、也不再相信“返回非空”的含义可能反而导致NullReferenceException出现率上升。因此决定现有虚/抽象/接口方法返回类型时应考虑几个因素在“保证”生效之前就已存在的重写中返回null有多常见该方法的实现overrides分布有多广这会加剧 (1)。通过基类调用该方法 vs 通过派生类型调用派生类型可能把返回类型从T?收窄为T哪种更常见在 (3) 的情形下调用方直接解引用结果 vs 把它交给一个接受T?的其他方法哪种更常见Object.ToString可以说是最极端的案例对上述问题的回答是在任何中等规模的代码库中都不难找到ToString在某些情况下返回null的例子无论有意还是无意团队在 dotnet/runtime、dotnet/roslyn、NuGet/NuGet.Client、dotnet/aspnetcore 等项目中都发现了实例。最常见的条件是那些直接返回字符串字段值的类型而该字段可能包含其默认值null对于结构体尤其如此构造函数可能甚至还没机会运行并验证输入。文档中的指引说ToString不应返回null或string.Empty但连文档自己都不遵守这条指引。我们控制之外的数以千计的类型今天重写了这个方法。通过基类object.ToString调用在辅助例程中很常见但许多ToString的实际使用是在派生类型上。在同时定义某类型又消费其ToString的代码库中尤为如此。基于对若干大型代码库的检查团队认为直接解引用在基类上调用的Object.ToString结果相对少见。更常见的是把它传给接受string?的其他方法如String.Concat、String.Format、Console.WriteLine、日志工具等。虽然团队主张ToString的结果不应被假定为特定机器可读格式并解析但代码库确实会这么做如对结果使用Substring在那些情况下调用方必须理解所渲染内容的格式而这通常意味着他们在操作派生类型而非通过基类Object.ToString调用。因此目前Object.ToString被注解为返回string?。随着获得更多消费者使用经验团队可以重新评估这一决定。作为对比Exception.Message同样是虚成员被注解为不可空尽管技术上派生类可以重写它返回null但这么做的情况极其罕见——团队没能找到任何返回null的有意义实例。这一设计决策与 src/coreclr/System.Private.CoreLib/src/System/Exception.CoreCLR.cs 中Exception的实现注解保持一致。接口实现Interface implementationsDO在引用类型T上实现IComparableT?而不是IComparableT。DO在引用类型T上实现IEquatableT?而不是IEquatableT。示例原文档给出的规范写法public sealed class Version : IComparableVersion?, IEquatableVersion?, ...这一规则的原因在于引用类型的消费者完全可能把null传入比较/相等接口IComparableT的CompareTo、IEquatableT的Equals从语义上都应当容忍null实参把泛型实参声明为T?才能在注解层面如实表达该契约。可空性属性Nullable Attributes完全指南C# 编译器遵循一组影响其流分析的特性。当简单注解不足以完整表达方法契约时dotnet/runtime 会使用这些特性DO把总是抛异常的方法如ThrowHelper.ThrowArgumentException或总是退出进程的方法如Environment.Exit注解为[DoesNotReturn]。DO在Try方法上把泛型out参数注解为[MaybeNullWhen(false)]。一般地当方法因无法获取/计算所需数据而返回false时此类参数可能为null。如果消费者把泛型类型定义为不可空则该注解会凸显“方法返回false时out参数可能为null、返回true时非null”如果消费者把泛型类型定义为可空该属性就退化为 nop因为该泛型类型的值本来就可能一直是null例如当字典允许null值时一次成功返回true的TryGetValue调用就可能合理地产生null。DO在Try方法上把非泛型out引用类型参数注解为可空并加上[NotNullWhen(true)]例如TryCreate([NotNullWhen(true)] out Semaphore? semaphore)。对于非泛型参数参数应为可空因为失败的调用通常会把null存入该参数又应为[NotNullWhen(true)]因为成功的调用会存入非null值。DO把保证退出时参数非null的ref参数例如延迟初始化辅助方法注解为[NotNull]。DO把 getter 永远不返回null但 setter 允许null的属性注解为不可空 [AllowNull]。DO把 getter 可能返回null但 setter 对null抛异常的属性注解为可空 [DisallowNull]。DO给Try方法中“如果方法返回true则确定非null”的可空参数添加[NotNullWhen(true)]。例如若Int32.TryParse(string? s)返回true则s已知非null因此方法应写为public static bool TryParse([NotNullWhen(true)] string? s, out int result)。DO给public virtual bool Equals(object? obj)的重写添加[NotNullWhen(true)]除非在极罕见情形下非空实例可能与null比较相等NullableT就是该属性不适用的一个例子。DO当某个可空引用参数在“另一个传入参数求值为非null”的前提下退出时必为非null时添加[NotNullIfNotNull(string)]并把该参数名作为字符串传入。示例public void Exchange([NotNullIfNotNull(value)] ref object? location, object? value);。DO当“如果某个传入参数求值为非null方法就不会返回null”时添加[return: NotNullIfNotNull(string)]并把该参数名作为字符串传入。示例[return: NotNullIfNotNull(name)] public string? FormatName(string? name);。DO给初始化成员字段的辅助方法添加[MemberNotNull(string fieldName)]传入字段名。例如[MemberNotNull(_buffer)] private void InitializeBuffer()。这能避免在“调用初始化方法后随即使用指定字段”的调用点产生虚假警告。注意MemberNotNull有两个构造函数一个接受单个string另一个接受params string[]。当初始化的字段数较少时例如 ≤ 3 个更推荐在方法上使用多个[MemberNotNull(string)]特性而不是使用一个[MemberNotNull(string, string, string, ...)]特性——因为后者不符合 CLS 兼容很可能需要在该行前后用#pragma warning disable和#pragma warning restore来抑制警告。AVOID使用[MaybeNull]。倒不是它有问题而是几乎总是存在更好的选择如T?文档成文时整个 dotnet/runtime 中[MaybeNull]仅有 7 处出现。它适用的一个例子是AsyncLocalT.Value这里不能用[DisallowNull]因为当T可空时null是合法的也不应使用T?因为当T不可空时Value不应被设为null。另一个是相对罕见的情形某个 public 或 protected 字段被暴露可能以 null 开始但不应当被显式置为 null。源码印证在仓库中可以验证这些规则的真实落地src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs 中使用了[DoesNotReturn]标注的辅助方法正是“总是抛异常的方法应标[DoesNotReturn]”这一条规则的实例。src/coreclr/System.Private.CoreLib/src/System/RuntimeType.CoreCLR.cs、src/coreclr/System.Private.CoreLib/src/System/RuntimeHandles.cs、src/coreclr/System.Private.CoreLib/src/System/ValueType.cs 等多处Try方法使用了[NotNullWhen(true)]与上文的Try方法注解规则一一对应。启用可空性时的代码评审指导启用可空性警告的代码评审非常特殊通常与常规代码评审差异显著。常规评审中评审者往往只关注实际被修改的代码例如 diff 工具高亮出的行而启用可空性特性的影响面要大得多——它实际上反转了代码库更准确地说是可空性警告上下文所作用的范围中每一处引用类型使用的含义。例如如果对整个文件启用可空性在文件顶部写#enable nullable然后没有改动文件中的其他任何行那么该文件中每个接受string的方法现在都变成了接受非空string此前向该参数传null是没事的现在编译器会发出警告若要允许 null参数必须改为string?。这意味着启用可空性检查要求评审该上下文中的所有暴露 API无论它们是否被改动——因为 API 暴露的契约可能已被隐式修改。一次启用可空性的代码评审通常包含三遍three passes第一遍评审代码中做出的所有实现变更除非是在显式修 bug这种情况应当罕见可空性所用注解对生成的 IL 应当零影响元数据中可能多出一些属性除外。最常见的变更是给引用类型参数和局部符号添加?。这告知编译器允许 null。对局部变量而言它们在编译时完全蒸发evaporate。对参数而言它们影响编译器输出到元数据的[Nullable(...)]属性但不影响实现 IL。给引用类型使用添加!。这本质上抑制 null 警告告诉编译器把该表达式当作非空处理。它们在编译时蒸发。添加Debug.Assert(reference ! null);语句。这告知编译器所提到的引用非null编译器会将其纳入考量从而抑制对该引用的后续警告直到流分析认为它可能变化为止。与任何Debug.Assert一样这些在 release 构建中未定义DEBUG时编译时蒸发。除此之外几乎任何其他变更都有改变 IL 的潜在可能而这个特性本不该需要改变 IL。特别常见的“偷渡”是在解引用上混入?例如把someVar.SomeMethod()改成someVar?.SomeMethod()——这是 IL 变更只有在确知有一个重要 bug 要修时才应使用否则就是在承担不必要的成本。同样很容易不小心给值类型加上?影响巨大它会把T变成NullableT必须避免。任何本不该需要、却因编译器问题或注解表达力不足而添加的!都应在同一行附上// TODO-NULLABLE: http://link/to/relevant/issue注释。第二遍评审显式做出的 API 变更这些是出现在 diff 中的变更。应从契约角度验证它们是否合理在参数引用类型被加?的地方我们是否确实期望/允许null在返回类型被加?的地方是否确实可能返回null是否有其他东西被改动可能构成意外的 breaking change例如值类型参数被注解成了NullableT而不是T第三遍评审所有其他导出 API评审所有导出 API例如 public 类型上的 public 和 protected 成员中处于返回值和参数位置的所有引用类型。任何没有被改成?的现在都被定义为不可空。对参数而言这意味着消费代码尝试传null时会收到严重警告因此如果null确实是被允许/期望的就应当改掉。对返回值而言这意味着 API 将永不返回null如果它在某些情形下可能返回nullAPI 应改为返回?。这是评审中最耗时、最繁琐的部分。小结dotnet/runtime 的可空性注解规范可以浓缩为三句话注解表达意图而非现状——先按期望契约注解 API 表面再解决实现中的警告而不是被警告牵着走且注解变更本身被视为可管理但需谨慎的 breaking change 来源。参数看“会不会因 null 失败/被特殊处理”返回值看“实现会不会真的返回 null”灰色地带逐案分析自有代码、第三方用法、null是否被当作占位符、同类 API 的一致性、null是否有语义Object.ToString标string?而Exception.Message标非空是两个方向的典型判例。启用可空性的 PR 要过三遍评审实现变更不得改变 IL只允许?、!、Debug.Assert三类变更外加TODO-NULLABLE注释规范、显式 API 变更要经得起契约推敲、未标?的导出 API 一律按新契约为“非空”复核。这套规范既是 .NET 类库自身的工程纪律也是所有下游库开发者为公共 API 设计可空性契约时可直接套用的决策框架。参考文档与源码官方指南原文docs/coding-guidelines/api-guidelines/nullability.md相关 API 指南docs/coding-guidelines/api-guidelines/README.md、docs/coding-guidelines/api-guidelines/System.Memory.md核心库可空性注解实例src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs、src/coreclr/System.Private.CoreLib/src/System/RuntimeType.CoreCLR.cs、src/coreclr/System.Private.CoreLib/src/System/RuntimeHandles.cs、src/coreclr/System.Private.CoreLib/src/System/ValueType.cs、src/coreclr/System.Private.CoreLib/src/System/Exception.CoreCLR.cs【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考