简单虚拟继承
示例代码如下:- #include struct VBase{ void zoo() { std::cout > 8 = 0xffffffffffffe8 ,正好是-24的补码表示。</p>__offset_flags 的低8 bit表示flags,本例中,基类 VBase 既是虚基类(对应 __virtual_mask = 0x1 ),又是public基类(对应 __public_mask = 0x2 ),因此 __offset_flags 的低8 bit是 0x03 ( __virtual_msat | __public_mask )。
- [size=5]为什么需要虚函数表?[/size]
- 现在我们知道了,当存在虚基类时,即使没有虚函数,我们也需要在虚函数表中记录必要的信息以供运行时使用,比如通过派生类对象访问虚基类子对象时所需的 vbase_offset ,动态类型转换( dynamic_cast )时所需的 typeinfo 等。
- [size=6]菱形继承[/size]
- 示例代码如下:
- [code]class GrandParent{public: GrandParent() {} virtual ~GrandParent() {} virtual void foo() {} virtual void zoo() {} int grandparent_data = 100;};class Parent1 : virtual public GrandParent{public: Parent1() {} virtual ~Parent1() {} virtual void foo() override {} int parent1_data = 200;};class Parent2 : virtual public GrandParent{public: Parent2() {} virtual ~Parent2() {} virtual void zoo() override {} int parent2_data = 300;};class Child : public Parent1, public Parent2{public: Child() {} virtual ~Child() {} int child_data = 400;};int main(){ Child *p_child = new Child; Parent1 *p_parent1_sub = p_child; GrandParent *p_gp1 = p_parent1_sub; p_gp1->foo(); Parent1 *p_parent1 = new Parent1; GrandParent *p_gp2 = p_parent1; p_gp2->foo(); delete p_child; delete p_parent1; return 0;}
复制代码 为什么需要VTT?
前文已经提到过VTT,它是一个列表,表中的每一项都是一个虚表指针。该表在对象的构造/析构过程中发挥了重要作用。本文将以 Child 类对象的构造过程为例,探究为什么需要VTT。不过在此之前,我们先给出VTT及其指向的vtable的内存布局。
▲ 图2 VTT及vtable的内存布局
上图中,cod表示complete object destructor,dd表示deleting destructor,这两种析构函数的区别在之前的文章中已详细说明,此处不再赘述。
从构造过程看VTT作用
step1:构造虚基类子对象
从下面的汇编代码可以看出,程序首先为完整对象 Child 分配了内存,并在分配的内存中构造了虚基类 GrandParent 的对象。- main: pushq %rbp movl $48, %edi ; operator new的参数,分配48字节内存 pushq %rbx subq $8, %rsp call operator new(unsigned long) ; 分配内存 movq %rax, %rbp ; new返回的指针存入%rbp movq %rax, %rdi ; new返回的指针存入%rbp,作为参数调用Child::Child() call Child::Child() [complete object constructor]Child::Child() [complete object constructor]: pushq %rbx movq %rdi, %rbx ; %rdi保存的是new返回的地址,也是Child对象的首地址 leaq 32(%rdi), %rdi ; 首地址偏移32字节,作为参数调用虚基类构造函数 call GrandParent::GrandParent() [base object constructor]GrandParent::GrandParent() [base object constructor]: movq $vtable for GrandParent+16, (%rdi) ; 设置虚表指针 movl $100, 8(%rdi) ; 设置数据成员 ret
复制代码 构造完成后,内存布局如下所示。
▲ 图3 虚基类子对象及其vtable的内存布局
step2:构造基类Parent1子对象
前面的文章中讲到,在构造过程中,当前正在构造谁,谁就是“临时的”完整对象,整个对象的类型就是谁,因此,虚函数指针会不断调整。但跟之前不同的是,这里使用了construction vtable这种特殊的vtable。- Child::Child() [complete object constructor]: pushq %rbx movq %rdi, %rbx ; %rdi保存的是new返回的地址,也是Child对象的首地址 leaq 32(%rdi), %rdi ; 首地址偏移32字节,作为参数调用虚基类构造函数 call GrandParent::GrandParent() [base object constructor]=> movq %rbx, %rdi ; 将Child对象首地址作为调用Parent1::Parent1()的第1个参数,即this指针 movl $VTT for Child+8, %esi ; 将VTT中第2个条目的地址作为调用Parent1::Parent1()的第2个参数 call Parent1::Parent1() [base object constructor]Parent1::Parent1() [base object constructor]: movq (%rsi), %rax ; 将VTT中第2个条目的内容(即虚表指针construction vtable for Parent1-in-Child+24,简称虚指针1)存入%rax movq 8(%rsi), %rdx ; 将VTT中第3个条目的内容(即虚表指针construction vtable for Parent1-in-Child+88,简称虚指针2)存入%rax movq %rax, (%rdi) ; 将虚指针1设置到this指针指向的内存,作为Parent1的虚表指针 movq -24(%rax), %rax ; 虚指针1向上偏移24字节,取对应内容(即vbase_offset),存入%rax movq %rdx, (%rdi,%rax) ; %rdi表示Parent1对象的首地址,%rax表示vbase_offset,两者相加,即为虚基类子对象GrandParent的地址,此句将虚指针2设置为虚基类的虚表指针 movl $200, 8(%rdi) ; 设置Parent1的数据成员 ret
复制代码 构造完成后,内存布局如下所示。

