[Herb Sutter 的名作More Exceptional C++中文版即将出版。作为本书译者,我很高兴将本书推荐给大家。征得华中科技大学出版社同意,我将公开部分译稿,敬请大家批评指正。]
继承与多态
不来点继承和多态,面向对象将会怎样?
尽管继承常被滥用,但它还是一种很重要的工具——这包括多继承。特别是,当你生活在现实世界中时,你会发现,你经常需要将不同供货商提供的程序库结合起来使用,此时,多继承便凸显它的价值。本章向你展示,在结合使用不同供货商提供的“基于继承”的程序库时,应当如何避免连体双婴(Siamese Twin)问题。此外,本章还示范了许多合理(以及一些不合理地)使用纯虚函数的方式,以及如何编写多继承的替代方案、如何对使用继承关系的用户施加控制。
条款24:为什么要使用多继承?
难度:6
一些语言,包括SQL99标准,还在为“是否应该支持单继承或多继承”的问题大伤脑筋。本条款邀请您讨论这一主题。
1. 什么是多继承(MI,即multiple inheritance)?在C++中引入MI带来了哪些额外的可能性和复杂性?
2. MI究竟有必要吗?如果必要,尽可能多地列举出它的使用场合,并论证为什么应该将MI加入到语言中;如果不必要,请论证为什么单继承(SI)(并且,可能结合Java风格的接口)可以取代多继承、甚至比它更出色,以及为什么不应该将MI加入到语言中。
解答
1. 什么是多继承(MI,即multiple inheritance)?在C++中引入MI带来了哪些额外的可能性和复杂性?
非常简要地回答:MI指的是从多个(多于一个)直接基类(direct class)继承的能力。
例如:
class Derived : public Base1, private Base2
{
//...
};
在C++中引入MI所带来的可能性是:一个类的同一个(直接或间接)基类(base class)可能会不只一次地作为它的基础类(ancestor)出现。这里有一个简单的例子,即那个经典的钻石形状的继承图,如图4所示。
这里,B两次作为D的间接基类(indirect class)出现,一次是通过C1,另一次则是通过C2。
这种情况下,就很有必要引入C++的另一个特性:虚拟继承。现在的问题是:程序员希望D拥有基类B的一个子对象还是两个?如果答案是一个,B就应该是一个虚拟基类,图4就成为了可怕的死亡钻石。如果答案是两个,B就应该是一个普通(非虚拟)基类。
最后,虚拟基类的主要复杂性在于:它们必须通过最底层的派生类(most-derived class)直接初始化。关于这一点的详细介绍,以及MI其它方面的知识,请参阅[stroustrup00],或[Meyers97]条款43。
B
D
C1
C2
图4:可怕的死亡钻石(如果D从B虚拟继承)
设计准则
避免多继承自多个“非protocol类”。(protocol类是一种抽象基类(abstract base class),或简称ABC,它完全由纯虚函数组成,没有数据成员。)
2. MI究竟必要吗?
简短的回答是:只要程序可以用汇编语言(或更低级的语言)来写,就不能说某种特性绝对“必要”。然而,正如大多数人不会去用简单的C编写自己的虚函数机制一样,在某些场合下,没有MI会让你大费周章。
正因为如此,我们现在才有了这一被称为MI的神奇特性。但问题是——或者,至少前面的问题应该换为——MI是个好东西吗?1
简而言之,的确有人认为MI是个坏主意,因而无论如何都应该避免它。这不对。是的,如果你在使用MI时有欠考虑,它的确会招致不必要的耦合性和复杂性。然而,任何一种被误用的继承都是这样(参见Exceptional C++ [Sutter00]条款24),但我相信,大家不会就此认为继承不是个好东西。还有,是的,在完全不使用MI的情况下,任何程序都可以写出来,但你也得知道,在完全不使用继承的情况下,任何程序也都可以写出来。实际上,任何程序都可以用汇编语言来写。但这并不是说用汇编语言来写程序就一定是个好主意,相反,你甚至不愿意去做那样的事。
2.如果必要,尽可能多地列举出它的使用场合,并论证为什么应该将MI加入到语言中;如果不必要,请论证为什么单继承(SI)(并且,可能结合Java风格的接口)可以取代多继承、甚至比它更出色,以及为什么不应该将MI加入到语言中。
那么,什么时候使用MI才算合适?简而言之,只有在每一个继承单独取出来看都合适的时候,这样的MI才算合适。Exceptional C++条款24提供了一个相当详尽的列表,说明了什么时候该使用继承。在现实世界中,MI的应用大多逃不出以下三类。
1. 结合使用程序模块或程序库。之所以首先提出这一点有一个理由,我将在后面说明。很多类被设计为基类——即,在使用时你得从它继承。这很自然地带来一个问题:如果你想写一个类,它用到了两个程序库,但每个程序库都要求你从它的某个类继承,这时候你该怎么办?
__________________
1. 一定程度上,本条款的主题启发自1998年6月SQL标准会议上发生的事件,在那次会议中,多继承被从ANSI SQL99标准草案中删除了。(如果说得远一点的话,你们当中如果有人对数据库感兴趣,就会知道,在SQL4中MI又以修订形式重新恢复了。)当时之所以那样做,主要是因为所提出的多继承规范存在技术上的困难,并且是为了想和Java这些不真正支持多继承的语言看齐。另外,仅仅是坐在那儿听人们在那样一个相对太迟的时间里争论多继承的优缺点,这本身就让人觉得有趣。在C++世界中,自这种语言形成以来,我们很少做这种事;这让我回忆起几年前(或比这更近的时期)新闻组上广泛展开的激烈争论,我会忍不住自言自语地念出一些标题,譬如:“MI是罪恶!!!”
在面对这种情况时,你一般无法通过修改程序库代码来避免某个继承。因为,它可能是从第三方供货商购买的程序库,或者,可能是你的公司里另一个项目组开发的模块。无论哪种情况,你不但不能修改代码,你甚至可能没有代码!如果是这样,MI就是必要的;没有其它(自然的)方法可以帮助你去做你必须做的事;此时,使用MI完全合理。
在实践中我发现,知道如何运用MI来结合使用供应商提供的程序库,这是每一个C++程序员必须具备的素质。无论你是否经常运用它,你绝对应该知道它并理解它。
2. Protocol类(interface类)。在C++中,MI最合适、最安全的应用是定义protocol 类——即,完全由纯虚函数构成的类。由于这种基类没有数据成员,MI臭名昭著的复杂性就得以完全避免。
有趣的是,有的语言(或模型)通过非继承机制来支持这种MI。Java和COM就是两个例子。严格来说,Java有多继承,但它将实现(implementation)的继承仅限于单继承。一个Java类可以实现多个“接口”,这种接口非常类似C++中没有数据成员的纯抽象基类。COM本身没有继承的概念(虽然在用C++来写COM对象时,这是一种常用的实现技术),但它同样也有“接口组合(composition of inerfaces)”的思想,COM接口类似Java接口和C++模板的结合。
3. 易于(多态)使用。有了继承,在接受基类对象的任何代码中,我们就都可以使用派生对象,这是一项威力强大的功能。在某些场合,如果同一个派生对象可以代替数种基类对象使用,那将会很有用处,这正是MI大显身手的地方。关于这一点有一个好例子,请参阅[Stroustrup00]14.2.2节,那里演示了一个基于MI的设计,用以构造异常类(exception class);在那个设计中,最底层的派生异常类可能和多个直接基类具有多态式的Is-A关系。
注意,第3点在很大程度上与第1、2点重叠。在实施另两点之一时,同时、并出于相同的理由运用第3点,往往很有用处。
还要考虑到另一点,不要忘记:有时候,单纯从两个不同的基类继承并没有必要,相反,我们要让每一个继承都有不同的理由。“多态的LSP Is-A 公有继承”并非唯一故事(译注:参见条款23);使用继承还有很多其它可能的原因。例如,一个类可能需要从一个基类A私有继承,以获得对类A的保护成员的访问权,同时,它还要从另一个基类B公有继承,以多态地实现类B的某个虚函数。