C# 8.0 的可空引用类型,不止是加个问号哦!你还有很多种不同的可空玩法

C# 8.0 引入了可空引用类型,你可以通过 ? 为字段、属性、方法参数、返回值等添加是否可为 null 的特性。

但是如果你真的在把你原有的旧项目迁移到可空类型的时候,你就会发现情况远比你想象当中复杂,因为你写的代码可能只在部分情况下可空,部分情况下不可空;或者传入空时才可为空,传入非空时则不可为空。


C# 8.0 可空特性

在开始迁移你的项目之前,你可能需要了解如何开启项目的可空类型支持:

可空引用类型是 C# 8.0 带来的新特性。

你可能会好奇,C# 语言的可空特性为什么在编译成类库之后,依然可以被引用它的程序集识别。也许你可以理解为有什么特性 Attribute 标记了字段、属性、方法参数、返回值的可空特性,于是可空特性就被编译到程序集中了。

确实,可空特性是通过 NullableAttributeNullableContextAttribute 这两个特性标记的。

但你是否好奇,即使在古老的 .NET Framework 4.5 或者 .NET Standard 2.0 中开发的时候,你也可以编译出支持可空信息的程序集出来。这些古老的框架中没有这些新出来的类型,为什么也可以携带类型的可空特性呢?

实际上反编译一下编译出来的程序集就能立刻看到结果了。

看下图,在早期版本的 .NET 框架中,可空特性实际上是被编译到程序集里面,作为 internalAttribute 类型了。

反编译

所以,放心使用可空类型吧!旧版本的框架也是可以用的。

更灵活控制的可空特性

阻碍你将老项目迁移到可空类型的原因,可能还有你原来代码逻辑的问题。因为有些情况下你无法完完全全将类型迁移到可空。

例如:

  1. 有些时候你不得不为非空的类型赋值为 null 或者获取可空类型时你能确保此时一定不为 null(待会儿我会解释到底是什么情况);
  2. 一个方法,可能这种情况下返回的是 null 那种情况下返回的是非 null
  3. 可能调用者传入 null 的时候才返回 null,传入非 null 的时候返回非 null

为了解决这些情况,C# 8.0 还同时引入了下面这些 Attribute

想必有了这些描述后,你在具体遇到问题的时候应该能知道选用那个特性。但单单看到这些特性的时候你可能不一定知道什么情况下会用得着,于是我可以为你举一些典型的例子。

输入:AllowNull

设想一下你需要写一个属性:

public string Text
{
    get => GetValue() ?? "";
    set => SetValue(value ?? "");
}

当你获取这个属性的值的时候,你一定不会获取到 null,因为我们在 get 里面指定了非 null 的默认值。然而我是允许你设置 null 到这个属性的,因为我处理好了 null 的情况。

于是,请为这个属性加上 AllowNull。这样,获取此属性的时候会得到非 null 的值,而设置的时候却可以设置成 null

++  [AllowNull]
    public string Text
    {
        get => GetValue() ?? "";
        set => SetValue(value ?? "");
    }

输入:DisallowNull

与以上场景相反的一个场景:

private string? _text;

public string? Text
{
    get => _text;
    set => _text = value ?? throw new ArgumentNullException(nameof(value), "不允许将这个值设置为 null");
}

当你获取这个属性的时候,这个属性可能还没有初始化,于是我们获取到 null。然而我却并不允许你将这个属性赋值为 null,因为这是个不合理的值。

于是,请为这个属性加上 DisallowNull。这样,获取此属性的时候会得到可能为 null 的值,而设置的时候却不允许为 null

输出:MaybeNull

如果你有尝试过迁移代码到可空类型,基本上一定会遇到泛型方法的迁移问题:

public T Find<T>(int index)
{
}

比如以上这个方法,找到了就返回找到的值,找不到就返回 T 的默认值。那么问题来了,T 没有指定这是值类型还是引用类型。

如果 T 是引用类型,那么默认值 default(T) 就会引入 null。但是泛型 T 并没有写成 T?,因此它是不可为 null 的。然而值类型和引用类型的 T? 代表的是不同的含义。这种矛盾应该怎么办?

这个时候,请给返回值标记 MaybeNull

++  [return: MaybeNull]
    public T Find<T>(int index)
    {
    }

这表示此方法应该返回一个不可为 null 的类型,但在某些情况下可能会返回 null

实际上这样的写法并没有从本质上解决掉泛型 T 的问题,不过可以用来给旧项目迁移时用来兼容 API 使用。

如果你可以不用考虑 API 的兼容性,那么可以使用新的泛型契约 where T : notnull

public T Find<T>(int index) where T : notnull
{
}

输出:NotNull

设想你有一个方法,方法参数是可以传入 null 的:

public void EnsureInitialized(ref string? text)
{
}

然而这个方法的语义是确保此字段初始化。于是可以传入 null 但不会返回 null 的。这个时候请标记 NotNull

--  public void EnsureInitialized(ref string? text)
++  public void EnsureInitialized([NotNull] ref string? text)
    {
    }

NotNullWhen, MaybeNullWhen

string.IsNullOrEmpty 的实现就使用到了 NotNullWhen

bool IsNullOrEmpty([NotNullWhen(false)] string? value);

它表示当返回 false 的时候,value 参数是不可为 null 的。

这样,你在这个方法返回的 false 判断分支里面,是不需要对变量进行判空的。

当然,更典型的还有 TryDo 模式。比如下面是 Version 类的 TryParse

bool TryParse(string? input, [NotNullWhen(true)] out Version? result)

当返回 true 的时候,result 一定不为 null

NotNullIfNotNull

典型的情况比如指定默认值:

[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 依赖的情况下完成引用。最终你输出的程序集是不带对此包的依赖的,详见:


参考资料

blog footer

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/csharp-nullable-analysis-attributes.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,或者前往 CSDN 关注我的主页

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 (walter.lv@qq.com)