1.6 具有多态性的可互换对象

当处理类型层次结构时,程序员常常希望不把对象看做是某一特殊类型的成员,而是想把它看做是其基本类型的成员,这样就允许程序员编写不依赖于特殊类型的程序代码。在形体的例子中,函数可以对一般形体进行操作,而不关心它们是圆形、正方形还是三角形。所有的形体都能被绘制、擦除和移动,所以这些函数能简单地发送消息给一个形体对象,而不考虑这个对象如何处理这个消息。

这样,程序代码不受增添新类型的影响,而且增添新类型是扩展面向对象程序来处理新情况最普通的方法。例如,可以派生出形体的一个新的子类型,称为五边形,而不必修改那些处理一般形体的函数。通过派生新的子类型,可以很容易扩展程序,这个能力很重要,因为这会在降低软件维护费用的同时,极大地改善软件设计。

然而,如果试图把派生类型的对象看做是它们所属的基本类型(圆形看做形体,自行车看做车辆,鸬鹚看做鸟),这里就有一个问题:如果一个函数告诉一个一般的形体去绘制它自己,或者告诉一个一般的车辆去行驶,或者告诉一只一般的鸟去飞翔,则编译器在编译时就不能确切地知道应当执行哪段代码。同样的问题是,消息发送时,程序员并不想知道将执行哪段代码。绘图函数能等同地应用于圆形、正方形或三角形,对象根据它的特殊类型来执行合适的代码。如果增加一个新的子类型,不用修改函数调用,它就可以执行不同的代码。编译器不能确切地知道执行哪段代码,那么它应该怎么办呢?例如,在下图中,BirdController对象只是与一般的Bird对象交互,并不知道它们到底是什么类型。这对于BirdController是方便的,因为不需要编写专门的代码来确定它正在对哪种Bird工作以及它有什么样的行为。但当忽略专门的Bird类型而调用move()时,将发生什么事情呢?会出现正确的行为吗?(Goose是跑、是飞、还是游泳?Penguin是跑、还是游泳?)

1.6 具有多态性的可互换对象 - 图1

在面向对象的程序设计中,答案是非常新奇的:编译器并不做传统意义上的函数调用。由非OOP编译器产生的函数调用会导致与被调用代码的早捆绑(early binding),对于这一术语,读者可能还没有听说过,因为从来没有想到过它。早捆绑的意思是,编译器会对特定的函数名产生调用,而连接器将这个调用解析为要执行代码的绝对地址。在OOP中,直到程序运行时,编译器才能确定执行代码的地址,所以,当消息被发送给一般对象时,需要采用其他的方案。

为了解决这一问题,面向对象语言采用晚捆绑(late binding)的思想。当给对象发送消息时,在程序运行时才去确定被调用的代码。编译器保证这个被调用的函数存在,并执行参数和返回值的类型检查[其中不采用这种处理方式的语言称为弱类型(weakly typed)语言],但是它并不知道将执行的确切代码。

为了执行晚捆绑,C++编译器在真正调用的地方插入一段特殊的代码。通过使用存放在对象自身中的信息,这段代码在运行时计算被调用函数函数体的地址(这一过程将在第15章中详细介绍)。这样,每个对象就能根据这段二进制代码的内容有不同的行为。当一个对象接收到消息时,它根据这个消息判断应当做什么。

我们可以用关键字virtual声明他希望某个函数有晚捆绑的灵活性。我们并不需要懂得virtual使用的机制,但是没有它,我们就不能用C++进行面向对象的程序设计。在C++中,必须记住添加virtual关键字,因为根据规定,默认情况下成员函数不能动态捆绑。virtual函数(虚函数)可用来表示出在相同家族中的类具有不同的行为。这些不同是产生多态行为的原因。

考虑形体的例子,在本章的前面画出过类的家族(所有基于统一接口的类)。为了论述多态性,我们希望编写一段简单的代码,只涉及基类,不涉及类型的具体细节。这段代码是从特定类型信息中分离出来的,编写起来比较简单,理解起来也比较容易。如果有一个新的类型(例如Hexagon)通过继承添加进来,那么所写的这段代码将会适用于“Shape”的这个新类型,就像在已经存在的类型上使用一样。这样,程序就是可扩展的(extensible)。

如果用C++编写一个函数(很快,读者将会学习如何去写):

1.6 具有多态性的可互换对象 - 图2

这个函数与任何Shape对话,所以它独立于它正在绘制或擦除的对象的特定类型(‘&’表示“取这个对象的地址,传给doStuff()”,但是现在理解这些细节并不重要)。如果在程序的其他部分使用doStuff()函数:

1.6 具有多态性的可互换对象 - 图3

对doStuff()的调用会自动正确工作,而不管调用对象的确切类型。这真是一个令人惊讶的技术。想一想这行代码:

1.6 具有多态性的可互换对象 - 图4

这里发生的事情是将Circle传递给对Shape有效的函数。因为Circle是一个Shape,所以可以由doStuff()处理。这就是说,任何能由doStuff()发送给Shape的消息,Circle都能接收。所以它是完全安全的和符合逻辑的。

我们把处理派生类型就如同处理其基类型的过程称为向上类型转换(upcasting)。“cast”一词来自铸造领域,“up”一词来自于继承图的典型排列方式,基类型置于顶层,派生类向下层展开。这样,类型向基类型的转换是沿继承图向上移动,即“向上类型转换”。

1.6 具有多态性的可互换对象 - 图5

面向对象程序在一些地方会包含一些向上类型转换,因为这正是我们从必须了解所处理的是什么具体类型这一桎梏中解脱出来的方式。请看在doStuff()中的代码:

1.6 具有多态性的可互换对象 - 图6

注意,这可不是在说:“如果是Circle,做这件事,如果是Square,做这件事,等等”。如果写这种代码,要检查Shape所有可能的类型,那将是很糟糕的,因为每次添加一种新的Shape,都必须改变代码。这里,我们只是在说:“它是一种形体,我知道它能erase()和draw()自己,如法操作,注意细节的正确性。”

doStuff()代码最引人注意的地方是它能做正确的事情。对Circle调用draw()将执行的代码不同于对Square或Line调用draw()所执行的代码。但是当draw()的消息发送给一个匿名Shape时,正确行为的发生取决于这个Shape的实际类型。这是令人惊奇的,因为,如前所述,当C++编译器编译doStuff()代码时,它并不知道它所处理对象的准确类型。所以以此类推,似乎最终调用的是Shape的erase()和draw()的版本,而不是特殊的Circle、Square或Line的版本。然而,因为多态性,一切操作都完全正确。编译器和运行系统可以处理这些细节,我们只需要知道它会这样做和知道如何用它设计程序就行了。如果一个成员函数是virtual的,则当我们给一个对象发送消息时,这个对象将做正确的事情,即便是在有向上类型转换的情况下。