▲ 图4 Parent1 作为基类子对象时,对象和Construction vtable的内存布局
为什么不能直接使用 Parent1 的vtable呢?我们不妨先看一下 Parent1 作为完整对象时,对象和vtable的内存布局。

▲ 图5 Parent1 作为完整对象时,对象和vtable的内存布局
可见,同样是 Parent1 对象,但作为基类子对象(图4)和作为完整对象(图5)相比,整体的内存布局是不一样的,即虚基类子对象 GrandParent 与 Parent1 对象的偏移量是不一样的。图4中,因为需要预留16字节给 Parent2 对象,所以 GrantParent 和 Parent1 离得更远。反映到vtable上,就是与偏移相关的条目,如 vbase_offset 和 vcall_offset ,值会不一样,图4中是32和-32,图5中是16和-16。因此,如果图5中直接使用了 Parent1 的vtable,在基类子对象 Parent1 和虚基类子对象 GrandParent 相互转换时(即 this 指针的调整),就会因使用了错误的偏移量而出错。
综上,按C++标准规定,构造到 Parent1 时, Parent1 就是“完整对象”,应当使用 Parent1 的vtable。但这个“完整对象”的内存布局,和真正的完整对象的内存布局,还可能不一样,差别在虚基类子对象的偏移上。若直接使用 Parent1 的vtable,偏移量相关的条目,就会与实际情况对不上。因此,就需要准备一张“临时”的vtable,在里面填上正确的偏移量,供构造时使用,这就是所谓的construction vtable。另外,这些构造过程中使用的vtable的地址,被统一保存到了另一个表里,即VTT。还需说明的是,VTT及construction vtable,不仅在对象构造过程中会被使用,在对象析构过程中,也会被使用,因为析构是构造的逆过程。关于VTT,Itanium C++ ABI的描述如下:
To ensure that the virtual table pointers are set to the appropriate virtual tables during proper base class construction, a table of virtual table pointers, called the VTT, which holds the addresses of construction and non-construction virtual tables is generated for the complete class. The constructor for the complete class passes to each proper base class constructor a pointer to the appropriate place in the VTT where the proper base class constructor can find its set of virtual tables. Construction virtual tables are used in a similar way during the execution of proper base class destructors.
除了使用construction vtable外,对象的构造过程与上篇文章讲到的并无差别,本文不再赘述。
vtable条目解析
上文给出了虚继承条件下的vtable,包括construction vtable,但并未深入其中的各个条目,现在,让我们来一探究竟。
construction vtable里的虚析构函数指针为什么是0?
细心的读者可能观察到了,在construction vtable中,虚析构函数的地址是0,即虚析构函数被禁掉了。关于这一点,C++标准并没有明确的规定,而是GCC编译器独有的行为,比如,Clang编译器就没有这个行为。GCC这么做的原因,笔者猜测,可能是为了防止基类子对象被重复析构。比如,在构造 Parent1 时,调用了 ~Parent1() (当然,在构造函数里调用析构函数,本身就是十分奇怪的,正常不会这么用,但话说回来,C++并没有禁止这么用),这样,虚基类 GrandParet 的虚函数也会被调用,虚基类也会被析构。等到构造 Parent2 时, ~Parent2() 也可能被调用,这样, GrandParent 会再次被析构,这样就会出现double free的问题。当然,这只是笔者的一个猜测,如果读者有其它的观点,欢迎不吝赐教。
vbase_offset的作用
考虑下面的代码:- int main(){ Child *p_child = new Child; Parent1 *p_parent1_sub = p_child; GrandParent *p_gp1 = p_parent1_sub; // Parent1作为Child的基类子对象,转为虚基类GrandParent p_gp1->foo(); Parent1 *p_parent1 = new Parent1; GrandParent *p_gp2 = p_parent1; // Parent1作为Child的基类子对象,转为虚基类GrandParent p_gp2->foo(); delete p_child; delete p_parent1; return 0;}
复制代码 当拿到一个 Parent1 类型的指针时,程序并不知道这个指针指向的是一个完整的 Parent1 对象,还是某个其它对象的基类子对象。而从图4和图5我们了解到,同样是 Parent1 对象,但作为基类子对象和完整对象时,到虚基类对象的距离是不一样的。因此,在向虚基类子对象转换时,是不能使用一个固定的偏移量的,而是需要一个“随机应变”的偏移量,这正是保存在vtable中的 vbase_offset 。
让我们通过汇编代码探究一下,当派生类指针/引用向基类指针/引用转换时,是如何利用 vbase_offset 调整指针值的。- main: pushq %rbp movl $48, %edi ; 需要分配的内存大小 pushq %rbx subq $8, %rsp ; 建议配合图6来理解下面的代码 call operator new(unsigned long) ; 分配48字节内存 movq %rax, %rbp ; 分配的内存的首地址存入%rbp和%rdi movq %rax, %rdi call Child::Child() [complete object constructor] ; 构造Child对象 ; 因为Parent1子对象的首地址和Child对象的首地址是一样的,因此, ; Parent1 *p_parent1_sub = p_child; 一句不需要调整指针,在-O2优化级别下, ; 没有生成汇编代码。下面的代码,是 Parent1* 向 GrandParent* 转换的汇编代码 movq 0(%rbp), %rax ; 在对象首地址处取出虚表指针,存入%rax movq -24(%rax), %rdi ; 虚表指针向上偏移24字节,正好指向vbase_offset,将vbase_offset的值(这里是32)存入%rdi addq %rbp, %rdi ; 这里相当于%rdi = %rbp + %rdi = Parent1子对象首地址 + vbase_offset(32),因此得到的是虚基类子对象GrandParent的首地址 movq (%rdi), %rax ; 从首地址取出GrandParent的虚表指针 call *16(%rax) ; 虚表指针加16,正是virtual trunk to Parent1::foo()条目的地址, ; 取出该地址处的值,正是virtual trunk to Parent1::foo()的地址,调用该函数 ; 建议配合图5来理解下面的代码 movl $32, %edi call operator new(unsigned long) ; 为构造Parent1对象,先分配32字节 movq %rax, %rbx ; 分配的内存的首地址存入%rbp和%rdi movq %rax, %rdi call Parent1::Parent1() [complete object constructor] ; 构造对象 movq (%rbx), %rax ; 在对象首地址处取出虚表指针,存入%rax movq -24(%rax), %rdi ; 虚表指针向上偏移24字节,正好指向vbase_offset,将vbase_offset的值(这里是16)存入%rdi addq %rbx, %rdi ; 这几句和上面相同,不再赘述 movq (%rdi), %rax call *16(%rax)
复制代码 下面给出 Child 对象及其vtable的内存布局,方便读者对照着理解上面的汇编代码。

▲ 图6 Child 对象及其vtable的内存布局
两种constructor
在上文中,一共出现了两种构造函数, complete object constructor 和 base object constructor 。顾名思义, complete object constructor 用来构造完整对象,它不仅会构造自己,还会构造其基类;而 base object constructor 是在 complete object constructor 中被调用的,用来构造基类子对象(base object)。
当 complete object constructor 调用 base object constructor 时,会传递两个参数,第一个是指向基类子对象的this指针,第二个是基类子对象vptr的地址(注意是vptr的地址而不是vptr),这个地址就是VTT中的某个条目。在 base object constructor 中,会使用第二个参数为基类子对象设置虚表指针,还会使用第二个参数寻址虚基类的vptr,为虚基类子对象设置虚表指针。
以上过程,在上文的代码中得到了完整的体现,读者可以配合代码来加深理解。
vcall_offset详解
考虑下面的代码:- Child *p_child = new Child;Parent1 *p_parent1_sub = p_child;GrandParent *p_gp1 = p_parent1_sub;p_gp1->foo();
复制代码 由于 Parent1 重写了 GrandParent 的虚函数,根据C++多态特性,这里应该调用 Parent1::foo() ,但此时 this 指向的是虚基类子对象 GrandParent ,并不是 Parent1 ,因此需要先调整 this 指针再调用正确的函数,那么,谁来做这件事情?怎么做?答案是 virtual trunk to Parent1::foo() 函数利用 vcall_base 来做。让我们通过汇编代码一探究竟。- virtual thunk to Parent1::foo(): movq (%rdi), %r10 ; %rdi指向虚基类子对象,这里是取出虚基类的虚表指针,存入%r10 addq -32(%r10), %rdi ; 如图6,虚表指针减32,正好是foo()函数对应的vcall_offset jmp .LTHUNK2 ; .LTHUNK2是Parent1::foo()的别名
复制代码 现在我们知道了,由派生类访问基类,靠的是 vbase_offset ,由基类访问派生类,靠的是 vcall_offset 。
具体地,以图6为例:
1. vcall_offset 存在于虚基类的vtable中。
2. 与虚基类中的虚函数一一对应,每个虚函数对应一个 vcall_offset ,只有一个例外——两个虚析构函数对应一个 vcall_offset 。
3. vbase_offset 在vtable中的排列顺序,与虚函数的排列顺序正好相反。以图6为例,从低地址到高地址,虚函数排布依次是两个 virtual trunk to Child::~Child() 、 virtual trunk to Parent1::foo() 、 virtual trunk to Parent2::zoo() ,与此相对, vcall_offset 的排布依次对应 virtual trunk to Parent2::zoo() 、 virtual trunk to Parent1::foo() 、两个 virtual trunk to Child::~Child() 。
为什么需要多个 vcall_offset ?因为重写各个虚函数的派生类可能是不一样的, this 指针需要调整到不同的派生类,偏移量也是不一样的。例如,对于虚析构函数,根据多态性,应该调用完整类型 Child 的析构函数,因此 this 指针应该偏移-32以便指向完整类型;对于 foo() ,因为被 Parent1 重写了,所以 this 指针需要指针 Parent1 子对象,偏移量也是-32;对于 zoo() ,因为被 Parent2 重写了,所以 this 指针需要指向 Parent2 子对象,偏移量就变成了-16。
特别的,如图5所示,当 Parent1 作为完整对象时,由于 Parent1 并没有重写 zoo() ,因此调用的仍然是 GrandParent::zoo() ,不需要调整 this 指针,因此 vcall_offset 条目被置0。
有读者可能要问了,如果 Parent2 也重写了 foo() ,结果会怎么样呢?结果会编译报错。因为会产生歧义,编译器不允许这么做。读者可以自行试验。
总结
本文首先以简单虚拟继承为例,向读者展示了在虚继承条件下,即使没有虚函数,也会存在虚函数表,用来记录 this 指针调整、动态类型转换等所需的信息。接下来,以菱形继承为例,详细介绍了VTT以及的construction vtable,深入探讨了该结构存在的原因以及在对象构造/析构中的作用。最后,详细讲解了虚析构函数、构造函数、 vbase_offset 和 vcall_offset 等与虚继承相关的虚表条目。
由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。
备注
[1] 之所以说是“恰好”,是因此编译器将内存程序运行的结构紧凑排布,恰好将VTT排在了 Derived 的vtable的后面。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |