当涉及多重继承(MI)时,C++社区大体上分裂为两个基本阵营。一个阵营认为,如果单继承(SI)是好的,那么多重继承一定更好。另一个阵营则认为,单继承固然好,但多重继承不值得那些麻烦。在本条款中,我们的主要目标是理解关于MI问题的两种视角。
首先要认识到的一点是,当MI进入设计视野时,就有可能从多个基类继承到相同的名称(比如函数、typedef等)。这会导致新的歧义机会。例如:

在这个例子中,对 checkOut 的调用是歧义的,尽管两个函数中只有一个是可以访问的(checkOut 在 BorrowableItem 中是 public,但在 ElectronicGadget 中是 private)。这符合 C++ 解析重载函数调用的规则:在判断一个函数是否可访问之前,C++ 首先找出与调用最匹配的函数。只有在找到最佳匹配函数之后,才会检查其可访问性。本例中,两个 checkOut 的匹配程度相同,所以没有最佳匹配。因此 ElectronicGadget::checkOut 的可访问性也就从未被检查。
当然,你也可以尝试显式调用 ElectronicGadget::checkOut,但那时的歧义错误会被替换成"试图调用私有成员函数"的错误。
多重继承仅仅意味着从多个基类继承,但在层次结构中,MI 也常常出现在有更高层基类的情况中。这可能会导致所谓的"致命的多重继承菱形":


任何时候,只要继承层次中基类和派生类之间存在多条路径(比如上面 File 和 IOFile 之间,分别经由 InputFile 和 OutputFile 两条路径),你就必须面对一个问题:是否希望基类中的数据成员在每条路径上都各有一份副本。例如,假设 File 类有一个数据成员 fileName,那么 IOFile 中应该有多少份该字段的副本?一方面,它从每一个基类各继承一份,所以似乎 IOFile 应该有两个 fileName 数据成员。另一方面,按照常理,一个 IOFile 对象只有一个文件名,所以它通过两个基类继承来的 fileName 字段不应该重复。
C++ 在这个问题上没有采取立场,它同时支持两种方式,不过默认行为是进行复制。如果这不是你想要的,就必须让包含该数据的类(即 File)成为一个虚基类。要做到这一点,你需要让所有直接继承自它的类都使用虚继承:


标准 C++ 库中就有一个与这类似的多重继承层次结构,区别在于那些类是类模板,名称是 basic_ios、basic_istream、basic_ostream 和 basic_iostream,而不是 File、InputFile、OutputFile 和 IOFile。
从正确行为的角度来看,public 继承应该总是虚继承。如果只有这一个视角,规则就很简单:任何时候你使用 public 继承,就用 virtual public 继承。可惜,正确性并非唯一的考量。避免继承字段的复制需要编译器在背后做一些手脚,其结果是:使用虚继承创建的类对象,通常比不使用虚继承的对象要大。访问虚基类中的数据成员也比访问非虚基类中的数据成员要慢。具体细节因编译器而异,但基本要点很清楚:虚继承是有代价的。
在其它方面,虚继承也有代价。虚基类的初始化规则比非虚基类更复杂,也更不直观。初始化虚基类的责任落在继承体系中最派生(most derived)的类身上。这一规则的含义包括:(1)从虚基类派生的、需要初始化的类,必须知道其虚基类的存在------无论这些基类相隔多远;(2)当一个新的派生类被加入继承体系时,它必须承担其虚基类(包括直接和间接的)的初始化责任。
我对虚基类(即虚继承)的建议很简单。首先,除非必要,否则不要使用虚基类。默认情况下,使用非虚继承。其次,如果必须使用虚基类,尽量在其中不放置数据。这样一来,你就不必担心这类类在初始化和赋值规则方面出现的种种怪异现象。值得注意的是,Java 和 .NET 中的接口(Interface)------它们在很多方面与 C++ 的虚基类相当------是不允许包含任何数据的。
现在我们来看下面这个用于对人的建模的 C++ 接口类(参见条款31):

IPerson 的客户必须通过 IPerson 的指针和引用来编程,因为抽象类无法被实例化。为了创建可以作为 IPerson 对象来操作的对象,IPerson 的客户使用工厂函数(再次参见条款31)来实例化从 IPerson 派生的具体类:

