【C# 8.0】Nullable Reference Types 可空引用类型特性,
总结
在 C# 8.0 以后将引用类型默认不可为空, 编译器使用静态分析,帮助开发人员尽可能地规避由空引用带来的代码问题。C# 8.0之前引用类型默认为空,也使用无法运行静态流分析。使用 ? 作为可为空声明,这对值类型和引用类型都适用。!表示忽略可空警告
编译器静态分析对象的属性、字段、参数、 方法返回值、参数ref out、中Nullable Reference Types
特性 。在编写代码时候编译器会根据【可空的引用类型特性】给出相应的警告,它使得程序在编译期更为安全,避免了运行时 NullReferenceException
的发生,我衷心希望大家都能应用上这个新特性,特别是开发公共库的作者们。而且因为这个特性是可以针对某个文件,某段代码进行开启或者关闭,是一个渐进式的特性,所以我们可以逐步引进,不会对项目产生影响。
- 前置条件( precondition):AllowNull 和 DisallowNull 用例在调用某个方法时必须满足的条件。
- 后置条件(postcondition):MaybeNull 和 NotNull实现在方法返回时必须达到的要求。
-
后条件的后置条件(Conditional post-conditions):
NotNullWhen
,MaybeNullWhen
, andNotNullIfNotNull
这就是 C# 8.0 Nullable Reference Types
特性的绝大部分的应用场景,还有一些较为小众的场景比如控制流属性:MemberNotNull、MemberNotNullWhen、 DoesNotReturn
和 DoesNotReturnIf(bool)
没有介绍到,大家感兴趣的话可以自行去了解。
启用可空上下文
从C#8.0开始,我们可以通过启用可空上下文,让VS在开发过程中可以检查我们出现的空指针引用异常。
启用可空上下文的方式有两种:
1、修改.csproj文件,添加<Nullable>enable</Nullable>节点,如:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
Nullable节点的值enable表示启用,disable表示停用。
此外需要注意,这种启用方式是全局性的,修改默认行为,默认是disable,但是从.net6开始,项目默认是启用可空上下文的,项目文件.csproj中会默认包含Nullable节点。
2、使用预编译指令#nullable enable来启用可空上下文,如:
//enable表示启用
#nullable enable
//disable表示停用
#nullable disable
使用预编译指令表示局部性启用,和修改.csproj文件的方式配合使用。而#nullable enable和#nullable disable配合使用可以实现块级的局部性启用。
泛型和可空引用类型特性
T=引用类型 => T?= string?
T=值类型 => T?= int
T=可空引用类型 => T?= string?
T=可空值类型 => T?= int?
对于返回值,T?等于[MaybeNull]T;对于参数值,T?等价于[AllowNull]T。有关更多信息,请参阅语言参考中关于属性的空状态分析的文章。泛型约束class标识不可为空的引用类型,class?表示可为空的引用类型
public class Node<T> where T:notnull { private T item; private Node<T>? next; }
正文
在启用了空值(在.cs文件头部添加预处理命令 #nullable enable或在项目配置文件(*.csproj)中修改,默认是启用的 <Nullable>enable</Nullable>)的上下文中,编译器对代码执行静态分析,以确定所有引用类型变量的空状态:
- not-null:静态分析确定变量具有非空值。
- maybe-null:静态分析无法确定是否为变量分配了非空值。
添加这些属性将为编译器提供有关 API 规则的更多信息。在启用了空值的上下文中编译调用代码时,编译器将在调用方违反这些规则时发出警告。这些属性不会对实现进行更多检查。
属性 | 类别 | 意义 |
---|---|---|
AllowNull | 前提 | 将不可为空的参数、字段或属性(作用与setter)设置为可能为空。 |
DisallowNull | 前提 | 将可为空的参数、字段或属性(作用与setter)设置为永远不应为 null。 |
MaybeNull | 后置条件 | 将不可为空的参数(ref\out)、字段、属性(作用与getter)或返回值设置为可能为空。 |
NotNull | 后置条件 | 将可为空的参数(ref\out)、字段、属性(作用与getter)或返回值设置为永远不会为空。 |
MaybeNullWhen | 有条件的后置条件 | 当方法返回指定的值时,不可为 null 的参数可能为 null。bool |
NotNullWhen | 有条件的后置条件 | 当方法返回指定的值时,可为 null 的参数将不会为 null。bool |
NotNullIfNotNull | 有条件的后置条件 | 如果指定参数的参数不为 null,则返回值、属性或参数不为空。 |
MemberNotNull | 方法和属性帮助程序方法 | 当方法返回时,列出的成员不会为空。 |
MemberNotNullWhen | 方法和属性帮助程序方法 | 当方法返回指定的值时,列出的成员将不会为 null。bool |
DoesNotReturn | 无法访问的代码 | 方法或属性永远不会返回。换句话说,它总是引发异常。 |
DoesNotReturnIf | 无法访问的代码 | 如果关联的参数具有指定的值,则永远不会返回此方法或属性。bool |
notnull
泛型约束
我们先看一下,一个简单的泛型接口定义:
interface IDoStuff<TIn, TOut> { TOut DoStuff(TIn input); }
在这个接口定义中,我们可以很清楚的知道,这个接口可以接受两个泛型参数,一个输入,一个输出,那么我们如何把非空引用这个新特性,加在泛型约束中呢?
答案是:notnull
我们来看一下实现:
interface IDoStuff<TIn, TOut> where TIn : notnull where TOut : notnull { TOut DoStuff(TIn input); }
Nice! 这样我们就得到了一个具有非空类型约束的接口定义了,我们来试着写一个实现:
// Warning: CS8714 - Nullability of type argument 'TIn' doesn't match 'notnull' constraint. // Warning: CS8714 - Nullability of type argument 'TOut' doesn't match 'notnull' constraint. public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut> { public TOut DoStuff(TIn input) { ... } }
可以看到,如果我们的实现没有加上非空类型约束,就会出现对应的警告信息。我们来修复一下:
// No warnings! public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut> where TIn : notnull where TOut : notnull { TOut DoStuff(TIn input) { ... } }
我们来继续创建几个这个类的实例,看一下效果:
// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint var doStuffer = new DoStuff<string?, string?>(); // No warnings! var doStufferRight = new DoStuff<string, string>();
同样的,值类型也一样有效:
// Warning: CS8714 - Nullability of type argument 'int?' doesn't match 'notnull' constraint var doStuffer = new DoStuff<int?, int?>(); // No warnings! var doStufferRight = new DoStuff<int, int>();
在泛型编程中,当你需要限定只有非空引用类型可以被当作类型参数时,非常有用。一个现成的例子是 Dictionary<TKey, TValue>
,其中的 TKey
是被约束为 notnull
,禁止了 null
作为 key:
// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint var d1 = new Dictionary<string?, string>(10); // And as expected, using 'null' as a key for a non-nullable key type is a warning... var d2 = new Dictionary<string, string>(10); // Warning: CS8625 - Cannot convert to non-nullable reference type. var nothing = d2[null];
可空的前提条件:AllowNull和
DisallowNull
可空的前提条件:被赋值对象对值的要求AllowNull或DisallowNull
AllowNull
的使用
先来看一个例子:
public class MyClass { public string MyValue { get; set; } }
这是一个 C# 8.0 之前很常见的例子,但是从 C# 8.0 开始,这意味着 string
表示一个非可空的 string
! 有些情况下,我们需要可以使用 null
对它赋值,但是 get
的时候能拿到一个 string
的值,我们可以通过 AllowNull
来实现:
public class MyClass { private string _innerValue = string.Empty; [AllowNull] public string MyValue { get { return _innerValue; } set { _innerValue = value ?? string.Empty; } } }
这样我们就可以保证 getter
得到的值永远都不会为 null
,但是 setter
依然可以设置 null
值:
void M1(MyClass mc) { mc.MyValue = null; // Allowed because of AllowNull } void M2(MyClass mc) { Console.WriteLine(mc.MyValue.Length); // Also allowed, note there is no warning }
DisallowNull的使用
public static HandleMethods { public static void DisposeAndClear(ref MyHandle handle) { ... } }
在这个情况下,MyHandle
指向某个资源。通常使用这个API的时候我们有一个 not null
的实例通过 ref
传递进去,但是当这个资源被这个API Clear
之后,这个引用就会变成 null
. 我们如何能同时兼顾 handle
可为 null
,但是传参的时候又不可为 null
呢?答案是 DisallowNull
:
public static HandleMethods { public static void DisposeAndClear([DisallowNull] ref MyHandle? handle) { ... } }
我们来看一下效果:
void M(MyHandle handle) { MyHandle? local = null; // Create a null value here HandleMethods.DisposeAndClear(ref local); // Warning: CS8601 - Possible null reference assignment // Now pass the non-null handle HandleMethods.DisposeAndClear(ref handle); // No warning! ... But the value could be null now Console.WriteLine(handle.SomeProperty); // Warning: CS8602 - Dereference of a possibly null reference }
这两个属性可以允许我们在需要的情况下使用单向的可空性或者不可空性。
可空的后置条件:MaybeNull
和NotNull
可空的后置条件:返回对象的情况(MaybeNull
、NotNull
)
可以使用以下特性指定无条件后置条件:
先看一下下面这个API:
public class MyArray { // Result is the default of T if no match is found public static T Find<T>(T[] array, Func<T, bool> match) { } // Never gives back a null when called public static void Resize<T>(ref T[] array, int newSize) { } }
现在我们有一个问题,我们希望 Find
在找不到元素的时候返回 default
,default
在 T
为引用类型的时候为 null
,另一方面,我们希望 Resize
能接受一个可能为 null
的数组,但是当 Resize
调用之后, array
绝不会为 null
. 我们如何才能实现这样的效果呢?
答案是 [MaybeNull]
和 [NotNull]
,通过这两个属性,我们可以实现这种奇妙的效果!让我们来修改一下我们的API:
public class MyArray { // Result is the default of T if no match is found [return: MaybeNull] public static T Find<T>(T[] array, Func<T, bool> match) { ... } // Never gives back a null when called public static void Resize<T>([NotNull] ref T[]? array, int newSize) { } }
现在这些可以影响调用方:
void M(string[] testArray) { var value = MyArray.Find<string>(testArray, s => s == "Hello!"); Console.WriteLine(value.Length); // Warning: Dereference of a possibly null reference. MyArray.Resize<string>(ref testArray, 200); Console.WriteLine(testArray.Length); // Safe! }
第一个方法指定返回的T可以是空值。这意味着此方法的调用方在使用其结果时必须检查是否为空。
第二个方法有一个更复杂的签名: [NotNull] ref T[]? 数组。这意味着作为输入的数组可以为空,但当调用Resize时,数组不可以为空。这意味着,如果您在调用Resize后“点”到数组中,将不会收到警告。但调用Resize后,数组将不再为空。
条件性的后置条件:MaybeNullWhen(bool)
和 NotNullWhen(bool)
MaybeNullWhen(bool)
和 NotNullWhen(bool)
思考一下另外一个例子:
public class MyString { // True when 'value' is null public static bool IsNullOrEmpty(string? value) { ... } } public class MyVersion { // If it parses successfully, the Version will not be null. public static bool TryParse(string? input, out Version? version) { ... } } public class MyQueue<T> { // 'result' could be null if we couldn't Dequeue it. public bool TryDequeue(out T result) { ... } }
像这样的API,在我们平时的开发过程中都会经常使用到,根据API返回的 true
和 false
,决定了我们传进去的参数是 null
还是 notnull
.
我们希望实现下面三个效果: 1. 当 IsNullOrEmpty
返回 false
的时候,value
不为空 2. 当 TryParse
返回 true
的时候,version
不为空 3. 当 TryDequeue
返回 false
的时候,result
可能会为空(当 T
为引用类型的时候)
通过 NotNullWhen(bool)
和 MaybeNullWhen(bool)
,我们能实现这个更为奇妙的效果,让我们来修改一下代码:
public class MyString { // True when 'value' is null public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) { ... } } public class MyVersion { // If it parses successfully, the Version will not be null. public static bool TryParse(string? input, [NotNullWhen(true)] out Version? version) { ... } } public class MyQueue<T> { // 'result' could be null if we couldn't Dequeue it. public bool TryDequeue([MaybeNullWhen(false)] out T result) { ... } }
然后看一下效果:
void StringTest(string? s) { if (MyString.IsNullOrEmpty(s)) { // This would generate a warning: // Console.WriteLine(s.Length); return; } Console.WriteLine(s.Length); // Safe! } void VersionTest(string? s) { if (!MyVersion.TryParse(s, out var version)) { // This would generate a warning: // Console.WriteLine(version.Major); return; } Console.WriteLine(version.Major); // Safe! } void QueueTest(MyQueue<string> q) { if (!q.TryDequeue(out var result)) { // This would generate a warning: // Console.WriteLine(result.Length); return; } Console.WriteLine(result.Length); // Safe! }
我们可以看到: 如果 IsNullOrEmpty
返回 false
, 那么 s
不为空并且可以安全的访问内在的属性 如果 TryParse
返回 true
, 那么 version
不为空并且可以安全的访问内在的属性 * 如果 TryDequeue
返回 false
, 那么 result
可能为空,反之则不为空
NotNullWhen(bool) 用法
#nullable enable using System.Diagnostics.CodeAnalysis; Screen screen = new(); string? userInput = null; if (Screen.IsNullOrEmpty(userInput)) { // 警告 CS8600 将 null 文本或可能的 null 值转换为不可为 null 类型。 string NonullCheckHere = userInput;//因为返回是true是空类型所以 要进行空检测 } else { //因为返回是false 不是空类型,所以这边不在需要进行空检测 string NonullCheck = userInput; int messageLength = userInput.Length; // no null check needed. } Console.Read(); public class Screen { public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) => (value == null || 0 == value.Length) ? true : false; }
输入与输出的可空性依赖
思考一下这个例子:
class MyPath { public static string? GetFileName(string? path) { ... } }
在这个例子中,我们的参数和返回值都是一个可空的 string
,但是我们希望实现这样的一个效果:当参数 path
不为空的时候,返回值也不为空。
在这种情况下,返回参数的可空性依赖于传入的参数,我们要如何实现呢?
通过 NotNullIfNotNull(string)
这个属性,我们可以实现这个最有意思的需求,让我们看一下修改之后的代码:
该类除了实现Load方法外,还会根据ReloadOnChange属性,在构造函数中注册OnChange事件,用于重新加载配置信息,源码如下:
请考虑如下示例:
class MyPath { [return: NotNullIfNotNull("path")] public static string? GetFileName(string? path) { ... } }
看一下效果:
void PathTest(string? path) { var possiblyNullPath = MyPath.GetFileName(path); Console.WriteLine(possiblyNullPath.Length); // Warning: Dereference of a possibly null reference if (!string.IsNullOrEmpty(path)) { var goodPath = MyPath.GetFileName(path); Console.WriteLine(goodPath.Length); // Safe! } }
[MemberNotNull] 与 [MemberNotNullWhen]
如下图所示,由于编译器无法保证 _mayNullStr.Length 不会引发空引用异常,所以抛出编译错误 CS8602;
此时可以通过添加 MemberNotNull 特性,显式地告诉编译器方法 PromisStrNotNull() 可以保证 _mayNullStr 不为 Null。
/// <summary> /// 返回 true 时,<see cref="_mayNullStr"/> 不为 null /// </summary> /// <returns></returns> [MemberNotNullWhen(true, nameof(_mayNullStr))] private bool StrNotNullWhenReturnTrue() { if (DateTime.Now.DayOfWeek == DayOfWeek.Friday) { _mayNullStr = "明天不用上班啦!"; return true; } _mayNullStr = null; return false; }