3.4 类之间的关系
考虑这些对象的类之间的相似和不同——花、雏菊、红玫瑰、黄玫瑰、花瓣和瓢虫,我们可以得到下面的结论。
■ 雏菊是一种花。
■ 玫瑰是(另)一种花。
■ 红玫瑰和黄玫瑰都是一种玫瑰。
■ 花瓣是两种花的组成部分。
■ 瓢虫会吃掉蚜虫等害虫,这些害虫会侵扰某些种类的花。
从这个简单的例子中我们可以得出结论,类像对象一样,也不是孤立存在的。对于一个特定的问题域,一些关键的抽象通常与各种有趣的方式联系在一起,形成了我们设计的类结构[21]。
出于某些原因,我们在两个类之间建立起关系。首先,一种类关系可能表明某种类型的共享。例如,雏菊和玫瑰都是花,这意味着它们都有色彩鲜艳的花瓣,散发出芳香。其次,一种类关系可能表明某种语义上的联系。因此,我们说红玫瑰和黄玫瑰的相似度要大于雏菊和玫瑰,雏菊和玫瑰的关系比花瓣和花的关系更密切。类似地,瓢虫和花之间存在一种共生关系:瓢虫保护花免遭害虫侵袭,花为瓢虫提供了食物来源。
总的来说,存在三种基本类型的类关系[22]。第一种关系是一般/特殊关系,表示“是一种”关系。例如,玫瑰是一种花,这意味着玫瑰是一种特殊的子类,而花是更一般的类。第二种关系是整体/部分,表示“组成部分”关系。因此,花瓣不是一种花,它是花的一个部分。第三种关系是关联,表示某种语义上的依赖关系,如果没有这层关系,这些类就毫无关系了,如瓢虫和花之间的关系。再如,玫瑰和蜡烛基本上是独立的类,但它们都是可以用来装饰餐桌的东西。
3.4.1 关联
在这些不同类型的类关系中,关联是最常见的,也是语义上最弱的。确定类之间的关联通常是分析和早期设计的活动。随着继续设计和实现,我们常常会细化这些较弱的关联,将它们变成某种更具体的类关系。
1.语义上的依赖关系
如示例3-4所示,关联只代表一种语义上的依赖关系,它不表示这种依赖关系的方向(如果没有特别说明,关联意味着双向导航,如我们的例子所示),也不表示一个类与另一个类相关的具体形式(我们只能通过命名每个类在关系中扮演的角色来暗示这些语义)。但是,这些语义在分析问题时已经足够了,这时只需要确定这样的依赖关系。通过创建关联,用语义关系、它们的角色和它们的基数记录了参与关联的类。
示例3-4
对于一种交通工具来说,两个关键抽象是交通工具和轮子。如图3-7所示,可以在这两个类之间显示一种简单的关联:Wheel类和Vehicle类。(也许聚合关系更好。)这个关联隐含表明一种双向导航。对于一个Wheel的实例,应该能够定位代表它的Vehicle的对象;对于一个Vehicle的实例,应该能够定位所有的轮子。
图3-7 关联
这里展示了一对多的关联:每个Wheel实例与一个Vehicle有关,每个Vehicle实例可能与多个Wheel有关(用*号表示)。
2.多重性
我们的例子引入了一种一对多的关联,这意味着对于每个Vehicle的实例,可能有0个(例如,船是一种交通工具,但没有轮子)或多个Wheel类的实例,对于每个Wheel,只有一个Vehicle。这说明了一种关联的多重性。在实践中,关联有以下三种常见的多重性。
■ 一对一
■ 一对多
■ 多对多
一对一的关系代表了一种非常有局限的关系。例如,在零售电话营销操作中,我们会发现Sale类和CreditCardTransaction类之间存在一对一的关系。每次销售都对应着一次信用卡事务,每次信用卡事务都对应一次销售。多对多的关系也很常见。例如,Customer类的每个实例都可能与几个SalesPerson类的实例发起一次交易,每个销售人员都可能与许多不同的客户打交道。第5章将进一步讨论,这三种常见的多重性存在的一些变化情况。
3.4.2 继承
在这些具体的关系之中,继承也许是语义上最有趣的,它代表了一般/特殊关系。但是根据我们的经验,给定问题域的关键抽象之间存在着丰富的关系,继承对于表示所有这些关系是不够的。继承的一种替代方法是一种所谓“委托”的语言机制,通过这种机制,对象将它们的行为委托给一些相关的对象。
示例3-5
在空间探测器被发射之后,它们向地面站发回重要子系统状态(如电力和推进子系统)以及不同传感器(如辐射传感器、质谱仪、摄像头、小陨石碰撞检测器等)的信息。这些被传回的信息被称为遥测数据。遥测数据一般以位流的方式被传输,它有一个头部,其中包含时间戳和信息类型的标识符,然后是几帧来自不同子系统和传感器的、经过处理的数据。这似乎就是几种不同数据的聚合。
这些重要的数据需要封装,否则没有办法防止客户修改像timestamp(时间戳)或currentPower(当前电能)这样的重要数据的值。类似地,这些数据的表示方式是已知的,所以,如果要改变这种表示方式(如加入一些新元素或改变原有的位对齐方式),所有客户都会受到影响。至少,我们需要重新编译所有对这个结构的引用。更重要的是,这样的修改可能会打破客户对这种表示形式所做的假定,导致程序逻辑受到破坏。
最后,假定我们的系统分析表明需要几百种不同类型的遥测数据,既包括前面提到的电子数据,也包括系统各处不同测试点的电压读数。我们会发现,声明这些额外的结构导致了大量的冗余,既重复了结构,也重复了共同的功能。
一种较好的做法是为每种遥测数据声明一个类。通过这种方式,可以将每个类的表示形式隐藏起来,将它的行为与它的数据关联起来。但是,这种方式仍然不能解决冗余的问题。
因此,更好的做法是构建一个类层次结构来反映我们的决策,在这个层次结构中,特殊化的类从一般化的类中继承结构和行为,如图3-8所示。
子类可以继承其超类的结构和行为
图3-8 ElectricalData(用电数据)从超类TelemetryData(遥测数据)中继承
对于ElectricalData类来说,它继承了TelemetryData类的结构和行为,但加上了它的结构(额外的电压数据),重新定义了它的行为(transmit函数)来传输额外的数据,甚至可以添加它的行为(currentPower函数,提供当前电能水平)。
1.单继承
简单来说,继承是类之间的一种关系,在这种关系中,一个类共享了另一个类(单继承)或多个类(多继承)中定义的结构和行为。我们把提供给其他类继承的类称为“超类”。在示例3-5中,TelemetryData是ElectricalData的超类。类似地,我们把从其他类继承的类称为“子类”。ElectricalData是TelemetryData的子类。所以,继承在类之间定义了“是一种”关系,在这种关系中,子类从一个或多个超类中继承。这实际上是继承的判别测试。给定类A和类B,如果A不是一种B,那么A就不应该是B的子类。从这个意义上说,ElectricalData是一种特殊类型的TelemetryData。语言是否支持这种继承,决定了它是基于对象的语言还是面向对象的语言。
子类通常扩展或限制了超类中原有的结构和行为。扩展超类的子类被称为扩展继承。例如,子类GuardedQueue可能提供额外的操作,使这个类在多控制线程的情况下变得安全,从而扩展了超类Queue的行为。与此相对的是,限制超类的子类被称为限制继承。例如,子类UnselectableDisplayItem可能限制了超类DisplayItem的行为,禁止客户从视图中选择它的实例。在实践中,子类扩展了超类还是限制了超类并不总是很清楚。实际上,子类常常同时做这两件事。
图3-9展示了从超类TelemetryData中导出的单继承关系,每条有向线段代表一个“是一种”关系。例如,CameraData“是一种”SensorData,SensorData又“是一种”TelemetryData。
这与语义网中的层次结构是等价的,语义网是认知科学和人工智能的研究者组织关于世界的知识的一种工具[25]。实际上,正如将在第4章中讨论的,在不同抽象之间设计一种合适的继承层次结构基本上是一种明智的分类工作。
图3-9 单继承
我们预计图3-9中的某些类会有实例,而另一些类会没有实例。例如,我们预计每一种最特殊的类(也被称为“叶子类”或“具体类”)都会有一些实例,如ElectricalData和SpectrometerData。但是对于中间的、更一般的类来说,可能没有任何实例,如SensorData或TelemetryData。没有实例的类被称为“抽象类”。抽象类在编写时就预期它的子类会添加结构和行为,通常是完成它未完成的方法实现。
在继承和封装之间存在着非常真实的压力。从大的方面来讲,利用继承暴露了被继承类的一些秘密。具体来说,这意味着要理解某个类的含义,就必须常常研究它的所有超类,有时还要研究超类的内部视图。
继承意味着子类继承了超类的结构。因此,在示例3-5中,ElectricalData类的实例包含了超类的数据成员(如id和timestamp),以及特殊类的数据成员(如fuelCell1Voltage、fuelCell2Voltage、fuelCell1Amperes和fuelCell2Amperes)。
子类也从超类中继承行为。因此,ElectricalData类的实例可以执行currentTime操作(从它的超类继承)、currentPower操作(该类自己定义)及transmit操作(子类重新定义)。
2.多态
对于TelemetryData类来说,成员函数transmit可能传送遥测数据流的标识符和它的时间戳。但是ElectricalData类的同一个函数可能调用TelemetryData的transmit函数并传送它的电压和当前的值。
这种行为是由多态引起的。在泛化中,这样的操作被称为“多态的”。多态是类型理论中的一个概念,即一个名字可能代表许多不同类的实例,只要它们都有共同的超类。于是,由这个名字所代表的对象就能够以不同的方式对同一组操作做出反应。利用多态,一个操作可以被层次结构中的类以不同的方式实现。通过这种方式,子类可以扩展超类的能力,或者覆写父类的操作,就像示例3-5中ElectricalData所做的那样。
多态的概念首先是由Strachey提出的[29],他谈到了一种初级的多态,指出像+这样的操作符可以被定义成含义不同的东西。我们将这个概念称为“重载”。在C++中,开发者可以用同样的名字来定义函数,只要它们的调用可以通过函数签名来区别。函数签名由参数的个数和类型构成(C++与Ada不一样,在重载解析时不考虑函数的返回值类型)。与此不同的是,Java不允许重载操作符。Strachey也提到了参数多态,今天我们就称之为多态。
没有多态,开发者写的代码就必须包含大量的case或switch语句。[6]没有多态,我们就不能为各种遥测数据创建一个类层次结构,而不得不定义一个一体化的可变记录来包含所有与这类数据相关的属性。为了区分每一种变种,必须检查与该记录关联的标记。
要加入另一种遥测数据,必须修改这个可变记录,将它加到操作这条记录的每一个case语句中。这很容易出错,也增加了设计的不稳定性。
有了继承,就不需要一体化的记录,因为可以区分不同类型的抽象。Kaplan和Johnson指出:“如果许多类使用相同的协议,多态就最有用”[30]。利用多态,我们就不需要大型的case语句,因为每个对象都知道自己的类型。
没有多态的继承是可能的,但它肯定用处不大。
多态和延迟绑定是分不开的。在出现多态时,方法和名字的绑定要在执行时确定。在C++中,开发者可以控制成员函数使用早期绑定或延迟绑定。具体来说,如果将方法声明为virtual的,那就使用延迟绑定,这个函数就被认为是多态的;如果没有virtual声明,那么这个函数就使用早期绑定,可以在编译时解析。Java不需要显式的virtual声明就执行延迟绑定。补充材料“调用一个方法”描述了一种实现是怎样选择某一个执行方法的。
调用一个方法
在传统的编程语言中,调用一个子程序完全是一种静态的活动。例如在Pascal中,如果一条语句调用了子程序p,编译器通常会生成代码,创建一个新的调用栈,将正确的参数放入栈中,然后改变控制流,开始执行子程序P相关的代码。但是,在支持某种形式的多态的语言中,如Smalltalk和C++,调用一个操作可能需要动态的活动,因为被操作对象的类可能要到运行时刻才能知道。如果我们加入继承,事情就更有趣了。在有继承而没有多态的情况下,调用一个操作的语义基本上和简单的静态子程序调用是一样的,但在有多态的情况下,则必须使用更为复杂的技术。
考虑图3-10中的类层次结构,它展示了基类DisplayItem和3个子类,分别是Circle、Triangle和Rectangle。Rectangle也有一个子类,名为SolidRectangle。在类DisplayItem中,假定定义了实例变量theCenter(表示该显示项中心的坐标)以及下面的操作,作为我们早期的例子。
■ draw:画出该项。
■ move:移动该项。
■ location:返回该项的位置。
图3-10 DisplayItem类图
location操作对于所有子类都是相同的,因此不需要重新定义,但我们预计draw操作和move操作会被重新定义,因为只有子类才知道如何画出和移动它们自己。
Circle类必须包含实例变量theRadius和相应的操作来存取它的值。对于这个子类,重新定义的操作draw会画出以theCenter为圆心给定半径的圆。类似地,Rectangle类必须包含实例变量theHeight和theWidth以及相应的操作来存取它们的值。对于这个子类,重新定义的操作draw会根据给定的高和宽画出矩形(也是以theCenter为中心)。子类SolidRectangle继承了Rectangle类的所有属性,但又重新定义了draw操作的行为。具体来说,SolidRectangle类的draw实现首先调用了超类Rectangle中定义的draw(画出矩形的外框),然后再填充这个矩形。调用draw操作要求实现多态行为。
现在,假定我们有一些客户对象希望画出所有的子类。在这种情况下,编译器不能够静态地生成代码来调用正确的draw操作,因为要到运行时刻才能知道被调用对象的类。让我们来考虑不同的面向对象语言是如何处理这种情况的。
因为Smalltalk是一种无类型的语言,所以方法选择是完全动态的。当客户向列表中的一项发出draw消息时,发生的事情如下:
■ 该对象从它的类消息字典中查找该消息;
■ 如果找到该消息,本地定义的方法代码就被调用;
■ 如果找不到该消息,就继续在超类中查找该方法。
这个过程会沿着超类层次结构向上,直至找到该消息,或者直至到达最顶端的基类Object时也没找到该消息。在后一种情况下,Smalltalk最终将发出doesNotUnderstand消息,表示出错。
这个算法的关键是消息字典,它是每个类的表示形式中的一部分,因此对于用户是不可见的。这个字典是在类创建时被创建的,包含了这个类的实例可以响应的所有方法。查找这个方法是耗时的,与简单的子程序调用相比,在Smalltalk中查找方法要花1.5倍的时间。所有产品品质的Smalltalk实现都通过提供缓冲的消息字典来优化方法选择,所以一般传递的消息可以实现快速的调用。缓冲通常能改进20%~30%的性能[31]。
子类SolidRectangle中定义的draw操作产生了一种特殊情况。我们说它的draw实现首先调用了超类Rectangle中定义的draw操作。在Smalltalk中,用关键字super来指定一个超类方法。然后,当把draw消息传递给super时,Smalltalk就会用前面提到的同样的方法选择算法,只是查找从这个对象的超类开始,而不是从它的类开始。
Deutsch的研究表明,多态在85%的情况下是不需要的,所以消息传递常常可以退化为简单的过程调用[32]。Duff指出,在这种情况下,开发者常常隐含假定允许对象类的早期绑定[33]。遗憾的是,像Smalltalk这样的无类型的语言没有方便的方式可以告诉编译器这些隐含的假定。
像C++这样的更强类型的语言确实让开发者声明这些信息。因为我们希望在可能的情况下避免方法选择,但又必须仍然允许多态选择的情况,所以在这些语言中调用一个方法与在Smalltalk中有些不同。
在C++中,开发者可以将某个操作声明为virtual,从而决定它是延迟绑定的。所有其他的方法都被认为是早期绑定的,因此编译器可以将方法调用静态解析为简单的子程序调用。
为了处理虚成员函数,大部分C++实现都使用了vtable的概念。在对象创建时(也就是当对象的类被确定时),每个需要多态选择的对象都会定义一个vtable。这个表通常包含一个虚函数指针列表。例如,我们创建了Rectangle类的一个对象,那么vtable中就有一项是为虚函数draw准备的,它指向最近的draw实现。例如,类DisplayItem包含了虚函数Rotate,它在Rectangle类中没有重定义,那么表Rotate的vtable表项就会指向DisplayItem类中的Rotate实现。通过这种方式,运行时刻的查找就省去了:对一个对象的虚成员函数的引用只是通过相应指针的一次间接引用,可以不必查找就立即调用到正确的代码[34]。
3.多继承
在单继承中,每个子类都只有一个超类。但是,Vlissides和Linton指出,虽然单继承非常有用,“但这常常迫使程序员从两个差不多有吸引力的类中选择一个来继承。这限制了预定义的类的实用性,常常需要重复代码。例如,没有办法派生出既是一个圆也是一张图画的图形,程序员必须从一个类派生,然后重新实现另一个类中的功能”[40]。
考虑人们如何组织不同的资产,如存款账户、房地产、股票和债券。存款账户和支票账户通常都是由银行管理的账户,所以我们可以将它们分类为银行账户,而银行账户是一种资产。股票和债券与银行账户的管理非常不同,所以我们可以将股票、债券、共同基金等分类为有价证券,而有价证券也是一种资产。
但是,还存在许多同样令人满意的方法,可以对存款账户、房地产、股票和债券进行分类。例如,在某些情况下,区分出房地产和某些银行账户(在美国,可以根据一些限制条件由联邦存款保险公司保险)这样的可保险资产是有用的。另外,区分出那些返回股息或利息的资产也是有用的,如存款账户、支票账户以及某些股票和债券。
遗憾的是,单继承的表达能力不足以刻画这样的网状结构,所以我们必须借助多继承。[7]图3-11展示了这样的类结构。这里我们看到,Security(有价证券)类是一种Asset(资产),也是一种InterestBearingItem(利息产生项);类似地,BankAccount(银行账户)是一种Asset,同时又是一种InsurableItem(可保险项)和一种InterestBearingItem。
设计一个合适的、涉及继承(特别是多继承)的类结构是一项困难的任务。这通常是一个增量和迭代的过程。当我们采用多继承时,会遇到两个问题:如何处理来自不同超类的名字冲突,以及如何处理重复的继承。
当两个或多个不同的超类对接口中的某些元素使用同样的名字时,就可能发生名字冲突,譬如同名的实例变量和方法。例如,假定InsurableItem类和Asset类都有一个名为presentValue的属性,表示该项资产的现值。由于RealEstate继承自这两个类,那么继承两个同样名字的操作是什么意思呢?这是多继承的关键困难:名字冲突可能导致多继承的子类的二义性行为。
有三种基本方法可用来解决这种冲突。首先,编程语言的语义可能认为这样的冲突是非法的,拒绝编译这样的类。其次,编程语言的语义可能认为不同类引入的相同名字指的是相同的属性。第三,编程语言的语义可能允许这种冲突,但需要所有对这个名字的引用都有完整的限定符,表明声明它的位置。
第二个问题是重复的继承,Meyer是这样描述这个问题的:“由于多继承而引起的一个微妙的问题就是,如果一个类通过多个途径成为另一个类的祖先,那会发生什么情况。如果语言允许多继承,那么迟早会有人写出D类有两个父类B和C,而B和C又都以A作为父类……或者其他的情况,使得D从A继承了两次(或更多)。这种情况被称为重复继承,必须正确地处理。”[41]例如,假定我们将MutualFund类(没有深思熟虑地)定义为Stock和Bond类的子类。这个类引入了对Security类的重复继承,Secirity类既是Stock类的超类,也是Bond类的超类(参见图3-11)。
有几种不同的方法可以用来处理重复继承的问题。首先,可以将重复继承视为非法。其次,可以允许超类的重复,但要求使用完整的限定名来引用成员的具体拷贝。第三,可以将对同一个类的多次引用视为代表相同的类。不同的语言使用不同的方法来处理这个问题。
图3-11 多继承
多重继承的存在促使了所谓的“混入类(mixin)”的出现。混入类来自于Flavors语言的编程文化:开发者可以组合(混入)小类来构建更复杂的类。“混入类在语法上等同于一个正常的类,但它的目的是不同的。这种类的目的只是……向其他的flavor(类)添加功能,开发者永远也不能创建混入类的实例。”[44]在图3-11中,InsurableItem和InterestBearingItem都是混入类。这些类都不能单独存在,它们被用于增强其他类的意义。因此,可以将混入类定义为一种包含单一、集中行为的类,通过继承,用于增强其他类的行为。混入的行为通常与被混入类的行为是完全正交的。如果一个类主要是通过继承从混入类派生而来,没有它自己的结构或行为,那么它就被称为聚合类(aggregate class)。
3.4.3 聚合
我们也需要聚合关系,它提供了类实例中的整体/部分关系。类之间的聚合关系与这些类的对象之间的聚合关系是并存的。
如图3-12所示,TemperatureController类代表整体,Heater类是它的部分之一。这完全对应于图3-6中这些类的实例之间的聚合关系。
图3-12 聚合
物理包容
在TemperatureController类的例子中,我们看到的是按值包容的聚合。这是一种物理包容,意味着Heater对象不会独立于包含它的TemperatureController而存在。这两个对象的生存期是紧密联系在一起的:当创建一个TemperatureController实例时,也会创建Heater类的实例;当销毁TemperatureController对象时,意味着也会销毁对应的Heater对象。
还有一种不这么直接的聚合,被称为“组合(composition)”,它是按引用包容的。在这种情况下,TemperatureController类仍然代表整体,Heater类的实例仍然是它的一部分,虽然这一部分现在必须间接地访问。因此,这两个对象的生存期不像前面那么联系紧密,我们可以独立地创建和销毁每个类的实例。
聚合确定了整体部分的方向。例如,Heater对象是TemperatureController对象的一部分,反之则不然。当然,正如在前面的例子中提到的,聚合不一定是物理上的包容。例如,虽然股票持有人拥有股票,但股票持有人并没有在物理上包容拥有的股票。相反,这些对象的生存期完全可以是独立的,虽然概念上的整体/部分关系仍然存在(每股股票都是股票持有者的一部分资产)。这种聚合的表示形式可以是非常间接的。
这仍是聚合,虽然它不是物理上的包容。总之,聚合的判别测试是:当且仅当两个对象之间存在整体/部分关系时,它们对应的类之间必然存在一种聚合关系。
多继承常常与聚合产生混淆。当考虑继承还是聚合时,要记得应用它们的判别测试。如果不能够肯定两个类之间存在“是一种”的关系,那么应该使用聚合或其他的关系来代替继承。
3.4.4 依赖关系
除了继承、聚合和关联之外,还有一类关系,被称为“依赖关系”。依赖关系表明,处于这种关系一端的元素以某种方式依赖于处于另一端的元素。这警告了设计者,如果其中一个元素发生了改变,可能会影响到另一个元素。存在许多种不同的依赖关系(完整列表请参见对象管理组织最新的UML规范[45])。常常会在架构模型(一个系统组件或包依赖于另一个组件或包)或实现层(一个模块依赖于另一个模块)中看到依赖关系。