C# – Nullable Reference Types
前言
Nullable Reference Types 是 C# 8.0 推出的东东,用了好几年了,今天心血来潮特写一篇介绍它。
参考:
官方 docs – Nullable reference types
官方 docs – Attributes for null-state static analysis interpreted by the C# compiler
官方 docs – Unconstrained type parameter annotations
前世
我们先讲一些比较基础和早期(8.0 之前)的情况。
C# 类型可以抽象分成两大类,第一个是 value types 值类型,第二个是 reference types 引用类型。
Value Types 对比 Reference Types
主要两个区别:
-
指针和拷贝
int v1 = 100; int v2 = v1; v1 = 300; Console.WriteLine(v2); // 100值类型 (e.g. int) 是拷贝的概念,
v2 = v1意思是把v1的值 copy paste tov2,然后它俩就没关系了,修改v1不会影响到v2。引用类型 (e.g. array) 则是指针概念
var v1 = new int[1] { 100 }; var v2 = v1; v1[0] = 300; Console.WriteLine(v2[0]); // 300v2 = v1不是拷贝,而是一种指向概念,这两个 variable hold 的值是同一个内存地址,修改v1等同于修改v2。 -
nullable vs non-nullable
引用类型天生是
nullable,值类型则不可为null。public class Person { public int Id { get; set; } public int[] Ids { get; set; } } var person = new Person(); Console.WriteLine(person.Id == 0); // true Console.WriteLine(person.Ids == null); // true person.Id = null; // Cannot convert null to 'int' because it is a non-nullable value typeCS0037int Id不可能是null,它至少会是default(int)也就是0。尝试赋值
null给int会直接报错。int[]则是nullable,default(int[])会得到null。
提醒:C# 的 string 是引用类型,DateTime 是值类型(它是 struct)。
Nullable Value Types
值类型没有 null 概念是很不方便的,你不能说 0 就代表 "没有",0 就是一个数,它只是默认,但不是 "没有"。
所以 C# 搞了一个 Nullable<T>。
长这样:
int? number = null;
number = 1;
// int? 是语法糖,它的本质是 Nullable<int>
Nullable<int> number2 = null;
number2 = 1;
Nullable 不是 class,而是 struct。

