导言
本篇探究普通多继承(没有虚继承)下的虚函数表。示例代码如下:- #include <iostream>
- struct X {
- virtual ~X() {}
- virtual void zoo() { std::cout << "X::zoo()\n"; }
- int x_data = 100;
- };
- struct A : public X {
- A() { this->funA(); }
- virtual void funA() { std::cout << "A::funA()\n"; }
- virtual ~A() {}
- int a_data = 200;
- };
- struct B : public X {
- B() { this->funB(); }
- virtual void funB() { std::cout << "B::funB()\n"; }
- virtual ~B() {}
- int b_data = 300;
- };
- struct C : public A, public B {
- virtual void foo() {}
- virtual void funA() override { std::cout << "C::funA()\n"; }
- virtual void funB() override { std::cout << "C::funB()\n"; }
- virtual ~C() {};
- int c_data = 400;
- };
- int main(int argc, char *argv[])
- {
- C *p = new C;
- p->foo();
- delete p;
- return 0;
- }
复制代码 最终的内存布局如下:
内存布局规律
可以看到,在对象和vtable的内存布局上:
- 按声明顺序依次存放各个基类子对象的虚表指针和非static数据成员,最后存放派生类自己的非static数据成员。
- 完整对象和第一个基类子对象(也称为primary base class)共用一个虚表指针,其余基类子对象各有一个虚表指针。
- 基类子对象的虚表指针指向的是完整对象的vtable,而不是它们自己的vtable。以基类子对象B为例,虚表指针指向了vtable for C + 80的位置,而不是vtable for B + 16。这是实现RTTI的关键。从 0x555555557cd0 到 0x555555557cff ,是B作为C的基类子对象时对应的虚函数表。其中,top_offset是-16,因为从基类子对象B的首地址到完整对象C的首地址,正好差了16字节。typeinfo指针也指向了C类的typeinfo对象,而不是B类自己的,这是因为现在的完整对象是C类型而不是B类型。这样,B类的指针或引用指向C类实例时,通过 typeid 获取到的就是类型C(即实际对象的类型)了,在 dynamic_cast 中,也能通过基类子对象的指针获取到完整对象的类型。
- 完整对象C的vtable的内存布局如下:首先是第一个直接基类A的虚函数表,其中typeinfo信息是C类的,并且,如果C类重写了A类某些虚函数,对应条目要换成C类版本的(比如这里的 C::funA() )。接着是C类自己的虚函数(比如这里的 C::foo() ),再接下来是C类重写的其它基类的虚函数,按声明顺序排列(比如这里的 C::funB() )。剩下的,就是其余各个基类的虚函数表,包括了top_offset、typeinfo(都指向C类typeinfo信息)、没有被override的虚函数、被override的虚函数(实际上是non-virtual thunk to的形式,后面详细介绍)等信息。
non-virtual thunk to function
在C类的vtable中,出现了之前没有介绍过的条目 non-virtual thunk to C::~C() 和 non-virtual thunk to C::funB() 。这是什么呢?别急,先考虑下面的问题。- main:
- pushq %r14
- movl $40, %edi # 分配40字节的内存
- pushq %rbx
- subq $8, %rsp
- call operator new(unsigned long) # 调用new操作符分配内存
- movq %rax, %rdi # %rax中保存了new返回的地址,把该地址存入%rdi,作为调用C类构造函数的第一个参数(即this指针)
- movq %rax, %rbx # 保存到%rbx是为了析构时用
- call C::C() [complete object constructor] # 调用C类的对象构造函数
复制代码 请问, pb 和 pc 相等吗?不相等!这里进行了隐式类型转换, pb 指向了完整对象中的基类子对象,以上图为例的话,就是 pc = 0x55555556aeb0 , pb = 0x55555556aec0 , pb 比 pc 多16字节。现在,假设要执行 pb->funcB(); ,根据多态性,应该执行 C::funcB() 才对,但现在 this 指针指向的却不是完整对象C,而是基类子对象B,不配套了。因此,需要在调用 C::funcB() 前调整 this 指针的值,non-virtual thunk函数就是做这个事情的,因为是非虚继承,因此是non-virtual。让我们来看看 non-virtual thunk to C::funcB() 做了什么?- C::C() [base object constructor]:
- pushq %rbx
- movq %rdi, %rbx
- call A::A() [base object constructor]
- leaq 16(%rbx), %rdi # 调整this指针,为调用B::B()做准备call B::B() [base object constructor]
- movq $vtable for C+16, (%rbx) # 覆盖A类的虚表指针
- movq $vtable for C+80, 16(%rbx) # 覆盖B类的虚表指针
- movl $400, 32(%rbx) # 初始化c_data
- popq %rbx
- ret
- X::X() [base object constructor]:
- movq $vtable for X+16, (%rdi) # 将X类的虚表指针放到内存的前8个字节(this指针指向的位置)
- movl $100, 8(%rdi) # 在接下来的内存中存入X::x_dataret
- A::A() [base object constructor]:
- pushq %rbx
- movq %rdi, %rbx # 保存首地址(this指针)
- call X::X() [base object constructor]
- movq $vtable for A+16, (%rbx) # 将A类的虚表指针放到内存的前8个字节(this指针指向的位置),这覆盖了之前X的虚表指针
- movq %rbx, %rdi # 将this放入%rbi,
- movl $200, 12(%rbx) # 在接下来的内存中存入A::a_datacall A::funA()
- popq %rbx
- ret
复制代码
其中 .LTHUNK0 是 C::funcB() 的别名,可见,所谓的thunk函数,就是调整指针后再执行真正要执行的函数。
__vmi_class_type_info
ltanium C++ ABI规定,表示typeinfo的类一共有3个:
- __class_type_info :用于没有基类的多态类,比如本文中的类X。
- __si_class_type_info :如果一个类只有一个基类,并且该基类是公共的(public继承)、非虚的(不是虚继承),那么,这个类的typeinfo信息就用 __si_class_type_info 表示,比如本文中的类A和类B。
- __vmi_class_type_info :用于除上述两种情况外的所有其它情况,比如多继承、虚拟继承等。
我们在之前的文章中已经详细介绍过前两个类,现在重点介绍 __vmi_class_type_info ,它的定义如下(仅保留数据成员,完整定义请参考源文件):- C::C() [base object constructor]:
- pushq %rbx
- movq %rdi, %rbx
- call A::A() [base object constructor]
- leaq 16(%rbx), %rdi
- call B::B() [base object constructor]
- => movq $vtable for C+16, (%rbx) # 覆盖A类的虚表指针
- movq $vtable for C+80, 16(%rbx) # 覆盖B类的虚表指针
- movl $400, 32(%rbx) # 初始化c_data
- popq %rbx
- ret
复制代码 让我们依次解释各个成员的含义,推荐读者对照着前面的图示来理解。
__flags & __base_count
__flags 表示继承的类型,是 enum __flags_masks 中各个值按位或的结果。对文本中的例子而言,因为是非菱形继承,有重复的基类,因此是 __non_diamond_repeat_mask ,即1。
__base_count 表示直接基类的个数,对文本中的例子而言,C有两个直接基类A和B,因此 __base_count 是2。
__base_info
__base_info 是一个数组,保存了各个直接基类的信息,一共有几个基类,数组中就有几个元素。正如注释所说,这里使用了称为trailing array struct hack的编程技巧,简单说就是可变长度的数组,感兴趣的读者请自行学习。让我们来看看 __base_class_type_info 里有什么。- C *pc = new C;
- B *pb = pc;
复制代码
其中, __class_type_info 指向了基类的typeinfo信息,而 __offset_flags 同时包含了标志位和offset信息。 __hwm_bit = 2 (hwm是High Water Mark的意思)表明了最低的2 bit用来储存继承关系,即 __virtual_mask = 0x1 和 __public_mask = 0x2 。 __offset_shift = 8 表示从第8 bit开始,储存的是基类子对象在完整对象中的偏移量。而第2 bit到第7 bit,目前暂未使用,留待未来扩展。以B类子对象为例,它的 __offset_flags 字段是0x1002,最低2 bit是 10 ,表明是public继承,不是virtual继承。 0x1002 >> 8 = 0x10 ,表明子对象到完整对象的偏移量是16,正好和top_offset对得上。
总结
- 在完整对象的构造/析构过程中,其虚表指针是随着构造/析构阶段不断变化的,正在构造/析构哪个对象,该对象就相当于“完整对象”,虚表指针就指向该对象的vtable。因为C++标准规定,在对象的构造/析构期间,对象的动态类型被认为是正在构造/析构的那个类,而不是最终派生出的完整类型。
- 在对象的构造/析构阶段,如果在构造函数/析构函数中直接或者间接调用虚函数,就相当于静态调用,即只能调用当前正在构造/析构的那个类自己或者其基类的虚函数,不能调用其派生类的虚函数。例如,在本文例子中,当构造到子对象A时,只能调用A类或者其基类X类中的虚函数,不能调用C类中的虚函数。(参考资料)
- 多重继承下的对象和vtable遵循特定的内存布局,详见上文。
- 当派生类指针赋值给基类指针时,会发生隐式转换,基类指针的值是基类子对象的首地址。当用基类指针调用派生类虚函数时,需要调整 this 指针,这一步由 non-virtual thunk to xxx 函数或者 virtual thunk to xxx 函数完成。
- 多重继承或虚拟继承下,类型的typeinfo信息由 __vmi_class_type_info 定义,该类记录了类的继承方式、基类的类型、基类子对象到完整对象的偏移量等RTTI需要的信息。
由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |