C# 8.0 引入了可空引用类型,你可以通过 ?
为字段、属性、方法参数、返回值等添加是否可为 null 的特性。
但是如果你真的在把你原有的旧项目迁移到可空类型的时候,你就会发现情况远比你想象当中复杂,因为你写的代码可能只在部分情况下可空,部分情况下不可空;或者传入空时才可为空,传入非空时则不可为空。
C# 8.0 可空特性
在开始迁移你的项目之前,你可能需要了解如何开启项目的可空类型支持:
可空引用类型是 C# 8.0 带来的新特性。
你可能会好奇,C# 语言的可空特性为什么在编译成类库之后,依然可以被引用它的程序集识别。也许你可以理解为有什么特性 Attribute
标记了字段、属性、方法参数、返回值的可空特性,于是可空特性就被编译到程序集中了。
确实,可空特性是通过 NullableAttribute
和 NullableContextAttribute
这两个特性标记的。
但你是否好奇,即使在古老的 .NET Framework 4.5 或者 .NET Standard 2.0 中开发的时候,你也可以编译出支持可空信息的程序集出来。这些古老的框架中没有这些新出来的类型,为什么也可以携带类型的可空特性呢?
实际上反编译一下编译出来的程序集就能立刻看到结果了。
看下图,在早期版本的 .NET 框架中,可空特性实际上是被编译到程序集里面,作为 internal
的 Attribute
类型了。
所以,放心使用可空类型吧!旧版本的框架也是可以用的。
更灵活控制的可空特性
阻碍你将老项目迁移到可空类型的原因,可能还有你原来代码逻辑的问题。因为有些情况下你无法完完全全将类型迁移到可空。
例如:
- 有些时候你不得不为非空的类型赋值为
null
或者获取可空类型时你能确保此时一定不为null
(待会儿我会解释到底是什么情况); - 一个方法,可能这种情况下返回的是
null
那种情况下返回的是非null
; - 可能调用者传入
null
的时候才返回null
,传入非null
的时候返回非null
。
为了解决这些情况,C# 8.0 还同时引入了下面这些 Attribute
:
AllowNull
: 标记一个不可空的输入实际上是可以传入 null 的。DisallowNull
: 标记一个可空的输入实际上不应该传入 null。MaybeNull
: 标记一个非空的返回值实际上可能会返回 null,返回值包括输出参数。NotNull
: 标记一个可空的返回值实际上是不可能为 null 的。MaybeNullWhen
: 当返回指定的 true/false 时某个输出参数才可能为 null,而返回相反的值时那个输出参数则不可为 null。NotNullWhen
: 当返回指定的 true/false 时,某个输出参数不可为 null,而返回相反的值时那个输出参数则可能为 null。NotNullIfNotNull
: 指定的参数传入 null 时才可能返回 null,指定的参数传入非 null 时就不可能返回 null。DoesNotReturn
: 指定一个方法是不可能返回的。DoesNotReturnIf
: 在方法的输入参数上指定一个条件,当这个参数传入了指定的 true/false 时方法不可能返回。
想必有了这些描述后,你在具体遇到问题的时候应该能知道选用那个特性。但单单看到这些特性的时候你可能不一定知道什么情况下会用得着,于是我可以为你举一些典型的例子。
输入:AllowNull
设想一下你需要写一个属性:
1
2
3
4
5
public string Text
{
get => GetValue() ?? "";
set => SetValue(value ?? "");
}
当你获取这个属性的值的时候,你一定不会获取到 null
,因为我们在 get
里面指定了非 null
的默认值。然而我是允许你设置 null
到这个属性的,因为我处理好了 null
的情况。
于是,请为这个属性加上 AllowNull
。这样,获取此属性的时候会得到非 null
的值,而设置的时候却可以设置成 null
。
1
2
3
4
5
6
++ [AllowNull]
public string Text
{
get => GetValue() ?? "";
set => SetValue(value ?? "");
}
输入:DisallowNull
与以上场景相反的一个场景:
1
2
3
4
5
6
7
private string? _text;
public string? Text
{
get => _text;
set => _text = value ?? throw new ArgumentNullException(nameof(value), "不允许将这个值设置为 null");
}
当你获取这个属性的时候,这个属性可能还没有初始化,于是我们获取到 null
。然而我却并不允许你将这个属性赋值为 null
,因为这是个不合理的值。
于是,请为这个属性加上 DisallowNull
。这样,获取此属性的时候会得到可能为 null
的值,而设置的时候却不允许为 null
。
输出:MaybeNull
如果你有尝试过迁移代码到可空类型,基本上一定会遇到泛型方法的迁移问题:
1
2
3
public T Find<T>(int index)
{
}
比如以上这个方法,找到了就返回找到的值,找不到就返回 T
的默认值。那么问题来了,T
没有指定这是值类型还是引用类型。
如果 T
是引用类型,那么默认值 default(T)
就会引入 null
。但是泛型 T
并没有写成 T?
,因此它是不可为 null
的。然而值类型和引用类型的 T?
代表的是不同的含义。这种矛盾应该怎么办?
这个时候,请给返回值标记 MaybeNull
:
1
2
3
4
++ [return: MaybeNull]
public T Find<T>(int index)
{
}
这表示此方法应该返回一个不可为 null
的类型,但在某些情况下可能会返回 null
。
实际上这样的写法并没有从本质上解决掉泛型 T
的问题,不过可以用来给旧项目迁移时用来兼容 API 使用。
如果你可以不用考虑 API 的兼容性,那么可以使用新的泛型契约 where T : notnull
。
1
2
3
public T Find<T>(int index) where T : notnull
{
}
输出:NotNull
设想你有一个方法,方法参数是可以传入 null
的:
1
2
3
public void EnsureInitialized(ref string? text)
{
}
然而这个方法的语义是确保此字段初始化。于是可以传入 null
但不会返回 null
的。这个时候请标记 NotNull
:
1
2
3
4
-- public void EnsureInitialized(ref string? text)
++ public void EnsureInitialized([NotNull] ref string? text)
{
}
NotNullWhen
, MaybeNullWhen
string.IsNullOrEmpty
的实现就使用到了 NotNullWhen
:
1
bool IsNullOrEmpty([NotNullWhen(false)] string? value);
它表示当返回 false
的时候,value
参数是不可为 null
的。
这样,你在这个方法返回的 false
判断分支里面,是不需要对变量进行判空的。
当然,更典型的还有 TryDo 模式。比如下面是 Version
类的 TryParse
:
1
bool TryParse(string? input, [NotNullWhen(true)] out Version? result)
当返回 true
的时候,result
一定不为 null
。
NotNullIfNotNull
典型的情况比如指定默认值:
1
2
3
4
[return: NotNullIfNotNull("defaultValue")]
public string? GetValue(string key, string? defaultValue)
{
}
这段代码里面,如果指定的默认值(defaultValue
)是 null
那么返回值也就是 null
;而如果指定的默认值是非 null
,那么返回值也就不可为 null
了。
在早期 .NET Framework 或者早期版本的 .NET Core 中使用
在本文第一小节里面,我们说 Nullable
是编译到目标程序集中的,所以不需要引用什么特别的程序集就能够使用到可空引用的特性。
那么上面这些特性呢?它们并没有编译到目标程序集中怎么办?
实际上,你只需要有一个命名空间、名字和实现都相同的类型就够了。你可以写一个放到你自己的程序集中,也可以把这些类型写到一个自己公共的库中,然后引用它。当然,你也可以用我已经写好的 NuGet 包 Walterlv.NullableAttributes。
Walterlv.NullableAttributes
微软 .NET 官方的可空特性在这里:
我将其注释翻译成中文之后,也写了一份在这里:
如果你想简单一点,可以直接引用我的 NuGet 包:
- 作为 dll 引用:NuGet Gallery - Walterlv.NullableAttributes
- 作为源代码包引用:NuGet Gallery - Walterlv.NullableAttributes.Source
源代码包可以在不用引入其他 dll 依赖的情况下完成引用。最终你输出的程序集是不带对此包的依赖的,详见:
参考资料
本文会经常更新,请阅读原文: https://blog.walterlv.com/post/csharp-nullable-analysis-attributes.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected]) 。