导言
本篇探究普通多继承(没有虚继承)下的虚函数表。示例代码如下:
#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;
}
构造过程
我们首先跟踪C类实例的构造过程,看看构造过程中都发生了什么。读者可以使用 g++ -g -O2 -fno-inline main.cpp -o main 命令编译上述代码,在 C *p = new C; 一句处打断点,然后单步执行汇编。这里我们给出Compiler Explorer中的汇编代码,因为它没有name mangling,更加易懂。
step1:分配内存,调用C::C()
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类的对象构造函数
step2:构造基类子对象
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
首先构造基类A,而A自己也有基类,所以先构造基类X,过程详见注释。构造完成后,内存布局如下:

基类X构造完成后,流程再次回到A类的构造函数中。我们看到, A::A() 将A类的虚表指针写入到了内存的前8个字节当中了,覆盖了之前A类的虚表指针!其实,这么做是合理的。假设我们在 X::X() 中调用虚函数,该使用那个版本的虚函数呢?自己的?还是派生类重写的?假设我们在 X::X() 中调用 typeid 操作符,该返回什么类型呢?X类型?A类型?C类型?在 X::X() 内部,它并不知道构造的是独立的X对象,还是某个完整对象的基类子对象(正如本例),而且,即便是基类子对象,调用派生类的虚函数也是有危险的。因为,此次时刻,正在构造基类子对象,派生类的部分还没有构造,派生类独有的数据成员自然也没有初始化,这个时候调用派生类的虚函数,如果该函数读写了派生类独有的数据成员,结果将是未定义的。因此,在 X::X() 中,只能调用X类自己版本的虚函数, typeid 也只能返回X类型(完整对象还没有构造完)。所以,在构造X类时,应当使用X类自己的虚函数表。
当流程再次回到 A::A() ,A的基类子对象X已经构造完了,因此,它可以使用使用自己的数据和函数,也可以使用基类子对象X的,所以,此时可以把虚表指针指向A类自己的虚函数表了。但还不能用派生类的虚函数,所以,当 A::A() 里调用 funA 时,生成的汇编代码直接是 call A::funA() ,相当于静态绑定。
还有一点需要注意,正常应该是先初始化数据成员(这里是 A::a_data ),再执行构造函数的函数体(这里是 this->funA(); )的,但上面的汇编代码,是反过来的,这是我们编译时开了 -O2 优化的缘故。
A::A() 执行完成后,对象布局如下。

至此,和构造一个独立的A对象没有区别。细心的读者可能发现了,在构造函数后面,有的标着complete object constructor,而有的标着base object constructor,在本例中,两者其实是同一个函数的别名。等到我们讲到虚拟继承时,再来看它们之间的区别。

构造完A类后,接着将 this 指针加上16字节,作为新的 this 指针,调用 B::B() ,开始构造B类子对象。这个过程和A类子对象的构造过程如出一辙,我们略过不表。完成后,对象内存布局如下。

step3:构造派生类部分
流程再次回到 C::C() ,因为所有基类子对象都已构造完毕,进入到构造完整对象阶段,可以放心得让虚表指针指向C类的虚函数表了。
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
最终的内存布局如下:

内存布局规律
可以看到,在对象和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() 。这是什么呢?别急,先考虑下面的问题。
C *pc = new C;
B *pb = pc;
请问, 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() 做了什么?
non-virtual thunk to C::funB():
subq $16, %rdi # this减去16,由指向基类子对象B改为指向完整对象C
jmp .LTHUNK0

其中 .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 ,它的定义如下(仅保留数据成员,完整定义请参考源文件):
// Type information for a class with multiple and/or virtual bases.
class __vmi_class_type_info : public __class_type_info
{
public:
unsigned int __flags; // Details about the class hierarchy.
unsigned int __base_count; // Number of direct bases.
// The array of bases uses the trailing array struct hack so this
// class is not constructable with a normal constructor. It is
// internally generated by the compiler.
__base_class_type_info __base_info[1]; // Array of bases.
// Implementation defined types.
enum __flags_masks
{
__non_diamond_repeat_mask = 0x1, // Distinct instance of repeated base. 非菱形继承,有重复的基类
__diamond_shaped_mask = 0x2, // Diamond shaped multiple inheritance.
__flags_unknown_mask = 0x10
};
};
让我们依次解释各个成员的含义,推荐读者对照着前面的图示来理解。
__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 里有什么。
class __base_class_type_info
{
public:
const __class_type_info* __base_type; // Base class type.
#ifdef _GLIBCXX_LLP64
long long __offset_flags; // Offset and info.
#else
long __offset_flags; // Offset and info.
#endif
enum __offset_flags_masks
{
__virtual_mask = 0x1,
__public_mask = 0x2,
__hwm_bit = 2,
__offset_shift = 8 // Bits to shift offset.
};
bool __is_virtual_p() const { return __offset_flags & __virtual_mask; }
bool __is_public_p() const { return __offset_flags & __public_mask; }
ptrdiff_t __offset() const { return static_cast<ptrdiff_t>(__offset_flags) >> __offset_shift; }
};
其中, __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需要的信息。
由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。