本节书摘来自华章出版社《面向对象的思考过程(原书第4版)》一书中的第3章,第3.1节,[美] 马特·魏斯费尔德(Matt Weisfeld) 著黄博文 译更多章节内容可以访问云栖社区“华章计算机”公众号查看。
第3章
高级的面向对象概念
第1章和第2章讲述了面向对象的基本概念。在开始学习关于构建面向对象系统的一些具体设计问题之前,我们需要更进一步了解面向对象的一些概念,比如构造函数、操作符重载以及多重继承。我们也会讲述错误处理技术以及面向对象的设计中作用域的重要性。
其中一些概念可能对深入理解面向对象设计并不是必需的,但设计和实现整个面向对象系统的人有必要了解。
3.1 构造函数
构造函数对于结构化编程的程序员来说是个新概念。非面向对象的语言(比如COBOL、C和Basic)通常不会用到构造函数。C/C++中的结构体(struct)具有构造函数。前两章提及过这个用于构造对象的特殊方法。在诸如Java和C#之类的面向对象的语言中,构造函数名称与类名相同。而Visual Basic .NET使用关键字New,Objective-C使用init关键字。这里我们只关注于构造函数的概念,而不会介绍所有语言的特殊语法。接下来用Java代码来实现一个构造函数。
例如,第2章中Cabbie类的构造函数如下所示:
编译器会意识到这个方法名与类名完全相同,所以认为该方法是个构造函数。
小心
注意,Java代码(以及C#和C++)中,构造函数没有返回值。如果有返回值,编译器就不认为该方法是构造函数。
例如,如果类中有以下代码,那么编译器不会认为该方法是构造函数,因为它有返回值,这个返回值是一个整数:
该语法会导致问题,因为虽然这份代码可以通过编译但得不到期望的行为。
3.1.1 什么是构造函数调用
当创建新对象时,首要事情之一是调用构造函数。请看以下代码:
new关键字创建了Cabbie类的一个新实例,这会按需分配内存。然后会调用构造函数自身,并且可以通过参数列表传递参数。开发人员可以在构造函数内进行相应的初始化
工作。
因此,new Cabbie()代码将实例化一个Cabbie对象,并调用Cabbie方法,即该类的构造函数。
3.1.2 构造函数中包含什么
构造函数最重要的功能大概是当遇到new关键字时初始化内存分配。总之,构造函数中的代码会把新创建的对象初始化到稳定、安全的状态。
例如,如果有一个计数器(counter)对象,里面有个属性叫count,你需要在构造函数中将count设置为0:
初始化属性
在结构化编程中,名为housekeeping(管家)或initialization(初始化)的例程往往用于初始化目的。初始化属性是构造函数经常执行的功能。
3.1.3 默认构造函数
如果编写了一个不包含构造函数的类,这个类仍然可以通过编译,你也可以使用它。如果没有为类提供一个显式的构造函数,那么类会有一个默认构造函数。请记住,无论你是否自定义了构造函数,类始终至少有一个构造函数。如果你没有提供构造函数,系统会为你提供一个默认的构造函数。
除了创建对象本身之外,默认构造函数的另一个行为是调用父类的构造函数。大多数情况下,父类是语言框架的一部分,比如Java中的Object类。例如,如果没有为Cabbie类提供构造函数,系统会提供下面默认的构造函数:
如果反编译编译器生成的字节码,你会看到这段代码。这段代码实际上是由编译器插
入的。
在本例中,如果Cabbie没有显式继承自其他类,Object类将会是它的父类。默认构造函数在有些场景下是适用的。然而,大多数场景下,需要自定义初始化一系列内存。不管在什么情况下,在类中始终包含至少一个构造函数是一个优秀的实践。如果类有属性,最好始终在构造函数中初始化这些属性。延伸开来,无论是否在编写面向对象的代码,初始化变量总是一个优秀的实践。
提供构造函数
通用规则是即使并不需要在构造函数中做任何事情,也应当始终提供一个构造函数。你可以提供一个不包含任何代码的构造函数,稍后再按需添加代码。尽管使用编译器默认提供的构造函数在技术上没有任何问题,但基于文档化和维护目的,这样更容易看懂你的代码。
这里考虑维护问题并不奇怪。如果你使用的是默认的构造函数,后续操作添加了另一个构造函数,那么系统不会再创建默认的构造函数。总之,只有类中没有包含任何构造函数时,系统才会添加默认的构造函数。一旦你提供了一个构造函数,系统就不再提供默认的构造函数。
3.1.4 使用多个构造函数
大多数情况下,可以用多种方式创建对象。这需要提供多个构造函数。例如,请看Count类:
一方面,可以初始化属性count为0,实现这一点很简单,可以在一个构造函数中初始化count为0,如下所示:
另一方面,可以传递一个初始化参数,从而可以设置count为其他数字:
这叫作重载方法(重载适用于所有方法,不止是构造函数)。大部分的面向对象语言都提供了重载方法的功能。
1.?重载方法
重载可以让程序员重复使用相同的方法名,只要每次方法签名不同即可。方法签名包含了方法名以及参数列表(如图3-1所示)。
所以,以下所有方法拥有不同的签名:
方法签名可能包含返回值类型,也可能不包含返回值类型,这取决于不同的语言。在Java和C#中,返回值类型并不属于签名的一部分。例如,以下代码即使返回值类型不同,也不能通过编译:
了解签名最好的方式是编写一些代码然后进行编译。
通过使用不同的签名,你可以根据不同的构造函数来构造对象。如果你不能保证每次都能掌握足够的信息,那么很适合这种方式。例如,当创建一个购物车时,顾客可能已经登录了自己的账号(你会得到顾客所有的信息)。而一个全新的顾客可能会向购物车中放入产品,但没有任何账号信息,这样的情况下构造函数初始化方式是不同的。
2.?使用UML对类建模
我们再回头看第2章中用到的数据库阅读器例子。构造数据库阅读器有两种方式:
传入数据库名称以及设置游标在数据库中的起始位置。
传入数据库名称以及设置游标在数据库中的期望位置。
图3-2展示了DataBaseReader类的类图。注意,该图列出了此类的两个构造函数。尽管该图显示了两个构造函数,但并未包含参数列表,所以无法区分出这两个构造函数。为了区分这两个构造函数,可以查看下面列出的DataBase-Reader类的对应代码。
无返回值类型
注意,在该类图中,构造函数没有返回值类型。除了构造函数之外,其他所有方法必须要有返回值类型。
以下代码片段展示了该类的构造函数,以及构造函数如何初始化属性(见图3-3):
请注意在两个场景中如何初始化startPosition。如果没有通过参数列表为构造函数提供位置信息,那么startPostion会被初始化为默认值,即0。
3.?如何构造父类
当使用继承时,你必须知道如何构造父类。请记住,当使用继承时,也继承了父类的所有东西。因此必须熟悉父类的数据和行为。任何继承的属性都是完全可见的。然而,对构造函数的继承则是不可见的。如果遇到了new关键字,那么会分配对象,并发生以下步骤(见图3-4):
1)在构造函数中会调用父类的构造函数。如果没有显式调用父类的构造函数,那么系统会默认自动调用;不过可以在字节码中看到这段代码。
2)对象中的所有属性会被初始化。这些属性是类中定义中的属性(实例变量),不是构造函数或其他方法中的属性(局部变量)。在DataBaseReader代码中,整数start-Position是类的实例变量。
3)执行构造函数中的其余代码。
3.1.5 设计构造函数
我们已经看到了,设计类的一个最佳实践是初始化所有属性。有些语言中,编译器会提供一部分初始化工作。与往常一样,不要依赖编译器来初始化属性!在Java中,只有属性被初始化后你才能使用它。如果属性在代码中很靠前,请确保你初始化属性为一些有效值,比如设置整数为0。
构造函数用来确保应用程序处于稳定的状态(我喜欢称之为“安全”的状态)。例如,如果把属性作为除法运算中的分母,那么初始化该属性为0会导致应用程序崩溃。你必须考虑除法中用0作为除数是非法操作。始终初始化属性为0并不总是最好的方式。
在设计时,优秀的实践应该是为所有属性识别一个稳定的状态,然后在构造函数中初始化这些属性为稳定的状态。