C VTable(不完整)structC+------------+
object | RTTI for C |0-structB+------->+------------+0-structA||C::f0()|0- vptr_A -------------------------++------------+8-int ax |B::f1()|12-int bx +------------+16-int cx |C::f2()|sizeof(C):24 align:8+------------+
从上面的代码可以看出,使用一个类型 A 或 B 的引用持有实际类型为 C 的对象,它的起始地址仍然指向 C 的起始地址,这意味着单链继承的情况下,动态向下转换和向上转换时,不需要对 this 指针的地址做出任何修改,只需要对其重新“解释”。然而,并非所有派生类都是单链继承的,它们的起始地址和其基类的起始地址不一定始终相同。
与单链继承不同,由于 A 和 B 完全独立,它们的虚函数没有顺序关系,即 f0 和 f1 有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且 A 和 B 中的成员变量也是无关的,因此基类间也不具有包含关系,这使得 A 和 B 在 C 中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。其内存布局如下所示:
C Vtable(7 entities)+--------------------+structC|offset_to_top(0)|
object +--------------------+0-structA(primary base)| RTTI for C |0- vptr_A ----------------------------->+--------------------+8-int ax |C::f0()|16-structB+--------------------+16- vptr_B ----------------------+|C::f1()|24-int bx |+--------------------+28-int cx ||offset_to_top(-16)|sizeof(C):32 align:8|+--------------------+|| RTTI for C |+------>+--------------------+| Thunk C::f1()|+--------------------+
在如上所示的布局中,C 将 A 作为主基类,也就是将它虚函数“并入” A 的虚函数表之中,并将 A 的虚指针作为 C 的内存起始地址。而类型 B 的虚指针 vptr_B 并不能直接指向虚表中的第 4 个实体,这是因为 vptr_B 所指向的虚表区域,在格式上必须也是一个完整的虚表,因此,需要为 vptr_B 创建对应的虚表放在虚表 A 的部分之后,可以看到,出现了两个“新”的实体,一个是 offset_to_top,另一个是 Thunk。
在多继承中,由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this 指针的偏移量也不相同。由于实际类型在编译时是未知的,这要求偏移量必须能够在运行时获取。实体 offset_to_top 表示的就是实际类型起始地址到当前这个形式类型起始地址的偏移量,在向上动态转换到实际类型时,让 this 指针加上这个偏移量即可得到实际类型的地址。
为了弄清楚 Thunk 是什么,首先要注意到,如果一个类型 B 的引用持有了实际类型为 C 的变量,这个引用的起始地址在 C+16 处,当它调用由类型 C 重写的函数 f1() 时,如果直接使用 this 指针调用 C::f1() 会由于 this 指针的地址多出16字节的偏移量导致错误。因此在调用之前,this 指针必须要被调整至正确的位置,这里的 Thunk 起到的就是这个作用:首先将 this 指针调整到正确的位置,即减少 16 字节偏移量,然后再去调用函数 C::f1()。
③ 构造与析构过程
在多态类型的构造和析构过程中,所调用的虚函数并不是最终的实际类型的对应函数,而是当前已经创建了的(或尚未析构的)类型的对应函数。如下所示的两个类型 A 和 B, 它们在构造和析构时都会调用对应的虚函数:
structA{virtualvoidf0(){
std::cout <<"A\n";}A(){this->f0();}virtual~A(){this->f0();}};structB:publicA{virtualvoidf0(){
std::cout <<"B\n";}B(){this->f0();}~B()override{this->f0();}};intmain(){
B b;return0;}// 输出:ABBA
运行上述程序,可以得到输出“ABBA”,表明程序依次调用了A::A()、B::B()、B::~B()、A::~A()。直观上理解,在构造 A 时,B 中的数据还没有创建,因此 B 重写的虚函数当然不可使用,因此应该调用 A 中的版本;反过来,析构的时候,由于 B 先析构,在 B 析构之后,B 中的函数当然也不可用,因此也应该调用 A 中的版本。在程序运行中,这一过程是通过动态的修改对象的虚指针实现的。
根据 C++ 中继承类的构造顺序,首先基类 A 被构造,在构造 A 时, 对象自身的虚指针指向 A 的虚表,由于 A 的虚表中,f0() 的位置保存着 A::f0() 的地址,因此 A::f0() 被调用。在 A 的构造结束后,B 的构造启动,此时虚指针被修改为指向 B 的虚表,析构过程与此相反。
如下,展示了一个经典的菱形虚继承关系,为了避免重复包含 A 中的成员,类型 B 和 C 分别虚继承 A,类型 D 继承了 B 和 C。依据其继承方式的不同,D 中的 B、C 的偏移量可以在编译时确定,而 A 的偏移量在运行时确定:
structA{int ax;virtualvoidf0(){}virtualvoidbar(){}};structB:virtualpublicA/****************************/{/* */int bx;/* A */voidf0()override{}/* v/ \v */};/* / \ *//* B C */structC:virtualpublicA/* \ / */{/* \ / */int cx;/* D */voidf0()override{}/* */};/****************************/structD:publicB,publicC{int dx;voidf0()override{}};
首先对类型 A 的内存模型进行分析,由于虚继承影响的是子类,不会对父类造成影响,因此 A 的内存布局和虚表都没有改变:
A VTable
+------------------+|offset_to_top(0)|structA+------------------+
object | RTTI for A |0- vptr_A -------------------------------->+------------------+8-int ax |A::f0()|sizeof(A):16 align:8+------------------+|A::bar()|+------------------+
类型 B 类和类型 C 没有本质的区别,因此只分析类型 B,如下所示为类型 B 的内存模型:
B VTable
+---------------------+|vbase_offset(16)|+---------------------+|offset_to_top(0)|structB+---------------------+
object | RTTI for B |0- vptr_B ------------------------->+---------------------+8-int bx |B::f0()|16-structA+---------------------+16- vptr_A --------------+|vcall_offset(0)|x--------+24-int ax |+---------------------+|||vcall_offset(-16)|o----+||+---------------------+||||offset_to_top(-16)||||+---------------------+|||| RTTI for B |||+-------->+---------------------+||| Thunk B::f0()|o----+|+---------------------+||A::bar()|x--------++---------------------+
对于形式类型为B的引用,在编译时,无法确定它的基类 A 它在内存中的偏移量。 因此,需要在虚表中额外再提供一个实体,表明运行时它的基类所在的位置,这个实体称为 vbase_offset,位于 offset_to_top 上方。
除此之外,如果在 B 中调用 A 声明且 B 没有重写的函数,由于 A 的偏移量无法在编译时确定,而这些函数的调用由必须在 A 的偏移量确定之后进行, 因此这些函数的调用相当于使用A的引用调用。也因此,当使用虚基类 A 的引用调用重载函数时 ,每一个函数对 this 指针的偏移量调整都可能不同,它们被记录在镜像位置的 vcall_offset 中。
例如,调用 A::bar() 时,this 指针指向的是 vptr_A,正是函数所属的类 A 的位置,因此不需要调整,即 vcall_offset(0);而 B::f0() 是由类型 B 实现的, 因此需要将 this 指针向前调整 16 字节。对于类型 D,它的虚表更为复杂,但虚表中的实体都已熟悉,如下为 D 的内存模型:
D VTable
+---------------------+|vbase_offset(32)|+---------------------+structD|offset_to_top(0)|
object +---------------------+0-structB(primary base)| RTTI for D |0- vptr_B ---------------------->+---------------------+8-int bx |D::f0()|16-structC+---------------------+16- vptr_C ------------------+|vbase_offset(16)|24-int cx |+---------------------+28-int dx ||offset_to_top(-16)|32-structA(virtual base)|+---------------------+32- vptr_A --------------+|| RTTI for D |40-int ax |+--->+---------------------+sizeof(D):48 align:8||D::f0()||+---------------------+||vcall_offset(0)|x--------+|+---------------------+|||vcall_offset(-32)|o----+||+---------------------+||||offset_to_top(-32)||||+---------------------+|||| RTTI for D |||+-------->+---------------------+||| Thunk D::f0()|o----+|+---------------------+||A::bar()|x--------++---------------------+
这个描述比较抽象,通过上面三中的①的菱形继承的例子进行解释,四个类型 A,B,C 和 D 的继承关系如下所示:
structA{int ax;virtualvoidf0(){}virtualvoidbar(){}};structB:virtualpublicA/****************************/{/* */int bx;/* A */voidf0()override{}/* v/ \v */};/* / \ *//* B C */structC:virtualpublicA/* \ / */{/* \ / */int cx;/* D */virtualvoidf1(){}/* */};/****************************/structD:publicB,publicC{int dx;voidf0()override{}};
观察实际类型为 B 和实际类型为 D 对象的内存布局可以发现,如果实际类型为 B,虚基类 A 对 B 的首地址的偏移量为 16;若实际类型为 D,则其中 A 对 B 首地址的偏移量为 32,这明显与 B 自身的虚表冲突,如果构建 D::B 时还采用的是 B 自身的虚表,会由于偏移量的不同导致错误。这一问题的解决方法其实很粗暴,那就是在对象构造、析构阶段,会用到多少种虚表,会用到多少种虚指针就生成多少种虚指针,在构造或析构时,“按需分配”。
例如,类型 D 是类型 B 和 C 的子类,而 B 和 C 虚继承了类型 A,这种继承关系会导致 D 内部含有的 B(称作 B-in-D)、C(称作 C-in-D)的虚表与 B、C 的虚表不同,因此,这需要生成两张新的虚表,即 B-in-D 和 C-in-D 的虚表。由于 B-in-D 也是 B 类型的一种布局,B 的一个虚表对应两个虚指针,分别是 vptr_B 和 vptr_A,因此它也有两个着两个虚指针,在构造或析构 D::B 时,其对 象的内存布局和虚表布局如图所示:
B-in-D VTable
+---------------------+|vbase_offset(32)|+---------------------+structD(Constructing/Deconstructing B)|offset_to_top(0)|
object +---------------------+0-structB(primary base)| RTTI for B |0- vptr_B ----------------------->+---------------------+8-int bx |B::f0()|16-structC+---------------------+16- vptr_C |vcall_offset(0)|x--------+24-int cx +---------------------+|28-int dx |vcall_offset(-32)|o----+|32-structA(virtual base)+---------------------+||32- vptr_A --------------+|offset_to_top(-32)|||40-int ax |+---------------------+||sizeof(D):48 align:8|| RTTI for B |||+-------->+---------------------+||| Thunk B::f0()|o----+|+---------------------+||A::bar()|x--------++---------------------+