C# 可为空引用类型

可为空引用类型?什么,没看错吧?难道不是所有引用类型都可为空吗? 

我对 C# 钟爱有加,我认为它严谨的语言设计非常棒。尽管如此,就目前而言,即使在 C# 版本 7 发布后,此语言也仍称不上完美。我这里指的是,尽管有理由期望 C# 会一直不断添加新功能,但遗憾的是,同时也存在着一些问题。.

请注意,我所指的问题不是 bug,而是根本问题。自 C# 1.0 发布以来,一直存在的最大问题区域之一也许就是引用类型能否为空。实际上,引用类型默认为空。导致可为空引用类型不尽理想的一些原因在于:

  • 对空值调用成员会导致 System.NullReferenceException 异常抛出,导致生产代码抛出 System.NullReferenceException 的所有调用都算是 bug。不过,遗憾的是,对于可为空引用类型,我们“失败了”,将精力放在了错误的事情上,而不是正确的事情上。“失败”操作是指未检查是否为空就调用了引用类型。

  • (引入 Nullable<T> 后)引用类型和值类型出现不一致,具体体现在使用“?”修饰的值类型(例如,int? 数字)可为空,而默认情况下值类型不可为空。相比之下,引用类型默认可为空。对于像我们这样长期使用 C# 编程的人来说,这很“正常”。不过,如果我们能够将一切推到重来,还是希望引用类型默认不可为空,并通过添加“?”显式允许为空。

  • 无法运行静态流分析,进而也就无法检查所有路径是否有空值(若为空,取消引用它)。例如,检查是否有非托管代码调用、多线程或基于运行时条件的空分配/替换。(更不用说分析是否能够检查所有已调用的库 API。)

  • 没有合理语法可用于指明引用类型空值对特定声明无效。

  • 无法将参数修饰为不允许为空。

我已经说过,尽管如此,我也仍钟爱 C#,所以我直接将可为空行为看作是 C# 的特性接受了。不过,在 C# 8.0 中,C# 语言团队正开始着手改进此问题。具体来说,他们希望做到以下几点:

  • 提供指明应使用空值的语法:让开发人员能够明确确定引用类型何时应包含空值,这样就不会在显式分配空值时看到任何标记。

  • 将引用类型设为默认不可为空:将所有引用类型都设为默认不可为空,但实现这一点时,应使用可选择启用的编译器开关,而不是突然对开发人员的现有代码发出大量警告,让人应接不暇。

  • 减少 NullReferenceException 抛出:降低 NullReferenceException 异常抛出的可能性,具体是通过改进静态流分析,标记出可能存在问题的情况,即调用值成员之一前未显式检查值是否为空。

  • 启用静态流分析警告抑制:支持某种形式的“相信我,我是程序员”声明,方便开发人员重写编译器的静态流分析,从而抑制任何可能的 NullReferenceException 警告。

在本文的剩余部分中,将逐一介绍这些目标,以及 C# 8.0 如何在 C# 语言中实现对它们的基本支持。

提供指明应使用空值的语法

首先,需要有语法可区分何时引用类型应为空,何时不应为空。允许为空的语法明显就是使用 ? 作为可为空声明,这对值类型和引用类型都适用。借助引用类型支持,可方便开发人员选择启用空值,例如:

string? text = null;

通过新增的此语法,就会明白为什么关键的可为空改进是通过看似令人困惑的名称“可为空引用类型”进行概括。 这不是因为新增了一些可为空引用数据类型,而是现在开始支持显式选择启用所述数据类型。

提供了可为空引用类型语法,不可为空引用类型语法又如何呢?  虽然下面的语法:

string! text = "Inigo Montoya"

似乎是不错的选择,但这又引入了一个问题,即下面的语法表示什么意思:

string text = GetText();

提供了三种声明?分别是可为空引用类型、不可为空引用类型,以及具体含义我也不知道的引用类型?呃,不是这样的!!

相反,我们真正需要的是:

  • 可为空引用类型:string? text = null;

  • 不可为空引用类型:string text = "Inigo Montoya"

当然,这意味着重大语言变化,即没有修饰符的引用类型默认不可为空。