使用方式是这样:
int? number = 1;
if (number.HasValue) // 或者 number != null
{
Console.WriteLine(number.Value); // 透过 .Value 获取到 int 值
}
var number1 = number.GetValueOrDefault(); // 返回值,如果没有值就返回 default 值,比如 0
先判断一下,确定有值才使用,或者当没有值时,采用默认值。
总之,就是要有明确对 null 情况的处理就是了。
有了 Nullable<T> struct, 值类型就具备 nullable 能力了。
今生
值类型和引用类型都具备 nullable 能力。
但是值类型同时具备 non-nullable 能力,而引用类型却不具备。
还有,值类型默认是 non-nullable,而引用类型则一定是 nullable。
这也造成了开发时的混乱。
Nullable Reference Types
于是,在 C# 8.0 推出了 Nullable Reference Types 概念。
首先,需要开启这个机制:
打开 .csproj 文件,加入
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <!--加入这一句-->
<LangVersion>8.0</LangVersion>
</PropertyGroup>
</Project>
开启后
int[] numbers = null; // Warning: Converting null literal or possible null value to non-nullable type
赋值 null 给引用类型会出现一个警告。
因为引用类型默认必须是 non-nullable,这和值类型规则一致。
int[] numbers = { 1, 2, 3 }; // 不给 null 就没有警告了
另外,在类型右边加上问号 int[]? 代表是 nullable
int[]? numbers2 = null; // ok
这也和值类型规则一致。
使用方式也类似
int[]? numbers2 = null;
Console.WriteLine(numbers2.Length); // 直接使用会有 Warning: Dereference of a possibly null reference,因为如果是 null 会 runtime error
// 先判断确定不是 null 才使用
if (numbers2 != null)
{
Console.WriteLine(numbers2.Length); // ok, no warning
}
Console.WriteLine(numbers2!.Length); // 惊叹号 ! 是 bypass 检测的意思,放了就不会有 warning 了。(自己要小心 runtime error)
Console.WriteLine(numbers2?.Length); // ?. 的意思是, 如果 number2 是 null,那直接中断,返回 null。
先判断一下,确定不是 null 才使用,或者中断返回 null,或者断言一定有值。
总之,就是要有明确对 null 情况的处理就是了。
nullable value types 和 nullable reference types 的区别
表面上看,它俩确实挺相像的,但从本质上,它俩是不同的思路:
-
值类型本身是不能 null 的,它是利用了
Nullable<T> struct才间接实现了nullable。 -
引用类型一定是
nullable,开启 nullable reference types 机制只是一种 IDE 层面的检测(也因此它只是个 warning,而不是 compile error)。 -
default的返回值Console.WriteLine(default(int) == 0); // true Console.WriteLine(default(int?) == null); // true Console.WriteLine(default(int[]) == null); // true,也是 true 哦! Console.WriteLine(default(int[]?) == null); // true -
注意:
default对值类型的nullable是有区别的,但对于引用类型来说,default始终只返回null。
所以,我们最好是把它俩分开看待,不要混肴。比如说 int[]? 是不是等于 Nullable<int[]>?当然不是!
当 nullable 遇上 generic 泛型约束
参考:官网 docs – Nullable reference types #constraints
首先,讲一点关于泛型 generic 和约束 constraints 相关的基础知识:
这是一个带有泛型的 Person 类
// C# 7.0 测试
public class Person<T>{}
var v1 = new Person<int>(); // 值类型
var v2 = new Person<int?>(); // nullable 值类型
var v3 = new Person<int[]>(); // 引用类型
泛型没有任何约束,可以用值类型、nullable 值类型、也可以用引用类型。
加上约束
public class Person<T> where T : class {}
var v1 = new Person<int>(); // The type 'int' must be a reference type
var v2 = new Person<int?>(); // The type 'int?' must be a reference type
var v3 = new Person<int[]>(); // ok
T : class 表示:T 必须是引用类型(e.g. interface、array、class instance 都是)
只有 v3 的 int[] 是引用类型,可以通过 。
int 和 int? 是值类型,会报错。
把约束换成
public class Person<T> where T : struct {}
var v1 = new Person<int>(); // 值类型可以
var v2 = new Person<int?>(); // The type 'int?' must be a non-nullable value type
var v3 = new Person<int[]>(); // type 'int[]' must be a non-nullable value type
T : struct 表示:T 必须是值类型,而且是 non-nullable(e.g. int、bool、DateTime)。
注意哦,Nullable<T> 不行哦。
C# 没有办法约束 T 必须是 Nullable<T>,做不到。
public class Person<T> where T : Nullable<int> {} // Error: 'int?' is not a valid constraint
不只是 Nullable<T>,其实任何 struct 都不行。
public struct MyNullable { }
public class Person<T> where T : MyNullable {} // Error: 'MyNullable' is not a valid constraint
C# 8.0 的泛型约束
C# 8.0 改了/也多了一些相关的泛型约束
public class Person<T> where T : class {}
var v1 = new Person<int[]>(); // ok
var v2 = new Person<int[]?>(); // Warning: Nullability of type argument 'int[]?' doesn't match 'class' constraint.
T : class 表示:T 是引用类型,而且是 non-nullable。
因此 init[]? 会发出警告。
public class Person<T> where T : class? {}
var v1 = new Person<int[]>(); // ok
var v2 = new Person<int[]?>(); // ok
T : class? 则表示:T 是引用类型,nullable 也行。
提醒:依然没有 T : struct? 哦。
另外,8.0 还多了一个
public class Person<T> where T : notnull {}
var v1 = new Person<int>(); // ok
var v2 = new Person<int?>(); // Warning: Nullability of type argument 'int?' doesn't match 'notnull' constraint.
var v3 = new Person<int[]>(); // ok
var v4 = new Person<int[]?>(); // Warning: Nullability of type argument 'int[]?' doesn't match 'notnull' constraint.
T : notnull 表示:可以是值类型或引用类型,但是不能是 nullable。
当 nullable 遇上 generic 泛型
参考:官网 docs – Nullable reference types #Generics
上一 part 我们聚焦在泛型约束,这一 part 我们来看看泛型的使用。
C# 8.0 的限制
我们先看 C# 8.0 的版本(9.0 有改动)
// C# 8.0 测试
public class Person<T>
{
public T Value { get; set; } // Warning: Non-nullable property 'Value' must contain a non-null value when exiting constructor
}
var v1 = new Person<int>().Value; // non-nullable 值类型
var v2 = new Person<int?>().Value; // nullable 值类型
var v3 = new Person<int[]>().Value; // non-nullable 引用类型
var v4 = new Person<int[]?>().Value; // nullable 引用类型
出现了一个 warning,警告 Value 可能为 null。
这是合理的,试想想,假如传入的 T 是 int[],Value 默认值是 default(int[]) 得出 null。
这样就不 match 了,int[] 不允许 null,但是 default(int[]) 却只能得出 null。
怎么破?
public class Person<T>
{
public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}
既然有可能是 null,那就改成 T? 看看。
结果报错了!这是 C# 8.0 的一个局限,T? 只能用于当 T 约束是 non-nullable 值类型或 non-nullable 引用类型。
改成
public class Person<T> where T : class
{
public T? Value { get; set; }
}
// 或者
public class Person<T> where T : struct
{
public T? Value { get; set; }
}
这样就可以了。
注:T : notnull 和 T : class? 是不行的哦:
public class Person<T> where T : notnull
{
public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}
public class Person<T> where T : class?
{
public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}
为什么它有诸多限制?我个人的理解是这样:
T? 意味着 T 一定要是 non-nullable,否则就会出现 Nullable<Nullable<int>> 这种嵌套的问题(当然编译器可以处理,但也许是当初还没有完善吧)。
另外,nullable 值类型和 nullabla 引用类型本质上有很大区别,混用可能会导致一些不明确的逻辑(也许当初还没约定好吧),因此 C# 8.0 一定要明确泛型约束后才能使用 T?。
好,细节就不管了,反正 C# 9.0 后限制就突破了。
C# 9.0
C# 9.0 突破了之前的限制:
// 没有约束 ok
public class Person<T>
{
public T? Value { get; set; }
}
// 约束成 nullable 引用类型 ok
public class Person<T> where T : class?
{
public T? Value { get; set; }
}
// 约束成 non-nullable 引用/值类型 ok
public class Person<T> where T : notnull
{
public T? Value { get; set; }
}
虽然可以无忧无虑的使用 T? 但是它有一些潜规则,我们需要知道。
public class Person<T>
{
public T? Value { get; set; }
}
var v1 = new Person<int[]>().Value; // int[]?
我们一个一个看,int[] 变成了 int[]?。
合理,因为里面是 T?。
var v2 = new Person<int[]?>().Value; // int[]?
int[]? 出来还是 int[]? 。
合理,总不可能嵌套成 int[]?? 吧。
var v3 = new Person<int?>().Value; // int?
int? 出来还是 int?。
和上一题一样,没有变成嵌套的 Nullable<Nullable<int>>,合理。
var v4 = new Person<int>().Value; // int
咦...前面三个出来都是 nullable,符合 T? 的表达,怎么这一个出来不是 nullable 呢?
没错,这就是混用的局限和潜规则。
查看 sharplab.io

