虚拟继承

基本概念

cfront 实现

#include <iostream>

struct X { public: int i; };       // size: 4
struct A: virtual X { int j; };    // size: 16
struct B: virtual X { double d; }; // size: 24
struct C: A, B { int k; };         // size: 40

int main() {
    C c;
    std::cout << c.i << '\n';
}

如果把 A 或者 B 的 virtual 继承属性删除就会出现 ambiguous 指代错误。因为 virtual 继承只保存基类的一份数据,删掉之后自然就有多份变量 i 了。不过,virtual 继承并不代表该类为多态类(可用 type traits 判断)

同样的,如果 Interface 中有虚函数 foo,而 A 和 B 都继承了 Interface,C 继承了 A 和 B。如果 A 和 B 没有虚拟继承 Interface,那么在 C 的对象调用函数 foo 时将出现 ambiguous 指代错误。如果 C 重写了 foo 函数,那么指代就还是明确的。或者,如果 A 和 B 都是虚拟继承自 Interface,那么也不会有编译错误。但这样通过指针/引用调用虚函数 foo 就需要先取虚基类子对象 this 的偏移,修改 this 之后再从 vptr 中读虚函数 foo,开销是 4 次访存(将虚拟继承和虚函数调用的代价累加起来了)。

虚拟继承下的对象构造

https://quuxplusone.github.io/blog/2019/09/30/what-is-the-vtt/#when-we-are-constructing-a-lion

文章讨论的是 Itanium C++ ABI。Itanium C++ ABI 是事实上的跨平台 C++ ABI,除了 MSVC 之外的大部分编译器都使用它。

类的构造函数分两种。第一种是  base object constructor,这是一个类型作为基类的时候,子类需要负责调用的构造函数;第二种是 complete object constructor,这是构造一个含虚基类的对象时,我们的代码显式调用的构造函数。区分了两类构造函数后,我们在构造一个含有虚基类的对象时就不会错误地初始化一个虚基类多次。如果一个类不含有虚基类,则 BOC 和 COC 是同一个。

有虚基类也不一定会生成完整对象构造函数。因为构造函数属于特殊函数,按需生成,只有在被使用时才需要被编译器生成。

相似的情况:类中写完整定义的方法是有内联属性的,编译器会尽量把方法内联,所以在生成的汇编中看不到类定义中诸如 void foo() {} 的方法。

同一个类的完整对象构造函数可能比基类对象构造函数复杂,因为基类对象构造函数不必考虑初始化其虚基类。基类对象构造函数也可能比完整对象构造函数复杂,因为基类构造函数需要从 VTT 中取得 vptr 的值。(VTT 见下面)

一个子类的完整构造函数会首先调用所有虚基类(可能是间接虚基类)的 基类对象构造函数,再调用非虚基类的 基类对象构造函数。

虚继承问题 1:如何找到虚基类成员

https://quuxplusone.github.io/blog/2019/09/30/what-is-the-vtt/#construction-vtables

在没有虚基类时,派生对象从基类开始构造,每次调用构造函数前就修改 vptr 的值。这将能保证 vptr 始终和当前的类型对应,尽管在构造函数中调用虚函数时不能展现出完整的多态性(因为子类还没开始构造),数据和虚函数的地址查找都是无歧义的。

但如果一个类有虚基类,且在某个函数中用到了继承自虚基类的数据,则还得知道虚基类子对象在本类中的偏移( 对象布局:来自虚基类的数据始终放在最后面)。因而 vtable 的头部要保存这部分信息,这些偏移可以通过当前的 vptr 获得。通过指针或引用的对虚基类成员的访问也是要读取 vtable 的这部分数据的。

有虚基类的类中函数(也包括基类对象构造函数)只靠自己不能知道虚基类的成员位置,因为虚基类数据放在自己对应的类外(不一定紧邻,比如 B 虚拟继承 A,C 具体继承 B 和 D,那么在 C 中的 B 类子对象要访问 A 的成员得隔着 D),而不是自己类的开头部分。

如果一个类是多态类,vptr[0] 则相当于是第 0 个虚函数的地址,vtable 头部的偏移信息相当于就是从 vptr 的负数下标位置取了。也就是说,这个类中的 vptr 本来就指向的是 vtable 的中间地址(而不是开头),这样比较方便虚函数查表。

虚继承问题 2:如何正确初始化 vptr

一个完整对象构造函数调用其基类的基类对象构造函数时,基类对象构造函数需要负责正确设置基类子对象的 vptr,但由于基类对象构造函数可能被多个子类的完整对象构造函数调用,基类对象构造函数此时不知道自己应该设置成哪一个虚表,这个信息就需要子类负责传入。该信息由 VTT(virtual table table) 维护。

虚继承导致了同一类型的多份虚表是因为虚表中的偏置信息不同,而在普通继承的情况下,一个类型无论被谁继承,其虚表都不需要多份。

construction vtable 和没有虚继承时的 vtable 格式基本一样:

  1. 开头增加了到各个虚基类子对象的偏置信息。
  2. 虚表中各虚函数地址也都是一样的,但各虚表中指出的到真实对象的偏置不同。

VTT 第 0 项是一个类本身的 vtable 表地址。后续多项(若存在)是该类的基类(基类作为子对象嵌入在子类中的那块数据)的 construction vtable,这个 construction vtable 其实就是子类调用基类的 base object constructor 时想要给基类的 vptr 赋的值。