将引用类型设为默认不可为空

将标准引用声明(无可为空修饰符)切换为不可为空,也许是减少可为空特性的所有要求中最难实现的一个。目前的实际情况是,字符串 text; 会生成 text 引用类型,它不仅允许文本为空,还要求文本应为空,实际上文本在许多情况下(如在字段或数组中)都默认为空。

不过,与值类型一样,允许为空的引用类型应被看作是例外情况,而不是默认情况。最好是在向文本分配空值或只能将文本初始化为空值时,编译器标记要取消引用的任何文本变量(编译器已在初始化前就标记出要取消引用的局部变量)。

遗憾的是,这意味着重大语言变化,并在分配空值(如 string text = null)或分配可为空引用类型(如 string? text = null; string moreText = text;)时发出警告。其中第一个 (string text = null) 就是重大变化。(对以前不发出警告的事件发出警告就是重大变化。)  

为了避免开发人员在开始使用 C# 8.0 编译器时就收到大量让人应接不暇的警告,为空性支持改为默认处于禁用状态,因而不会有任何重大变化。因此,若要利用此支持,必须选择启用相应功能。(不过,请注意,截至本文撰写之时,为空性在预览阶段默认处于启用状态 (itl.tc/csnrtp)。)

当然,一旦启用此功能,警告就会出现,提示用户选择相应操作。请明确选择是否允许引用类型为空。如果不允许,请删除分配的空值,警告也会随之消失。不过,这样一来,用户稍后可能会看到警告,因为变量未分配值,需要为它分配非空值。或者,如果应明确使用空值(例如,表示“未知”),请将声明类型更改为可为空,如下所示:

string? text = null;

减少 NullReferenceException 抛出

支持将类型声明为可为空或不可为空后,至于确定声明是否可能违反规定,现在就取决于编译器静态流分析的选择。尽管可以将引用类型声明为可为空,或避免向不可为空类型分配空值,但稍后代码中也可能会出现新的警告或错误。

