vtale 内存布局分析
虚函数表指针与虚函数表布局
考虑如下的 class:
class A { public: int a; virtual void f1() {} virtual void f2() {} }; int main() { A *a1 = new A(); return 0; }
首先明确,sizeof(A)的输出是 16,因为:class A 中含有一个 int 是 4 字节,然后含有虚函数,所以必须含有一个指向 vtable 的 vptr,而 vptr 是 8 字节,8 + 4 = 12,对齐到 8 的边界,也就是 16
上述 class 的 AST record layout 如下:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8]
可以证明对齐边界为 8 字节
需要注意的是:由于含有指针,而 64 位系统,指针为 8 字节,所以对齐边界是 8
虚函数表指针 vptr
为了完成多态的功能,现代的 C++编译器都采用了表格驱动的对象模型,具体来说,所有虚函数的地址都存放在一个表格之中,而这个表格就被称为虚函数表vtable
,这个虚函数表的地址被存在放类中,称为虚函数表指针vptr
使用 clang 导出上述 class A 的对象布局,有如下输出:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8]
可以看到,在 class A 的对象布局中,第一个就是 vptr(8 字节)
虚函数表 vtable
利用 clang 的导出虚函数表的功能,可以看到上述 class A 的虚函数表具体内容如下:
Vtable for 'A' (4 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() VTable indices for 'A' (2 entries). 0 | void A::f1() 1 | void A::f2()
需要注意的是:-- (A, 0) vtable address -- 的意思是,class A 所产生的对象的 vptr 指向的就是这个地址
我们经常所说的vtable仅仅含有虚函数的地址,实际上,这不是完整的vtable
一个完整的 vtable,有以下内容(虚函数表中的内容被称为条目
或者实体
,另外并不是所有的条目都会出现,但是如果出现,一定是按照下面的顺序出现):
- virtual call (vcall) offsets:用于对虚函数执行指针调整,这些虚函数在虚基类或虚基类的子对象中声明,并在派生自虚基类的类中重写
- virtual base (vbase) offsets:用来访问某个对象的虚基
- offset to top:记录了对象的这个虚函数表地址偏移到该对象顶部地址的偏移量
- typeinfo pointer:用于 RTTI
- vitual function pointers:一系列虚函数指针
各种情况下的 vtable 布局
1 单一继承
下面讨论,单一继承情况下,虚函数表里面各种条目的具体情况,考虑如下代码:
class A { public: int a; virtual void f1() {} virtual void f2() {} }; class B : public A { public: int b; void f1() override {} }; int main() { A *a1 = new A(); B *b1 = new B(); return 0; }
首先需要明确的是:sizeof(A)与 sizeof(B)的大小:
- sizeof(A):4 + 8 = 12,调整到 8 的边界,所以是 16
- sizeof(B):4 + 4 + 8 = 16,不需要进行边界对齐,所以也是 16
利用 clang 查看 class A 与 class B 的所产生的对象 a1 与 b1 的布局,有如下输出:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] // 对于b1来说:在构造b1时,首先需要构造一个A父类对象,所以b1的布局最开始上半部分是一个A父类对象 // 但是b1中的 vtable pointer指向的是class B的虚表 *** Dumping AST Record Layout 0 | class B 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 12 | int b | [sizeof=16, dsize=16, align=8, | nvsize=16, nvalign=8]
利用 clang 查看 class A 与 class B 的虚函数表内容,有如下输出:
Vtable for 'A' (4 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() VTable indices for 'A' (2 entries). 0 | void A::f1() 1 | void A::f2() Vtable for 'B' (4 entries). 0 | offset_to_top (0) 1 | B RTTI -- (A, 0) vtable address -- -- (B, 0) vtable address -- 2 | void B::f1() 3 | void A::f2() VTable indices for 'B' (1 entries). 0 | void B::f1()
在 class B 的虚函数表内容中,有如下两条:
-- (A, 0) vtable address --
-- (B, 0) vtable address --
意思是:
- 如果以 A 类型的引用或者指针来看待 class B 的对象,那么此时的 vptr 指向的就是-- (A, 0) vtable address --
- 如果以 B 类型的引用或者指针来看待 class B 的对象,那么此时的 vptr 指向的就是-- (B, 0) vtable address --
虽然在上述里例子中,这两个地址是相同的,这也意味着单链继承的情况下,动态向下转换和向上转换时,不需要对 this 指针的地址做出任何修改,只需要对其重新“解释”
(这里需要说明一下:指针或者引用的类型,真正的意义是影响编译器如何解释或者说编译器如何看待该指针或者引用指向的内存中的数据)
此处还有另一种情况,即 class A 不含有虚函数,而 class B 含有虚函数,且 class B 继承于 class A:
class A { public: int a; }; class B : public A { public: int b; virtual void f1() {} }; int main() { A *a1 = new A(); B *b1 = new B(); return 0; }
打印 class A 与 class B 的对象布局如下:
*** Dumping AST Record Layout 0 | class A 0 | int a | [sizeof=4, dsize=4, align=4, | nvsize=4, nvalign=4] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | class A (base) 8 | int a 12 | int b | [sizeof=16, dsize=16, align=8, | nvsize=16, nvalign=8]
在这种情况下,把一个 derived class object 指定给 base class 的指针或者引用,就需要编译器的介入了(编译器需要调整地址,因为 class B object 中多了一根 vptr)
但是这种情况很少出现,因为:如果一个类要作为基类,那么它的析构函数基本上都要是虚的,否则通过指向基类的指针删除对象将会触发未定义的行为
单一继承情况下的虚函数表所含条目也比较少,理解起来也很容易
2 多重继承
考虑如下代码:
class A { public: int a; virtual void f1() {} }; class B { public: int b; virtual void f2() {} }; class C : public A, public B { public: int c; void f1() override {} void f2() override {} }; int main() { A *a1 = new A(); B *b1 = new B(); C *c1 = new C(); return 0; }
首先,依然讨论一下 A,B,C 三个 class 的大小:
- sizeof(A):4 + 8 = 12,调整到 8 的边界,即 16
- sizeof(B):4 + 8 = 12,调整到 8 的边界,即 16
- sizeof(C):4 + 4 + 4 +8 + 8 = 28,调整到 8 的边界,即 32
这里有一个问题,为什么计算 C 的大小时,加了两次 8?因为这两个 8 是两个 vptr,那怎么 C 会有两根 vptr 呢,后面会进行解释,此处先不讨论
查看 class A、B、C 三个对象的布局,如下:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8]
查看 class A、B、C 的虚函数表的所有条目:
Vtable for 'A' (3 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() VTable indices for 'A' (1 entries). 0 | void A::f1() Vtable for 'B' (3 entries). 0 | offset_to_top (0) 1 | B RTTI -- (B, 0) vtable address -- 2 | void B::f2() VTable indices for 'B' (1 entries). 0 | void B::f2() Vtable for 'C' (7 entries). 0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2() 4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual VTable indices for 'C' (2 entries). 0 | void C::f1() 1 | void C::f2()
此时可以看到,在多重继承下,虚函数表多出了许多单一继承没有的条目,接下来进行仔细讨论
2.1 为什么 C 的布局中有两个 vptr?
与单链继承不同,由于 A 和 B 完全独立,它们的虚函数没有顺序关系,即 f1 和 f2 有着相同对虚表起始位置的偏移量,所以不可以按照偏移量的顺序排布;并且 A 和 B 中的成员变量也是无关的,因此基类间也不具有包含关系;这使得 A 和 B 在 C 中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数表索引
2.2 class C 对象的内存布局中 primary base 是何意义?
再次关注一下 class C 的对象的内存布局:
*** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8]
已经知道 class C 是 public 方式继承了 class A 与 class B,而 class A 被标记为primary base
,其意义是:class C 将 class A 作为主基类
,也就是将 class C 的虚函数并入
class A 的虚函数表之中
2.3 多重继承情况下,class C 的虚函数表 vtable 的特点?
多重继承情况下,class C 的虚函数表内容如下:
Vtable for 'C' (7 entries). 0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2() 4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual VTable indices for 'C' (2 entries). 0 | void C::f1() 1 | void C::f2()
可以看到,class C 的整个虚函数表其实是两个虚函数表拼接而成
(这也就对应了 class C 为什么由两个 vptr)
一步步分析,先看上半部分的虚函数表:
0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2()
前面已经提到过,class C 会把 class A 当作主基类
,并把自己的虚函数并入到 class A 的虚函数表之中,所以,可以才会看到如上的内容
所以,class C 中的一根 vptr 会指向这个虚函数表
再看下半部分的虚函数表:
4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual
注意,此时的 offset_to_top 中的偏移量已经是 16 了
之前说过,offset_to_top 的意义是:将对象从当前这个类型转换为该对象的实际类型的地址偏移量
在多继承中,以 class A、B、C 为例,class A 和 class B 以及 class C 类型的指针或者引用都可以指向 class C 类型的实例,比如:
C cc = new C(); B &bb = cc; bb.f1(); // 我们知道,由于多态,此时实际调用的class C中的虚函数f1(),即相当于cc.f1() // 回顾class C的对象的内存布局 // 当我们用 B类型的引用接收cc对象时,this指针相当于指在了`16 | class B (base)`这个地方,要想实现多态,需要将this指针向上偏移16个字节,这样this指针才能指向cc对象的起始地址,编译器才能以C类型来解释cc这个对象而不会出错 *** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8]
在多继承中,由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this 指针的偏移量也不相同,且由于多态的特性,cc 的实际类型在编译时期是无法确定的;那必然需要一个东西帮助我们在运行时期确定 cc 的实际类型,这个东西就是offset_to_top
。通过让this指针
加上offset_to_top
的偏移量,就可以让 this 指针指向实际类型的起始地址
class C 下半部分的虚函数表还有一个值得注意的地方:
6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual
意思是,当以 B 类型的指针或者引用接受了 class C 的对象并调用 f2 时:需要将 this 指针调整-16 个字节,然后再进行调用(这跟上面所说的一样,将 this 向上调整 16 个字节就是让 this 指向 class C 对象的起始地址,从而编译器会以 class C 这个类型来看待 this 指针),然后再调用 f2,也就确保了调用的是 class C 的虚函数表中自己的 f2
3 虚拟继承
首先考虑如下代码中的 class A、B、C、D:
class A { public: int a; virtual void fa() {} }; class B : public virtual A { public: int b; virtual void fb() {} }; class C : public virtual A { public: int c; virtual void fc() {} }; class D : public B, public C { public: int c; void fa() override {} virtual void fd() {} }; int main() { A *a1 = new A(); B *b1 = new B(); C *c1 = new C(); D *d1 = new D(); return 0; }
class B、C 都是以虚拟继承
的方式继承 class A
对于编译器来说,要支持虚拟继承实在要花费很大的一番功夫,因为编译器不仅需要在 class D 中只保存一份 class A 的成员变量,还要确保多态行为的正确性
还是先打印出相应的对象布局以及 vtable 布局:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class C 0 | (C vtable pointer) 8 | int c 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class D 0 | class B (primary base) 0 | (B vtable pointer) 8 | int b 16 | class C (base) 16 | (C vtable pointer) 24 | int c 28 | int c 32 | class A (virtual base) 32 | (A vtable pointer) 40 | int a | [sizeof=48, dsize=44, align=8, | nvsize=32, nvalign=8] Vtable for 'A' (5 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() 4 | void A::f3() VTable indices for 'A' (3 entries). 0 | void A::f1() 1 | void A::f2() 2 | void A::f3() Vtable for 'B' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | B RTTI -- (A, 16) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'B' (1 entry). A | -24 Thunks for 'void B::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void B::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'B' (3 entries). 0 | void B::f1() 1 | void B::f2() 2 | void B::fb() Vtable for 'C' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | C RTTI -- (C, 0) vtable address -- 3 | void C::f1() 4 | void C::f2() 5 | void C::fc() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | C RTTI -- (A, 16) vtable address -- 11 | void C::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void C::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'C' (1 entry). A | -24 Thunks for 'void C::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'C' (3 entries). 0 | void C::f1() 1 | void C::f2() 2 | void C::fc() Vtable for 'D' (21 entries). 0 | vbase_offset (32) 1 | offset_to_top (0) 2 | D RTTI -- (B, 0) vtable address -- -- (D, 0) vtable address -- 3 | void D::f1() 4 | void D::f2() 5 | void B::fb() 6 | void D::fd() 7 | vbase_offset (16) 8 | offset_to_top (-16) 9 | D RTTI -- (C, 16) vtable address -- 10 | void D::f1() [this adjustment: -16 non-virtual] 11 | void D::f2() [this adjustment: -16 non-virtual] 12 | void C::fc() 13 | vcall_offset (0) 14 | vcall_offset (-32) 15 | vcall_offset (-32) 16 | offset_to_top (-32) 17 | D RTTI -- (A, 32) vtable address -- 18 | void D::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 19 | void D::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 20 | void A::f3() Virtual base offset offsets for 'D' (1 entry). A | -24 Thunks for 'void D::f1()' (2 entries). 0 | this adjustment: -16 non-virtual 1 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void D::f2()' (2 entries). 0 | this adjustment: -16 non-virtual 1 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'D' (3 entries). 0 | void D::f1() 1 | void D::f2() 3 | void D::fd() Construction vtable for ('B', 0) in 'D' (14 entries). 0 | vbase_offset (32) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-32) 8 | vcall_offset (-32) 9 | offset_to_top (-32) 10 | B RTTI -- (A, 32) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Construction vtable for ('C', 16) in 'D' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | C RTTI -- (C, 16) vtable address -- 3 | void C::f1() 4 | void C::f2() 5 | void C::fc() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | C RTTI -- (A, 32) vtable address -- 11 | void C::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void C::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3()
先分析 classB、C;由于 class B、C 基本相同,所以此处只分析 class B,先单独看 class B 的对象的内存布局:
*** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8]
对比可以看出,在 class B 的对象的内存布局上,虚拟继承与普通继承的最大区别在于:虚拟继承下,class B 的内存布局不再是 class A 的内容在最前面然后紧接着 class B 的内容,而是先是 class B 的内容,然后再接着 class A 的内容
这种布局看起来就像在 class B 对象的后面接上一个 class A 对象,观察一下左边显示的偏移量:
可以看到 class A 的 vptr 的偏移量为 16,在 class A 之前,就是 class B 的内容了,class B 只含有一根 vptr(8 字节)+一个 int(4 字节)=12 字节,然而 class A 的 vptr 的偏移量却是 16,也就是说,class B 的对象完成了边界调整(12 调整到 16),然后再在后面拼接上 class A 的对象
再分析一下 class B 的 vtable:
Vtable for 'B' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | B RTTI -- (A, 16) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'B' (1 entry). A | -24 Thunks for 'void B::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void B::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'B' (3 entries). 0 | void B::f1() 1 | void B::f2() 2 | void B::fb()
vbase_offset (16):用来访问虚基类子对象的偏移量(结合 class B 的对象内存布局观察)
vcall_offset(-16):当 class A 的引用 a 实际接受的是 class B 对象,然后执行 a→f1()(或 f2),由于 f1(或 f2)在 class B 中被重写过了,而此时的 this 表示的是一个 class A 类型的对象,所以需要对 this 进行调整才能正确的调用到 B::f1()(或 f2),this 如何调整?靠的就是这个 vcall_offset(-16)即将 this 指针向上调整 16 个字节,然后再调用 f1()(或 f2)
vcall_offset(0):当 class A 的引用 a 实际接受的是 class B 对象,然后执行 a→f3(),由于 f3 并没有被 class B 重写,所以此时的 this 不需要进行调整,所以 vcall_offset 为 0
对于 class D 的 vtable 来说,只是变得更加复杂而已,其中的条目在之前已经全部介绍过了,可以自行进行分析
PS:个人分析,不对的地方请指正