4.6 关于null的编程
第3章提到,null值非常重要,但它也带来了一些挑战,比如:通过引用型变量调用一个对象的方法之前,需要确保该变量的值不为null。此外,有时为一个值为null的变量设置一个恰当的有效值也不是一件容易的事。
除了可以使用相等操作符甚至关系操作符来检查null值之外,还有很多其他方式可以让程序很好地处理null值情形。比如,C# 7.0中被加强的is操作符就比相等操作符更适合检查null值,此外,空合并操作符(包括C# 8.0中的空合并操作符赋值语句)、空条件操作符也可以帮助处理null值。当编译器不知道一个变量的值是否为null,但是程序员相信其值不为null时,还可以用空包容操作符告诉编译器该变量不会为null。下面我们先从简单的判断null值开始介绍相关知识。
4.6.1 检查null值
下面的表4.4介绍了多种可以检查null值的方法。
表4.4 检查null值的方法
检查null值的方法很多,自然会带来一个疑问:使用哪一个方法更好?在C# 6.0以及更早,相等操作符、不等性操作符,以及is object是仅有的选择。C# 7.0加强了is操作符后,也可以用is null来检测。事实上使用is null更好,因为它的行为不会被更改,因此不用担心程序的效率问题。从C# 8.0开始,也可以用is {}来代替is object,只不过它有些不够直观。
总之,在C# 7.0之后,推荐使用is object或者is null,而在C# 7.0之前,如果能确保没有重载相等操作符,则推荐使用==null来判断null值,因为这个语法清晰易懂,如果不确定是否有重载,则使用Object.ReferenceEquals(<target>, null)来判断会更加稳妥。
上面4.4中的第2行和第3行所涉及的模式匹配操作符将在第7章进行更详细的介绍。
4.6.2 空合并操作符与空合并赋值操作符
空合并操作符??能简单地表示“如果这个值为空,就使用另一个值”,其形式如下:
??操作符支持短路求值。如expression1不为null,就返回expression1的值,另一个表达式不求值。如expression1求值为null,就返回expression2的值。和条件操作符不同,空合并操作符是二元操作符。
代码清单4.36是使用空合并操作符的例子。
代码清单4.36 空合并操作符
如果GetFileName()方法返回null,空合并操作符便会将fullName设置为"default.txt"。否则,fullName被设置为GetFileName()方法返回的值。
空合并操作符能完美“链接”。例如,对于表达式x ?? y ?? z,x不为null将返回x;x为null且y不为null将返回y;否则返回z。也就是说,从左向右选出第一个非null表达式。之前所有表达式都为null,就选择最后一个。在代码清单4.36中,对变量directory的赋值便是空合并操作符链接特性的应用示例。
C# 8.0引入了空合并赋值操作符,可以简单地理解为:如果等于号左侧的变量不为null,则维持其原值不变,否则将用等于号右侧表达式的值对等于号左侧的变量进行赋值。代码清单4.36中,对变量fullName的赋值使用了空合并赋值操作符。
4.6.3 空条件操作符
由于调用成员前经常要检查变量的值是否为null,因此C# 6.0引入了?.操作符,称为空条件操作符,如代码清单4.37所示。
代码清单4.37 空条件操作符
调用方法或属性(Length)前,空条件操作符检查操作数(第一个segments)是否为null。segments?.Length逻辑上等价于以下代码(虽然在C# 6.0语法中segments只求值一次):
关于空条件操作符,有非常重要的一点需要注意:它产生的运算结果永远是可空类型。在前面的例子中,即使string.Length是一个不可空的int类型值,但当我们通过可空操作符来访问Length属性时,得到的将是一个可空的int值(即int?)。
空条件操作符也可以用于访问数组。例如通过segments?[0]将在segments不为null的前提下获得数组的首元素。通过空条件操作符访问数组的情形并不多见,因为能够这样做的前提是:你不确定数组变量是否不为null,但确切地知道数组的元素数量,或者至少知道将要访问的元素是存在的。
空条件操作符最方便之处在于可“链接”(使用或者不使用空合并操作符均可)。例如,在下面的示例代码中,ToLower()和StartsWith()都只有在segments以及segments[0]均不为null的前提下才会调用:
上面代码假设segments数组中的元素有可能为null。因此,segments数组应该像下面这样来声明(假设使用C# 8.0):
上面代码将segments数组及其元素均声明为可空。
空条件表达式链接起来后,如第一个操作数为null,表达式求值会被短路,调用链中不再发生其他调用。也可以在整个表达式的末尾追加空合并操作符,这样一来,如果前面的表达式运算的结果为null,则可以自动获得默认值:
与空条件操作符不同的是,空合并操作符未必返回可空的值。在上面的代码中,如果空合并操作符右侧的值不为空,而整个表达式恰好返回了这个值,则此时返回的是字符串字面量"intellitech.com",显然是一个不可空的值。
此外,注意不要遗漏额外的空条件操作符。例如,假定(只是假定)ToLower()也返回null会发生什么?这样在调用StartsWith()时仍会抛出NullReferenceException异常。但这并不是说一定要使用一个空条件操作符链,而是说应关注程序逻辑。本例由于ToLower()永远不为空,所以无须额外的空条件操作符。
虽然有点怪(和其他操作符行为相比),但只在调用链最后才生成可空值类型的值。结果是在Length上调用点(.)操作符只允许调用int(而非int?)的成员。但将segments?.Length放到圆括号中(从而强制先求值int?)就可以在int?的返回值上调用Nullable<T>类型的特殊成员(HasValue和Value)了。
4.6.4 空包容操作符
你可能注意到了,在代码清单4.37中,调用Join()方法时使用了一个叹号:
这段代码先检查了length变量是否为有效值。根据代码中的逻辑,如果length不为null并且不等于0,则说明segments数组也不为null并且有大于0个元素,所以此时可以安全地调用Join()方法。
然而,编译器有能力做出同样的判断。但是,由于Join()方法要求第二个参数不为空,因此如果直接将未赋值的可空型变量segments作为参数,则会产生编译器警告。在C# 8.0中可以使用空包容操作符(!)来避免该警告。该操作符告诉编译器程序员可以保证某个变量一定不为null值,从而在编译时,编译器会相信程序员的保证而不再产生警告信息。(但是在程序执行的时候,运行时库仍然会检查null值。)
不幸的是,上面代码中的例子存在一定的风险,因为前面length那一行中的空条件操作符给人一种安全的错觉,认为segments数组不为空,则数组里面的元素也一定存在。但实际上即便在一个非空的数组中,也可以存在值为null的元素。
高级主题:空条件操作符应用于委托
空条件操作符本身已是极好的功能。它和委托调用配合,更是解决了自C# 1.0版本以来的一个大问题。注意下例代码清单4.38中,先将PropertyChange事件处理程序赋给一个局部拷贝(propertyChanged),再执行空检查,非空则引发事件。这是以最简单的线程安全的方式来调用事件,可防范在空检查和引发事件之间发生的事件被取消的风险。但该模式并不直观,经常会有开发者不遵守。结果就是抛出让人摸不着头脑的NullReferenceException。幸好,C# 6.0的空条件操作符解决了该问题。现在,委托值的空检查从以下代码:
代码清单4.38 空条件操作符在事件处理程序中的应用
变成了以下更优雅的代码:
事件即委托,所以通过空条件操作符和Invoke()来调用委托也完全可行。