6.8 不可空引用类型属性与构造函数
下面这个编译器警告在本章中一再地被忽略和关闭:
在一个类中,如果定义了不可空引用类型的字段或者默认实现的属性[1],则在其宿主类完成实例化之前,这些字段和属性的值需要先被初始化,否则,它们只能拥有默认的null值,而这显然与“不可空”相悖。
但问题是,有时候这类字段和属性可能已经被间接地初始化了,超出了构造函数的直接作用域,因此超出了编译器代码分析的作用域,即便通过构造函数调用的方法或属性能够初始化也是如此[2]。下面是一些会出现上述问题的情形:
·在代码清单6.20中,不可空字段_LastName被属性LastName的set方法赋值。该方法在赋值之前对输入值做了是否为null或空串的简单校验。
·在代码清单6.22中,Name属性为另一个不可空的属性赋值。
·在代码清单6.32和6.33中,集中初始化方法Initialize()在构造函数中被调用。
·除上述例子之外,当类被外部的其他代理模块实例化并初始化时,如果该类有公共不可空属性,也会引发上述问题[3]。
在大部分情况下,不可空引用型字段和自动实现的不可空属性的初始化,都通过构造函数调用属性或方法间接完成。遗憾的是,C#编译器无法识别对不可空引用型字段或属性的间接赋值,即便该间接赋值发生在构造函数里。(为了方便阐述,本节中提及不可空字段或属性时,均指引用型。)
此外,所有不可空字段和属性都需要确保它们在任何时候都不会被设置成null值。对于字段来说,这需要将对它们的存取封装在相应的属性里,并在其赋值方法中对输入值进行校验。(请记住,若要使属性的赋值校验起到作用,需要遵循的设计规范是:将字段声明为私有,从而不让它被类外部代码直接访问。)基于这个方法,一个实现完整的可读写的不可空引用型属性便可以确保不会被赋值为null值。
不可为null的自动实现属性需要限制为只读封装,在实例化期间分配任何值,并在分配之前验证为非null。对于自动实现的不可空属性永远不要允许可读写方式,尤其当这种属性具有公共的赋值方法时更是如此,因为自动实现的赋值方法不会检查null值情况,从而无法避免在程序的其他地方意外地将它赋值为null。虽然可以通过在构造函数中对不可空字段进行赋值来避免编译器警告,但这样显然是不够的。该属性是读写的,因此可以在实例化后将其赋值为null,该属性便不是不可空的。
6.8.1 可读写的引用型不可空属性
代码清单6.34展示了如何既使用可读写的引用型不可空属性,又避免产生不可空属性未初始化的编译器警告。这样做的最终效果是在编译器看来该属性/字段为可空(因此不会产生警告),而在调用者看来该属性/字段不允许被设置为空。
代码清单6.34 为不可空属性提供null值检查
上面代码能够达到前述目的的原理如下:
1.字段_Name被声明为可空,因此编译器不会产生警告。
2.字段被声明为私有,因此无法直接被类的外部存取。
3.字段对应的属性Name中,赋值方法具有对null值的检查,并且会拒绝接受null值。这一点协同前一点一起确保了字段_Name不会为空。
4.取值方法中使用空包容操作符(!)声明其返回的值不会是null,而实际上其返回值也确实不会为null,这一点由赋值方法所保证。
虽然将不应该为空的字段声明为可空看起来不太正确,但是由于编译器无法识别对不可空字段的间接赋值,因此有时确实需要这样做。好在程序员可以通过字段的私有性,以及精细设计的取值、赋值方法,实现没有编译警告的可读写的不可空属性。
6.8.2 自动实现的只读引用型属性
前文提到如果一个不可空的引用型字段有自动实现的属性,则该属性应该为只读,从而避免该字段被意外设置为null值。但是即便做到了这一点,在构造函数里为该字段进行赋值时,仍然需要检查null值,如代码清单6.35所示。
代码清单6.35 在构造函数中为不可空引用型字段赋值时的null值检查
你可能会产生这样的疑问:当一个不可空的引用型字段有自动实现的属性时,是否可以让它拥有私有的赋值方法?毕竟这样做,对类的外部来说,该属性仍然是只读的。这种做法看起来可行,问题是,你能否保证在编写该类的其他部分程序时,不会意外地将该属性设置为null值?一个类从外部接收到null值的情形,不只会在构造函数中发生。事实上,这种情形随时有可能发生,但是对null值的检查却非常容易被忽略。
设计规范
·当类的不可空引用型字段需要对应的属性时,要将该字段声明为可空,并且要编写完整实现的属性方法,而不要采用自动实现的方式。在赋值方法中要做好null值检查,在取值方法中要使用空包容操作符。
·不可空的引用型字段要在构造函数内完成赋值。
·如果一定要为不可空的引用型字段使用自动实现的属性,要将属性声明为只读。
·在操作任何引用型字段或属性时,都要检查null值。
[1] 只写了get和set关键字但没有写出具体代码的属性。——译者注
[2] 或者可能通过外部代理,如反射,参见第18章。
[3] 例如,MSTest中的TestContext属性,或者那些通过依赖注入初始化的属性。