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

6.7 构造函数

现在已为类添加了用于存储数据的字段,接着应考虑数据的有效性。代码清单6.3展示了可用new操作符实例化对象。但这样可能创建包含无效数据的员工对象。实例化employee1后得到的是姓名和工资尚未初始化的Employee对象。代码清单6.3中在实例化员工之后立即对尚未初始化的字段进行赋值。但假如忘了初始化,编译器也不会发出警告。结果是得到含有无效姓名的Employee对象。(在C# 8.0中,不可空引用类型变量会触发一个编译器警告,建议使用可空引用类型,以便避免默认的null值。无论如何,在实例化一个类的对象时,一定要对其字段进行初始化,以确保字段都具有有效值。)

6.7.1 声明构造函数

为解决该问题,必须提供一种方式在创建对象时指定必要的数据。这是用构造函数来实现的,如代码清单6.26所示。

代码清单6.26 定义构造函数

定义构造函数需创建一个无返回类型的方法,方法名必须和类名完全一样。构造函数是“运行时”用来初始化对象实例的方法。本例的构造函数获取员工名字和姓氏作为参数,允许程序员在实例化Employee对象时指定这些参数的值。代码清单6.27演示了如何调用构造函数。

代码清单6.27 调用构造函数

注意new操作符返回对完成实例化的对象的一个引用(虽然在构造函数的声明或实现中没有显式指定返回类型,也没有使用返回语句)。另外已移除了名字和姓氏的初始化代码,因为现在是在构造函数内部初始化。本例由于没有在构造函数内部初始化Salary,因此对工资进行赋值的代码仍然予以保留。

开发者应注意既在声明中又在构造函数中赋值的情况。如果字段在声明时赋值(比如代码清单6.5中的string Salary="Not enough"),那么只有在这个赋值发生之后,构造函数内部的赋值才会发生。所以,最终生效的是构造函数内部的赋值,它会覆盖声明时的赋值。如果不细心,很容易就会以为对象实例化后保留的是声明时的字段值。因此,有必要考虑一种编码风格,避免同一个类中既在声明时赋值,又在构造函数中赋值。

高级主题:new操作符的实现细节

new操作符内部和构造函数是像下面这样交互的。new操作符从内存管理器获取“空白”内存,调用指定构造函数,将对“空白”内存的引用作为隐式的this参数传给构造函数。构造函数链剩余的部分开始执行,在构造函数之间传递引用。这些构造函数都没有返回类型(行为都像是返回void)。构造函数链上的执行结束后,new操作符返回内存引用。现在,该引用指向的内存处于完成初始化的形式。

6.7.2 默认构造函数

必须注意,一旦显式添加了构造函数,在Main()中实例化Employee就必须指定名字和姓氏。因此,代码清单6.28的代码无法编译。

代码清单6.28 默认构造函数不再可用

如果类没有显式定义的构造函数,C#编译器会在编译时自动添加一个。该构造函数不获取参数,称为默认构造函数。一旦为类显式添加了构造函数,C#编译器就不再自动提供默认构造函数。因此,在定义了Employee(string firstName, string lastName)之后,编译器不再添加默认构造函数Employee()。虽然可以手动添加,但会再度允许构造没有指定员工姓名的Employee对象。

没必要依赖编译器提供的默认构造函数。程序员任何时候都可显式定义默认构造函数,比如用它将某些字段初始化成特定值。无参构造函数就是默认构造函数。

6.7.3 对象初始化器

C# 3.0新增了对象初始化器[1],用于初始化对象中所有可以访问的字段和属性。具体地说,调用构造函数创建对象时,可在后面的一对大括号中添加成员初始化列表。每个成员的初始化操作都是一个赋值操作,等号左边是可以访问的字段或属性,右边是要赋的值,如代码清单6.29所示。

代码清单6.29 使用显式成员赋值调用对象初始化器

注意,使用对象初始化器时要遵守相同的构造函数规则。这实际只是一种语法糖,最终生成的CIL代码和创建对象实例后单独用语句对字段及属性进行赋值无异。C#代码中的成员初始化顺序决定了在CIL中调用构造函数后的属性和字段赋值顺序。

总之,构造函数退出时,所有属性都应初始化成合理的默认值。利用属性的赋值方法的校验逻辑,可制止将无效数据赋给属性。但偶尔一个或多个属性的值可能导致同一个对象的其他属性暂时包含无效值。这时应推迟抛出异常,直到对象实际使用这些相关属性时再决定是否抛出异常。

设计规范

·要为所有属性提供有意义的默认值,确保默认值不会造成安全漏洞或造成代码执行效率大幅下降。

·要允许属性以任意顺序设置,即使这会造成对象暂时处于无效状态。

高级主题:集合初始化器

C# 3.0还增加了集合初始化器,采用和对象初始化器相似的语法,用于在集合实例化期间向集合项赋值。它借用数组语法来初始化集合中的每一项。例如,为初始化Employee列表,可在构造函数调用之后的一对大括号中指定每一项,如代码清单6.30所示。

代码清单6.30 调用集合初始化器

像这样为新集合实例赋值,编译器生成的代码会按顺序实例化每个对象,并通过Add()方法把它们添加到集合。

高级主题:终结器

构造函数定义了在类的实例化过程中发生的事情。为定义在对象销毁过程中发生的事情,C#提供了终结器。和C++的析构器不同,终结器不是在对一个对象的所有引用都消失后马上运行。相反,终结器是在对象被判定“不可到达”之后的不确定时间内执行。具体地说,垃圾回收器会在一次垃圾回收过程中识别出带有终结器的对象。但不是立即回收这些对象,而是将它们添加到一个终结队列中。一个独立的线程遍历终结队列中的每一个对象,调用其终结器,然后将其从队列中删除,使其再次可供垃圾回收器处理。第10章深入讨论了这个过程以及资源清理的主题。

6.7.4 重载构造函数

构造函数可以重载。只要参数数量和类型有区别,可同时存在多个构造函数。例如,如代码清单6.31所示,可提供一个构造函数,除了获取员工姓名还获取员工ID,或者只获取员工ID。

代码清单6.31 重载构造函数

这样当Program.Main()根据姓名来实例化员工对象时,既可只传递员工ID,也可同时传递姓名和ID。例如,在创建新员工时调用同时获取姓名和ID的构造函数,而从文件或数据库加载现有员工时调用只获取ID的构造函数。

和方法重载一样,多个构造函数使用少量参数支持简单情况,使用附加的参数支持复杂情况。应优先使用可选参数而不是重载,以便在API中清楚地看出“默认”属性的默认值。例如,构造函数签名Person(string firstName, string lastName, int? age=null)清楚指明如果Person的年龄未指定就默认为null。

注意,从C# 7.0开始支持构造函数的表达式主体成员实现,例如,

在本例中,我们通过设置Id属性来间接地为FirstName和LastName成员赋值。不幸的是,编译器无法检测到这种间接赋值,因此认为这两个成员未赋值,并且从C# 8.0开始发出警告,建议将这两个成员声明为可空。事实上,因为我们明确知道已经为它们赋值,所以将该警告关闭。

设计规范

·如果构造函数的参数只是用于设置属性,那么构造函数参数(camelCase)要使用和属性(PascalCase)相同的名称,区别仅仅是首字母的大小写。

·要为构造函数提供可选参数,并且提供便利的重载构造函数,用好的默认值初始化属性。

·要允许以任何顺序设置属性,即使这会导致暂时无效的对象状态。

6.7.5 构造函数链:使用this调用另一个构造函数

注意,代码清单6.31对Employee对象进行初始化的代码在好几个地方重复,所以必须在多个地方维护。虽然本例代码量较小,但完全可以从一个构造函数中调用另一个构造函数,以避免重复输入代码。这称为构造函数链,用构造函数初始化器实现。构造函数初始化器会在执行当前构造函数的实现之前,判断要调用另外哪一个构造函数,如代码清单6.32所示。

代码清单6.32 从一个构造函数中调用另一个

针对相同对象实例,为了从一个构造函数中调用同一个类的另一个构造函数,C#语法在一个冒号后添加this关键字,再添加被调用构造函数的参数列表。本例是获取三个参数的构造函数调用获取两个参数的构造函数。但通常采取相反的调用模式——参数最少的构造函数调用参数最多的构造函数,为未知参数传递默认值。

初学者主题:集中初始化

如代码清单6.32所示,在Employee(int id)构造函数的实现中不能调用this(firstName, lastName),因为该构造函数没有firstName和lastName这两个参数。要将所有初始化代码都集中到一个方法中,必须创建单独的方法,如代码清单6.33所示。

代码清单6.33 提供初始化方法

本例是将方法命名为Initialize(),它同时获取员工的名字、姓氏和ID。注意,仍然可以从一个构造函数中调用另一个构造函数,就像代码清单6.32展示的那样。

通过Id属性来设置FirstName和LastName可以避免触发编译器警告,与之类似,通过Initialize方法来赋值也可以做到这一点。因此在上面示例中不会有警告产生。

[1] object initializer有时也称为“对象初始化列表”。——译者注