15.8 继承和VTABLE

可以想象,当实现继承和重新定义一些虚函数时,会发生什么事情?编译器对新类创建一个新VTABLE表,并且插入新函数的地址,对于没有重新定义的虚函数使用基类函数的地址。无论如何,对于可被创建的每个对象(即它的类不含有纯虚函数),在VTABLE中总有一个函数地址的全集,所以绝对不能对不在其中的地址进行调用(否则结果将会是灾难性的)。

但是在派生(derived)类中继承或增加新的虚函数时会发生什么呢?下面有一个简单的例子:

15.8 继承和VTABLE - 图1

类Pet中含有2个虚函数:speak()和name(),而在类Dog中又增加了第3个称为sit()的虚函数,并且重新定义了speak()的含义。下图有助于显示发生的事情。这是由编译器为Pet和Dog创建的VTABLE。

15.8 继承和VTABLE - 图2

注意,编译器在Dog的VTABLE中把speak()的地址准确地映射到和Pet的VTABLE中同样的位置。类似地,如果类Pug从Dog中继承而来,则在它的VTABLE中sit()也将会被放置在和Dog的VTABLE中相同的位置。这是因为(正如通过汇编语言例子看到的)编译器产生的代码在VTABLE中使用一个简单的偏移来选择虚函数。不论对象属于哪个特殊的类,它的VTABLE都是以同样的方法设置,所以对虚函数的调用将总是使用同样的方法。

然而在这里,编译器只对指向基类对象的指针工作。而这个基类只有speak()和name()函数,所以它就是编译器惟一允许调用的函数。那么,如果只有基类对象的指针,那么编译器怎么可能知道自己正在对Dog对象工作呢?这个指针可能指向其他一些没有sit()函数的类。在VTABLE中,可能有,也可能没有一些其他函数的地址,但无论何种情况,对这个VTABLE地址做虚函数调用都不是我们想要做的。所以编译器通过防止我们对只存在于派生类中的函数做虚函数调用来完成其工作。

有一些比较少见的情况,可能我们知道指针实际上指向哪一种特殊子类的对象。这时如果想调用只存在于这个子类中的函数,则必须类型转换这个指针。下面的语句可以消除由前面程序产生的出错信息:

15.8 继承和VTABLE - 图3

这里,我们碰巧知道p[1]指向Dog对象,但通常情况下我们并不知道。如果你的问题是必须知道所有对象的确切类型,那么我们应当重新考虑这个问题,因为我们可能在进行不正确的虚函数调用。然而对于有些情况,如果知道保存在一般容器中的所有对象的确切类型,会使我们的设计工作在最佳状态(或者没有选择)。这就是运行时类型辨认(Run-time_type Identification, RTTI)问题。

RTTI是有关向下类型转换基类指针到派生类指针的问题(“向上”和“向下”是相对典型类图而言的,典型类图以基类为顶点)。向上类型转换是自动发生的,不需强制,因为它是绝对安全的。向下类型转换是不安全的,因为这里没有关于实际类型的编译时信息,所以必须准确地知道这个类实际上是什么类型。如果把它转换成错误的类型,就会出现麻烦。

在本章的后面将讨论RTTI,而且本书的第2卷中也有一章专门讨论这个主题。

15.8.1 对象切片

当多态地处理对象时,传地址与传值有明显的不同。所有在这里已经看到的例子和将会看到的例子都是传地址的,而不是传值的。这是因为地址都有相同的长度[1],传递派生类(它通常稍大一些)对象的地址和传递基类(它通常更小一点)对象的地址是相同的。如前面所述,这是使用多态的目的,即让对基类对象操作的代码也能透明地操作派生类对象。

如果对一个对象进行向上类型转换,而不使用地址或引用,发生的事情将会使我们吃惊:这个对象被“切片”,直到剩下来的是适合于目的的子对象。在下面例子中可以看到当一个对象被“切片”后发生了什么。

15.8 继承和VTABLE - 图4

15.8 继承和VTABLE - 图5

函数describe()通过传值方式传递一个类型为Pet的对象。然后对于这个Pet对象调用虚函数description()。我们可能希望第一次调用产生“This is Alfred”,而第二次调用产生“Fluffy likes to sleep”。实际上,两次调用都是调用了基类版本的description()。

在这个程序中,发生了两件事情。第一,describe()接受的是一个Pet对象(而不是指针或引用),所以describe()中的任何调用都将引起一个与Pet大小相同的对象压栈并在调用后清除。这意味着,如果一个由Pet派生来的类的对象被传给describe(),则编译器会接受它,但只拷贝这个对象的对应于Pet的部分,切除这个对象的派生部分,如下图所示:

15.8 继承和VTABLE - 图6

现在,我们可能对这个虚函数调用有这样的疑问:如果Dog:description()使用了Pet(它仍存在)和Dog(它不再存在,因为已被切掉),当调用它时,会发生什么呢?

其实我们已经从灾难中被解救出来,这个对象正安全地按值传递。这是因为派生类对象已经被强迫地变为基类对象,所以编译器知道这个对象的确切类型。另外,当按值传递时,Pet对象的拷贝构造函数被调用,该构造函数初始化VPTR指向Pet的VTABLE,并且只拷贝这个对象的Pet部分。这里没有显式的拷贝构造函数,所以编译器自动地生成一个。由于所有上述原因,因此这个对象在切片过程中真的变成了一个Pet对象。

对象切片实际上是当它拷贝到一个新的对象时,去掉原来对象的一部分,而不是像使用指针或引用那样简单地改变地址的内容。因此,不常使用对象向上类型转换,事实上,通常是要提防或防止这种操作。注意,在本例中,如果description()在基类中是一个纯虚函数(这并不是毫无理由的,因为它在基类中实际上也并没有做什么事情),因为编译器不会允许我们“创建”基类对象(这就是我们通过传值向上类型转换所发生的事情),所以它将阻止对对象进行“切片”。这可能会是纯虚函数最重要的作用:如果某人试着这么做,将通过生成一个编译错误来阻止对象切片。

[1]实际上,并不是所有机器上的指针都具有同样的长度。然而,在我们的讨论范围内,认为它们是相同的。