C# 8.0本质论
上QQ阅读APP看书,第一时间看更新

3.2 将变量声明为可空

将一个变量的值设置为空往往非常实用。例如,当指定一个数量值时,如果数量未知或者用户未提供数值,那么应该输入什么呢?一个可能的办法是指定特殊值,比如-1或int.MaxValue,但这些毕竟都是有效的整数值,有时很难分辨一个特殊值意味着真实数值或是无效值。因此,更好的解决方案是允许将变量赋值为null,以便区分真实数值和无效值。此外,null值对于数据库编程来说尤为重要,因为很多数据库都允许字段的值为null。如果不允许将变量赋值为null,则在读取数据库记录时便会产生很多问题。

你可以将类型声明为可空或不可空,这意味着可以使用可空修饰符将类型声明为允许或不允许空值。(从C# 2.0开始允许将值类型变量声明为可空,从C# 8.0开始,引用类型变量也可以声明为可空。)为声明一个能被赋值为null的变量,要使用可空修饰符:?。例如,int? number=null将声明一个可空的int型变量,并将其值初始化为null。需要注意的是,使用可空变量时也存在一些陷阱,需要开发者更加小心谨慎。

3.2.1 对null值引用类型变量进行解引用

支持将变量赋值为null是一件好坏参半的事:这样做本来非常有意义,可惜其缺点也不容忽视。虽然将null赋值给一个变量,或者作为参数去调用一个方法并不会直接产生问题,但是如果对一个值为null的引用类型变量进行解引用(例如调用其方法),则会引发System.NullReferenceException异常——例如,调用text.GetType(),当text值为null时,该异常便会发生。在产品级的代码中如果发生了System.NullReferenceException异常,则是一个无可否认的bug,因为这个异常通常意味着程序员在调用方法之前忘记了检查null值。更糟糕的是,对null值的检查依赖于程序员能够意识到一个变量的值可能为null,而这种意识显然非常不可靠,因此,一个更好的方案是在默认情况下不允许将变量赋值为null,而若想要赋值为null,则必须用可空修饰符进行显式声明。这种显式声明的一个暗含的意义是:如果程序员主动声明一个变量可以被赋值为null,则他便需要对可能出现的null值担负更多的责任。

到目前为止,我们尚未讨论用于检查null值的操作符和语句。后面的“高级主题:检查null值”将会介绍一些简要方法。在第4章中将介绍更多细节。

高级主题:检查null值

判断一个变量的值是否为null的方法很多,其中最简单的便是在if语句中用is操作符来检查null值。代码清单3.1中演示了这一方法。

代码清单3.1 检查null值

在上面的代码中,if语句用is操作符判断number变量是否为null,并且根据判断结果执行不同的操作。虽然也可以使用等于操作符“==”来判断null值,但由于等于操作符可能被重写并实现不同的行为,因此判断null值最好使用is操作符。

另一个在C# 6.0中引入的用于处理null值的操作符叫作“null值条件(null-conditional)操作符”。该操作符会先判断一个变量是否为null,再对其进行解引用。例如int? length=text?.length;这句代码会先判断text变量是否为null。如果是,则将length变量也赋值为null,而不会发生异常;反之,如果text引用了有效的字符串值,则将length赋值为字符串长度。需要注意的是,由于text?.length可能为null,因此必须将length变量声明为可空。

在第4章中,我们将更加详细地介绍if语句和null值条件操作符,而关于is操作符的更多细节,则将在第7章讨论模式匹配时再介绍。

3.2.2 可空值类型

一个值类型变量存储的是一个实际的值,而不是一个引用,并且值类型变量本质上也不应该拥有null值。尽管如此,在实际中,当我们调用一个值类型变量的方法或者访问其属性时,仍然认为是在对该值类型变量进行解引用。虽然技术上不太正确,但是当人们谈论解引用时,普遍并不在意一个变量是值类型还是引用类型[1]

高级/初学者主题:对值为null的值类型变量解引用

技术上讲,一个用可空修饰符声明的值类型变量仍然是值类型,而不会变成引用型。即使当它被赋值为null时,会拥有一些与null值引用变量相同的行为,但这不是因为该变量存储了null值。因此,对一个被赋值为null的值类型变量进行解引用时,大部分情况下不会发生null值异常[2]。值类型变量的方法和属性,比如HasValue、ToString(),甚至与等值判断相关的GetHashCode()和Equals()等,都是基于模板类Nullable<T>实现的,因此不会因为值类型变量为null而发生异常。(当对一个被赋值为null的值类型变量进行解引用时,不会发生System.NullReferenceException异常,但是可能发生System.InvalidOperationException异常,以便提醒程序员去检查null值。)唯一的例外是如果对一个值为null的值类型变量调用GetType()方法,仍然会发生System.NullReferenceException异常。这是因为它不是虚方法,因此无法被Nullable<T>重载,因此保留了默认行为,即发生System.NullReferenceException异常[3]

3.2.3 可空引用类型

在C# 8.0之前,所有引用类型变量都可以被赋值为null。但这一规则导致了大量bug的产生。这是因为避免null值异常需要程序员能够预见到一个变量可能为null,从而在程序中编写保护性代码,但实际中这种预见性很难做到万无一失。此外,引用类型变量默认为可空,且其初始值默认为null也使得这一问题变得更糟。例如,在代码清单3.2中有一个名为text的引用型局部变量,其值尚未初始化,如果此时对其进行解引用,编译器会报告错误“use of unassigned local variable 'text'”(使用未赋值的局部变量'text')。为了解决这个编译错误,最简单的办法是在声明变量时将其初始化为null,而不是为其寻找一个更合理的值。但这样一来,程序员便有可能掉进陷阱中:为了简单地解决编译错误,声明一个变量并将其初始化为null,并且寄希望于该变量被真正使用之前能够被幸运地设置一个有效的值,然而这种期待有可能会落空。

代码清单3.2 对未赋值的变量进行解引用

总之,引用型变量默认可以被赋值为空,是造成System.NullReferenceException异常的罪魁祸首。而编译器的赋值检查则很容易将程序员引入歧途,除非他们特别小心谨慎地编程才能躲避陷阱。

为了显著地改善这种情况,C#团队在C# 8.0中将可空性概念同样赋予了引用类型变量,即所谓的可空引用类型。至此,引用类型变量和值类型变量都可以声明时被指定为可空或者不可空。在C# 8.0中,声明任何类型的变量时,默认都为不可空。

不幸的是,支持使用可空修饰符声明引用类型,并将不使用空修饰符的引用类型声明默认为不可空,这对从早期版本的C#升级的代码有重大影响。考虑到C# 7.0和更早版本支持将null赋值给所有引用类型声明(即string text=null),所有代码都会在C# 8.0中编译失败吗?

确保兼容旧代码对于C#团队来说非常重要,因此C#在默认情况下并不支持引用类型的可空性特性。要想启用此特性,需要使用#nullable语句,或者在项目属性配置中启用该特性。

首先,可以在程序代码中使用下面语句来启用引用类型的可空性特性:

该语句在#nullable后面输入三个可选值:enable、disable和restore。restore的作用是将可空性设置恢复为项目全局设置的值。前面的代码清单3.2演示了使用#nullable语句来启用该特性的例子。正是该语句使得编译器不再会因为string? Text;语句而发出警告。

启用引用类型的可空性特性的另一个方法是在项目属性中添加设置。该特性默认为不启用。如果要启用它,可以找到项目的.csproj文件,并加入代码清单3.3中的设置。

代码清单3.3 通过修改.csproj文件在项目全局范围内启用可空性特性

本书附带的全部示例代码(https://github.com/EssentialCSharp)都在项目全局范围内启用了可空性特性。也可以在dotnet命令行参数中通过/p参数设置该特性是否启用:

该命令行参数会取代项目代码中所有对可空性特性的设置。

[1] 可空值类型是C# 2.0引入的。

[2] 这里指的是前文提到的System.NullReferenceException异常。——译者注

[3] 通常只有一种解引用情形可能会发生System.InvalidOperationException异常。假设有一个可空值类型变量被赋值为null,例如,int? x=null;,此时如果将它显示转换为一个不可空的值类型,例如,int y=(int)x;,此时C#会试图对变量x进行解引用,并发生上述异常。——译者注