但 makePerson 是如何创建它所要返回指针所指向的对象的呢?显然,一定存在某个从 IPerson 派生的具体类,makePerson 可以实例化它。
假设这个类叫 CPerson。作为一个具体类,CPerson 必须为它从 IPerson 继承来的纯虚函数提供实现。它可以从头编写这些实现,但更好的做法是利用现有的组件,它们已经完成了大部分乃至全部必要的工作。例如,假设有一个旧的数据库专用类 PersonInfo,提供了 CPerson 所需的核心功能:

可以看出这是一个旧式类,因为它的成员函数返回的是 const char* 而不是 string 对象。不过,如果这双鞋合脚,为什么不穿呢?这个类成员函数的名字暗示着,结果很可能相当合用。
你发现 PersonInfo 的设计初衷是为了便于以各种格式打印数据库字段,每个字段值的开头和结尾用特殊字符串作为分隔符。默认情况下,字段值的开始和结束分隔符是方括号,所以字段值"Ring-tailed Lemur"会被格式化成这样:

考虑到方括号并非所有 PersonInfo 客户都想要,虚函数 valueDelimOpen 和 valueDelimClose 允许派生类指定它们自己的开始和结束分隔符字符串。PersonInfo 成员函数的实现会调用这些虚函数,以便在它们返回的值中添加适当的分隔符。以 PersonInfo::theName 为例,代码如下:


PersonInfo::theName 这种古董式的设计可能让人诟病(尤其是使用固定大小的静态缓冲区,既容易溢出又存在多线程问题------另见条款21),但先把这些疑问放在一边,关注重点:theName 先调用 valueDelimOpen 生成返回字符串的开始分隔符,然后生成名字值本身,最后调用 valueDelimClose。
由于 valueDelimOpen 和 valueDelimClose 是虚函数,theName 返回的结果不仅取决于 PersonInfo,还取决于从 PersonInfo 派生的类。
作为 CPerson 的实现者,这是个好消息,因为在细读 IPerson 文档的细则时,你发现 name 和 birthDate 要求返回纯值,即不允许带有分隔符。也就是说,如果一个人叫 Homer,那么调用该人的 name 函数应该返回"Homer",而不是"Homer"。
CPerson 与 PersonInfo 之间的关系在于:PersonInfo 恰好有一些函数能让 CPerson 的实现变得更简单。仅此而已。因此它们的关系是 is-implemented-in-terms-of(根据某物实现),而我们知道这种关系有两种表达方式:通过组合(参见条款38)和通过 private 继承(参见条款39)。条款39指出,组合通常是更受青睐的方式,但如果要重新定义虚函数,继承就是必需的。在本例中,CPerson 需要重新定义 valueDelimOpen 和 valueDelimClose,所以单纯的组合行不通。最直接的解决方案是让 CPerson 以 private 方式继承自 PersonInfo,尽管条款39也指出,稍加额外工作,CPerson 也可以使用组合加继承的方式,来有效地重新定义 PersonInfo 的虚函数。这里我们采用 private 继承。
但 CPerson 还必须实现 IPerson 接口,这要求 public 继承。这就引出了多重继承的一个合理应用:将接口的 public 继承与实现的 private 继承结合起来:



在UML图中,这个设计看起来这样:

归根结底,多重继承只是面向对象工具箱中的又一种工具。与单继承相比,它通常使用起来更复杂,理解起来也更复杂,所以如果你有一个与 MI 设计大致相当的 SI 设计,那么 SI 设计几乎肯定更可取。如果你能想到的唯一设计涉及 MI,那就应该再多想一想------几乎总有某种方法能让 SI 工作。同时,MI 有时确实是完成工作最清晰、最易维护、最合理的方式。当情况如此时,不要害怕使用它,只需确保谨慎使用。
切记:
1.多重继承比单继承更复杂。它可能引发新的歧义问题,以及对虚继承的需求。
2.虚继承在大小、速度和初始化与赋值的复杂性方面都有代价。当虚基类没有数据时,它最为实用。
3.多重继承确有合理的用途。一种场景是将接口类的 public 继承与帮助实现的类的 private 继承结合起来。