透过反编译(我们写的 C# → 编译 → IL → 反编译 → 优化过的 C#),我们可以看到最终 C# 的 source code 是 T 而已,不是 T? 或 Nullable<T>。
因此,传入 int 结果当然只能是 int,不可能变成 int?。
因为nullable 值类型和引用类型是完全不同的实现方式(值类型需要 Nullable struct),
所以在不明确 T 的情况下,根本不可能有一种代码能实现两种类型的 nullable。
我们试试明确 T 为值类型看看

T? 变成了 Nullable<T>,效果
var v1 = new Person<int>().Value; // int?
成功变成 int? 了。
潜规则怎么记?
T? 表示它理应是一个 nullable。
绝大多数情况下,它也确实会是 nullable。
只有在 T 不明确(可能是值类型和引用类型)时,如果 T 是 non-nullable 值类型,它不会变成 nullable。
比如
public class Person<T> where T : notnull // T 可能是值类型或引用类型
{
public T? Value { get; set; }
}
var v1 = new Person<int>().Value; // int 没有变成 nullable
var v2 = new Person<int[]>().Value; // int[]? 变成 nullable
还有
public class Person<T> // T 可能是值类型或引用类型
{
public T? Value { get; set; }
}
var v1 = new Person<int>().Value; // int 没有变成 nullable
var v2 = new Person<int[]>().Value; // int[]? 变成 nullable
记住了哦。
Attributes for Nullable Reference Types
参考:官方 docs – Attributes for null-state static analysis interpreted by the C# compiler
Nullable Reference Types 使用方式大致如上,日常够用了。
不过,有时候会遇到一些比较奇葩的场景,这时可能需要借助 Attributes 的能力,让代码更加 type-safe。
我们一个一个 Attributes 看一看 🚀。
AllowNull
AllowNull Attribute 用在 class property 的 setter 上。
public class Person
{
private string _value = "default value";
public string Value
{
get => _value;
set => _value = value;
}
}
有一个 Person 类,里面有一个 Value 属性。
Value 是 non-nullable,但是我们希望允许赋值 null 给它,因为可以在 setter 里面做 null 的处理(比如给默认值)。
如果我们把它改成 string?
public class Person
{
private string _value = "default value";
public string? Value
{
get => _value;
set => _value = value ?? "default value";
}
}
var person = new Person
{
Value = null // 可以给 null
};
DoSomething(person);
static void DoSomething(Person person)
{
Console.WriteLine(person.Value.Length); // Warning : Dereference of a possibly null reference
// 因为 Value 是 nullable 所以这里有警告
}
这样连 getter 也变成 nullable 了,不是我们期望的。
唯一的解法就是使用 AllowNull Attribute。
public class Person
{
private string _value = "default value";
[AllowNull] // 加上 AllowNull 让 setter 允许 set null 进来,与此同时,getter 依然是 non-nullable
public string Value
{
get => _value;
set => _value = value ?? "default value";
}
}
var person = new Person
{
Value = null // 可以给 null
};
DoSomething(person);
static void DoSomething(Person person)
{
Console.WriteLine(person.Value.Length); // 没有 warning 了
}
提醒:只有引用类型可以使用 AllowNull,值类型不行。
因为倘若要允许给值类型 setnull,那它的类型就要变成 Nullable<T>,但是我们并没有要换掉 Value 的类型,所以根本办不到。
反观,引用类型它不管是不是 nullable,它本质上就只有一个 string 类型而已,string? 只是 IDE 层面的东西,最终的 source code 是没有 string? 这个写法的。
DisallowNull
DisallowNull 顾名思义,就是 AllowNull 的相反。
public class Person
{
private int? _value;
[DisallowNull]
public int? Value
{
get => _value;
set => _value = value;
}
}
var person = new Person();
person.Value = null; // Warning: A possible null value may not be used for a type marked with [NotNull] or [DisallowNull]
// 不可以 set null
DoSomething(person);
static void DoSomething(Person person)
{
Console.WriteLine(person.Value.HasValue); // 需要处理 Value 可能是 null 的情况
}
Value setter 不允许赋值 null,但 Value 本身是 nullable。
也就是说,一开始允许 Value 是 null,但后来赋值就不允许是 null 了。
当然,使用 Value 的时候,一定要处理 null 的情况。
注:property 是引用类型或值类型都可以使用 DisallowNull Attribute。
NotNull
NotNull Attribute 用在参数上。
有一个 throw when null 函数
static void ThrowWhenNull(object? value)
{
if (value == null) throw new ArgumentNullException();
}
如果参数是 null 就 throw error。
它的使用方式是这样:
static void LogMessage(string? message)
{
ThrowWhenNull(message);
Console.WriteLine(message.Length); // Warning: Dereference of a possibly null reference
}
message 是 nullable,经过 ThrowWhenNull 函数检查,如果是 null 就 throw error,如果不是 null 就继续往下。
按理说,既然已经检测了,使用 message.Length 时,message 就不可能是 null,但这里却依旧 warning。
显然 IDE 不够聪明,我们需要使用 NotNull Attribute 帮助它。
// 在参数 value 左边加上 NotNull Attribute
static void ThrowWhenNull([NotNull] object? value)
{
if (value == null) throw new ArgumentNullException();
}
static void LogMessage(string? message)
{
ThrowWhenNull(message);
Console.WriteLine(message.Length); // 没有 warning 了
}
它的意思是:参数 value 本来是 nullable,但只要函数允许完,参数就变成 non-nullable。
因此在 ThrowWhenNull 后,message.Length 就不会有 nullable 的 warning 了。
其实我们不需要自己实现 ThrowWhenNull,因为有 built-in 的:
static void LogMessage(string? message)
{
ArgumentNullException.ThrowIfNull(message);
Console.WriteLine(message.Length);
}
源码:

它就是用 NotNull Attribute 实现的。
NotNullWhen
NotNullWhen Attribute 和 NotNull 挺类似的,区别在于:NotNull 是看函数有没有执行完(没有 throw),而 NotNullWhen 则是看函数的返回值 bool。
// 如果函数返回 true,那 value 就不是 null
static bool NotNullOrEmpty([NotNullWhen(true)] string? value)
{
return value != null && value.Length > 0;
}
static void LogMessage(string? message)
{
if (NotNullOrEmpty(message))
{
Console.WriteLine(message.Length); // 没有 warning
}
}
built-in 的 string.IsNullOrEmpty 函数就用到了 NotNullWhen Attribute:

再一个例子:
static bool TryParse(string? format, out string? parsed)
{
parsed = format == "test" ? "yes" : null;
return parsed != null;
}
static void LogMessage(string? message)
{
if (TryParse("test", out var parsed))
{
Console.WriteLine(parsed.Length); // Warning: Dereference of a possibly null reference
}
}
parsed.Length 出现了警告,因为 IDE 不够聪明,以为 parsed 可能是 null。
在 out string? parsed 加上 NotNullWhen(true)
static bool TryParse(string format, [NotNullWhen(true)] out string? parsed)
这样就不会有 warning 了。
NotNullIfNotNull
NotNullIfNotNull Attribute 用在定义函数返回值,它会依据某个参数是不是 nullable 来决定返回值是不是 nullable。
// 如果 format 是 nullable 那 return 也是 nullable,
// 如果 format 是 non-nullable 那 return 也是 non-nullable
[return: NotNullIfNotNull(nameof(format))]
static string? Parse(string? format)
{
return format == null ? null : "yes";
}
static void LogMessage(string message)
{
// 如果 message 是 nullable 那 parsed 就是 nullable
// 如果 message 是 non-nullable 那 parsed 就是 non-nullable
var parsed = Parse(message);
Console.WriteLine(parsed.Length); // 没有 warning
}
注意看,它和 NotNullWhen 用法不太一样,它判断的是参数的类型,而不是实质的 value。
比如说参数类型是 string? 那 return 就是 string?,不管最终参数值是不是 null。
我个人的感觉NotNullIfNotNull没有 NotNullWhen 来的好用。
MemberNotNull
MemberNotNull Attribute 用在 class method 上
public class Person
{
public string? Name { get; set; }
public int? Age { get; set; }
[MemberNotNull(nameof(Name), nameof(Age))]
public void Init()
{
Name = "default value";
Age = 0;
}
}
它的逻辑是这样:
Name 和 Age 一开始是 nullable,在调用 Init 方法后,Name 和 Age 就变成 non-nullable 了。
使用方式:
var person = new Person();
Console.WriteLine(person.Name.Length); // Warning: Dereference of a possibly null reference
person.Init();
Console.WriteLine(person.Name.Length); // 不会 warning 了
MemberNotNullWhen
MemberNotNullWhen Attribute 和 MemberNotNull 用法雷同,区别是它会返回 bool。
public class Person
{
public string? Name { get; set; }
public int? Age { get; set; }
// 返回 true 表示 initialize 成功
[MemberNotNullWhen(true, nameof(Name), nameof(Age))]
public bool Init()
{
Name = "default value";
Age = 0;
return true;
}
}
var person = new Person();
if (person.Init()) // 判断成功才使用
{
Console.WriteLine(person.Name.Length); // 没有 warning
}
// initialize 可能失败,所以这里依然会有 warning
Console.WriteLine(person.Name.Length); // Warning: Dereference of a possibly null reference
DoesNotReturn
DoesNotReturn Attribute 用在函数/方法上。
static void ThrowError()
{
throw new Exception("whatever");
}
static void LogMessage(string? message)
{
ThrowError();
Console.WriteLine(message.Length); // Warning: Dereference of a possibly null reference
}
虽然已经 throw error 了,但是 warning 依旧。
因为 IDE 不够 smart,它感知不到。
我们需要添加 DoesNotReturn 来帮助它。
[DoesNotReturn]
static void ThrowError()
{
throw new Exception("whatever");
}
static void LogMessage(string? message)
{
ThrowError();
Console.WriteLine(message.Length); // 没 warning 了
}
总结
比起 TypeScript,感觉 C# 对于 nullable 的处理不是很完善。
尤其是还需要搞一些 Attribute 来帮助 IDE,希望以后还会进步吧。
先写这些,以后有用到更复杂的例子再回来补。

浙公网安备 33010602011771号