如前所述,如果从未向局部变量分配过值(在 C# 8.0 推出前局部变量就是这样的情况),那么不可为空引用类型稍后就会导致代码出错。相比之下,如果检测不到对空值和/或向非空值分配任何可为空值的预检查,静态流分析就会标记要取消引用调用的任何可为空类型。图 1 列举了几个示例。

图 1:静态流分析结果示例

string text1 = null;

// Warning: Cannot convert null to non-nullable reference

string? text2 = null;

string text3 = text2;

// Warning: Possible null reference assignment

Console.WriteLine( text2.Length ); 

// Warning: Possible dereference of a null reference

if(text2 != null) { Console.WriteLine( text2.Length); }

// Allowed given check for null

无论采用上述哪种方式,通过使用静态流分析来验证可为空意图,最终都会减少潜在 NullReferenceException 抛出。

正如前面所述,静态流分析应该标记不可为空类型可能分配有空值(直接分配空值或分配可为空类型)的情况。遗憾的是,这有时也会出问题。例如,如果某方法声明返回不可为空引用类型(可能是尚未使用为空性修饰符进行更新的库)或错误返回空值(可能是警告被忽略),或抛出非致命异常且未执行预期分配,那么不可为空引用类型最终仍可能会分配有空值。

这很遗憾,但支持可为空引用类型应该会降低 NullReferenceException 抛出可能性,尽管不是完全杜绝。(这类同于分配变量时的编译器检查易错性。)

同样,静态流分析有时也会无法识别下面这种情况:代码实际上在取消引用某值前确实检查了是否有空值。流分析其实只检查局部变量和参数的方法主体的为空性,并利用方法和运算符签名来确定有效性。

例如,它不会深入研究 IsNullOrEmpty 方法主体,进而也不会分析此方法是否已成功执行为空性检查(如果已执行,就无需额外执行其他为空性检查)。

启用静态流分析警告抑制

鉴于静态流分析的易错性,如果编译器无法识别为空性检查(可能是通过 object.ReferenceEquals(s, null) 或 string.IsNullOrEmpty() 调用执行),该怎么办?如果程序员更清楚值不会为空,可以在 ! 运算符(例如,text!)后面取消引用,如下所示:

string? text;...

if(object.ReferenceEquals(text, null))

{  var type = text!.GetType()

}

如果没有感叹号,编译器会警告可能存在的空调用。同样,如果向不可为空值分配可为空值,可以使用感叹号修饰所分配的值,以告知编译器你作为程序员更清楚:

string moreText = text!;

这样一来,可以重写静态流分析,就像可以使用显式强制转换一样。当然,在运行时,仍会进行相应验证。

总结

引入引用类型的为空性修饰符不是引入新类型。引用类型仍可为空,并且编译 string? 仍在 IL 中生成 System.String。IL 级差异在于,使用以下属性修饰可为空已修改类型:

System.Runtime.CompilerServices.NullableAttribute

这样一来,下游编译可以继续利用已声明的意图。此外,在该属性可用的前提下,旧版 C# 仍可以引用 C# 8.0 编译库,尽管没有任何为空性改进。最重要的是,这意味着,现有 API(如 .NET API)能够使用可为空元数据进行更新,而不破坏 API。此外,这还意味着,不支持根据为空性修饰符进行重载。

遗憾的是,在 C# 8.0 中改进空引用类型处理有一个非常不幸的后果。将向来可为空声明转换为不可为空声明一开始会引入大量警告。虽然这很遗憾,但我相信开发人员已在恼怒和改进自己代码之间取得合理平衡:

  • 警告删除向不可为空类型分配的空值可能会消除 bug,因为值不再是禁止的空值。

  • 也能添加可为空修饰符,更明确表达意图,从而改进代码。

  • 久而久之,更新后的可为空代码和旧代码之间的阻抗不匹配将会消失,同时减少了过去常常出现的 NullReferenceException bug。

  • 在现有项目中,为空性功能默认处于禁用状态,因此可以延迟处理,直到决定选择启用它。最后,代码将会变得更加可靠。如果你比编译器更清楚,可以使用 ! 运算符(声明“相信我,我是程序员”),就像使用强制转换一样。

C# 8.0 中的其他增强功能

C# 8.0 正考虑改进另外三个主要区域:

异步流:借助异步流支持,await 语法可以迭代一组任务 (Task<bool>)。例如,可以调用:

foreach await (var data in asyncStream)

线程不会屏蔽 await 后面的任何语句,而是在迭代完成后“继续”处理它们。迭代器会根据请求(请求是对可枚举流的迭代器调用 Task<bool> MoveNextAsync)暂停下一项,然后调用 T Current { get; }。

默认接口实现:使用 C#,可以实现多个接口。这样一来,每个接口的签名都是继承而来。此外,还可以在基类中提供成员实现,这样所有派生类就都有默认成员实现。

遗憾的是,无法实现多个接口并提供默认接口实现(即多重继承)。通过引入默认接口实现,我们克服了这项限制。假设合理默认实现可行,通过 C# 8.0,可以添加默认成员实现(仅属性和方法),且实现接口的所有类都会有默认实现。尽管多重继承可能会产生不良影响,但这真正改进的是,能够使用其他成员扩展接口,而不会引入重大 API 变化。

例如,可以将 Count 方法添加到 IEnumerator<T>(尽管实现它需要迭代集合中的所有项),而不会中断实现此接口的所有类。请注意,必须有相应的框架版本,才能使用此功能(自 C# 2.0 和通用支持发布起就没有此要求)。

扩展渗透到方方面面:LINQ 引入了扩展方法。我记得曾经和 Anders Hejlsberg 共进晚餐,并且咨询了其他扩展类型(如属性)。Hejlsberg 先生告诉我,团队仅在考虑对 LINQ 实现必需的扩展。

现在,10 年过去了,这个假设正在重新接受评估,他们正在考虑扩大扩展方法的添加范围,不仅要对属性添加,还要对事件、运算符和可能的构造函数添加(后者拉起了一些有趣工厂模式实现的帷幕)。

需要注意的一点是(尤其是在属性方面),扩展方法是在静态类中实现,因此引入的扩展类型没有任何附加实例状态。如果需要此类状态,必须在按扩展类型实例编制索引的集合中存储它,才能检索相关状态。