9.5 虚基类

在这种情况下通常需要的是真正的菱形继承,Left和Right子对象在一个完整的Bottom对象内部共享着一个Top对象,这正是第1个类图描述的情况。这是通过使Top成为Left和Right的一个虚基类(virtual base class)来完成的:

9.5 虚基类 - 图1

给定类型的各个虚基类都涉及相同的对象,不论它在层次结构的哪个地方出现。[1]这意味着,当一个Bottom对象被实例化时,对象的布局看起来像下面的样子:

9.5 虚基类 - 图2

Left和Right子对象各有一个指向共享的Top子对象的指针(或某种概念上等价的对象),并且对Left和Right成员函数中那个子对象的所有引用都要通过这些指针来完成。[2]在这里,当从一个Bottom向上类型转换为一个Top对象时,不存在二义性的问题,因为这里只有一个Top对象可用来进行转换。

前面程序的输出结果如下:

9.5 虚基类 - 图3

打印出来的地址说明这种特殊的实现确实在完整的对象的结尾处储存Top子对象(尽管实际上它在那里并不重要)。dynamic_cast到void*的结果总是确定指向完整对象的地址。

尽管在技术上这样做是不合法的,[3]但是如果去掉了虚析构函数(和dynamic_cast语句,这样程序将可以通过编译),那么Bottom的长度将减少到24个字节。似乎Bottom的长度的减少量正好等于3个指针的大小。为什么呢?

重要的是不必太按照字面上的意思去推敲这些数字。当加入虚构造函数时,使用其他编译器处理仅使该类占用的空间增加4个字节。因为我们不是编译器的编写者,所以无法得知编译器的秘密。然而可以确定的是,一个带有多重继承的派生对象必须表现出它好像有多个VPTR,它的每个含有虚函数的直接基类都有一个。就是那么简单。编译器的作者不论发明什么样的最优化技术,但是这些编译器必须产生相同的行为。

在前面的代码中,最奇怪的事情是Bottom构造函数中对Top的初始化程序。正常的情况下,不必担心直接基类以外的子对象的初始化,因为所有的类只照料它们的直接基类的初始化。然而,由于从Bottom到Top有多个继承路径,因此依赖于中间类Left和Right将必需的初始化数据传递给基类导致了二义性—谁负责进行基类的初始化呢?基于这个原因,最高层派生类(most derived class)必须初始化一个虚基类。但是也对Top进行初始化的Left和Right构造函数中的表达式应该如何编写呢?当创建独立的Left或Right对象时,这些初始化表达式确实是必需的,但是当创建Bottom对象时,它们必须被忽略(因此,在Bottom构造函数的初

始化程序中,这些表达式都为零—当Left和Right的构造函数在Bottom对象的语境中执行时,这些位置上的任何值都将被忽略)。编译器为程序员处理所有这一切,但是了解责任所在还是很重要的。必须始终保证,多重继承层次结构中的所有具体的(非抽象的)类都知道任何虚基类并对它们进行相应的初始化。

这些责任规则不仅仅适用于类的初始化,而且适用于所有跨越类继承层次结构的操作。现在考虑前面代码中的流插入符。使数据成为保护的,这样就可以“骗取”和访问operator<<(ostream&,const Bottom&)中继承来的数据。通常将打印各子对象的工作分配到其各个相应的类来进行,并且在需要的时候让派生类调用它的基类函数,这样做更有意义。就像下面的代码的说明,如果在程序中尝试使用operator<<(),将会出现什么情况?

9.5 虚基类 - 图4

在通常处理方式中不能盲目地向上分摊责任,因为Left和Right每个流插入程序都调用了Top流插入程序,并且再出现数据的副本。另外,这里需要模仿编译器的初始化办法。一种解决办法是在类中提供特殊的函数,这种函数知道有关虚基类的情况,在打印输出的时候忽略虚基类(而将工作留给最高层派生类):

9.5 虚基类 - 图5

9.5 虚基类 - 图6

specialPrint()函数是protected的,因为它们只能被Bottom调用。specialPrint()函数只输出自己的数据并忽略Top子对象,因为当这些函数被调用时,Bottom插入程序将获得控制权。像Bottom的构造函数一样,Bottom插入程序也必须知道虚基类。同样的推理适用于具有虚基类的层次结构中的赋值操作符,也适用想要分担层次结构中所有类的工作的任何成员函数或非成员函数。

在讨论了虚基类后,现在可以举例说明对象初始化的“全部情节”。因为虚基类引起共享子对象,共享发生之前它们就应该存在才有意义。所以子对象的初始化顺序遵循如下的规则递归地进行:

1)所有虚基类子对象,按照它们在类定义中出现的位置,从上到下、从左到右初始化。

2)然后非虚基类按通常顺序初始化。

3)所有的成员对象按声明的顺序初始化。

4)完整的对象的构造函数执行。

下面的程序举例说明了这个过程:

9.5 虚基类 - 图7

9.5 虚基类 - 图8

这段代码中的类可以用下图表示:

9.5 虚基类 - 图9

每一个类都有一个嵌入的M类型的成员。注意,只有4个派生类是虚拟的:E派生自B和C、F派生自B和C。这个程序的输出结果是:

9.5 虚基类 - 图10

9.5 虚基类 - 图11

g的初始化需要首先初始化它的E和F部分,但是B和C子对象首先被初始化,因为它们是虚基类,并且二者的初始化在G的构造函数的初始化程序中进行,G是最高层派生类。类B没有基类,所以根据第3条规则,它的成员对象m被初始化,然后它的构造函数打印输出“B from G”,对于E的C子对象处理相同。E子对象的初始化需要先对A、B和C子对象进行初始化。因为B和C已经被初始化,于是E子对象的A子对象接着被初始化,然后是E子对象自己初始化。相同的情况重复出现在g的F子对象上,但是虚基类的初始化不重复进行。

[1]使用术语层次结构(hierarchy)因为人人都在使用它,但是用来表示多重继承关系的图一般是一个有向无环图(DAG),也称为网格,其理由是显而易见的。

[2]这些指针的出现说明为什么B的长度远大于4个整型变量的长度。这是虚基类的部分开销。还有VPTR的开销,这归因于虚析构函数。

[3]再说明一次,基类必须有虚析构函数,但是大部分编译器都能使这个例子编译通过。