虚函数表与多态
虚函数表与多态,是C++开发人员终究要面对的问题。
虽然很久没写C++了,此处还是将其整理一下进行记录。
编译器信息:
1 类空间
class Empty { public: Empty() = default; ~Empty() = default; void hello() { std::cout << "hello world" << std::endl; } }; // sizeof(Empty) = 1
首先需要明确,空类(包含非虚函数),其大小为1。
为了能将class实例放到数组里,空类必须具有大小,否则数组sizeof将是灾难。
不过空类作为基类时,为了对齐可能占用4各字节或以上,因此编译器有空基类优化。
空基类优化:令非静态数据成员、无虚函数的基类实际占用0字节。
现在,我们开始加入一个虚函数,再次查看类大小。
class Empty { public: Empty() = default; ~Empty() = default; void hello() { std::cout << "hello world" << std::endl; } virtual void virtual_test() {} }; // sizeof(Empty) = 8
加入虚函数后,类大小从1字节增加至为8字节。
这是因为,编译器在类中隐式插入了虚函数表指针(void *vptr
),指针大小为8字节。
关于编译器在背后做的事情,建议看<
2 虚函数表指针(vptr)与虚函数表(vtbl)
对于包含虚函数的类,编译器会为类创建相应的虚函数表(vtbl)。
虚函数表中,主要存放类所对应的虚函数地址。
在编译期间,编译器会在构造函数中,对vptr进行赋值,数值为vtbl的地址。
伪代码如下所示:
class Empty { public: Empty() { vtpr = (void*)&Empty::vtbl; } }
改进一些,我们修改Empty类如下所示:
class Empty { public: Empty() = default; virtual ~Empty() {} virtual void virtual_func1() {} virtual void virtual_func2() {} public: int m1 = 0x01020304, m2 = 0x04030201; }; int main() { Empty empty; std::cout << empty.m1 << std::endl; return 0; }
主要改进就是添加成员变量m1,m2,以及添加若干函数(包含虚函数)。
使用gdb查看Empty实例的内存布局,具体如下所示:
由上图可知,Empty实例的内存布局为:
3 多态调用
C++的三大特性是封装,继承以及多态,其中多态必须依靠虚函数实现。
通俗点说,如果通过调用虚函数表指针(vtpr)找到虚函数表(vtbl)的入口并执行虚函数,则程序使用到了多态。
举个例子:
class Base { public: virtual void virtual_func() {} }; int main() { Base *a = new Base(); a->virtual_func(); // 多态调用 Base b; b.virtual_func(); // 非多态调用 Base *c = &b; c->virtual_func(); // 多态调用 return 0; }
为了验证注释中的观点,我们使用汇编代码进行佐证:
上图可以看出,三次调用virtual_func
,汇编代码存在较大不同。
原因是a,c实例调用virtual_func
相对于b实例调用virtual_func
,多了需要去虚表(vtbl)中查找virtual_func
函数入口的过程。
4 内存布局
下文将分别从单继承,多继承以及菱形继承三点阐述虚表的内存布局(使用g++
导出内存布局)。
4.1 单继承
class A { int ax; virtual void f0() {} }; class B : public A { int bx; virtual void f1() {} }; class C : public B { int cx; void f0() override {} virtual void f2() {} };
内存布局如下所示:
Vtable for A A::vtable for A: 3 entries 0 (int (*)(...))0 // 类型转换偏移量 8 (int (*)(...))(& typeinfo for A) // 运行时类型信息(Run-Time Type Identification,RTTI) 16 (int (*)(...))A::f0 // 虚函数f0地址 Class A size=16 align=8 base size=12 base align=8 A (0x0x7f753a178960) 0 vptr=((& A::vtable for A) + 16) Vtable for B B::vtable for B: 4 entries 0 (int (*)(...))0 // 类型转换偏移量 8 (int (*)(...))(& typeinfo for B) // 运行时类型信息(Run-Time Type Identification,RTTI) 16 (int (*)(...))A::f0 // 虚函数f0地址(未override基类函数,因此继承自A) 24 (int (*)(...))B::f1 // 虚函数f1地址 Class B size=16 align=8 base size=16 base align=8 B (0x0x7f753a00e1a0) 0 vptr=((& B::vtable for B) + 16) A (0x0x7f753a178a20) 0 primary-for B (0x0x7f753a00e1a0) Vtable for C C::vtable for C: 5 entries 0 (int (*)(...))0 // 类型转换偏移量 8 (int (*)(...))(& typeinfo for C) // 运行时类型信息(Run-Time Type Identification,RTTI) 16 (int (*)(...))C::f0 // 虚函数f0地址 24 (int (*)(...))B::f1 // 虚函数f1地址(未override基类函数,因此继承自B) 32 (int (*)(...))C::f2 // 虚函数f2地址 Class C size=24 align=8 base size=20 base align=8 C (0x0x7f753a00e208) 0 vptr=((& C::vtable for C) + 16) B (0x0x7f753a00e270) 0 primary-for C (0x0x7f753a00e208) A (0x0x7f753a178ae0) 0 primary-for B (0x0x7f753a00e270)
此处需要明确,Class A/B/C
均有对应的虚表。
虚表主要包含三类信息:
4.2 多继承
class A { int ax; virtual void f0() {} }; class B { int bx; virtual void f1() {} }; class C : public A, public B { virtual void f0() override {} virtual void f1() override {} };
得到类内存布局如下所示:
// 因为类A与类B比较简单,因此省略内存布局(可参考单继承内存布局) Vtable for C C::vtable for C: 7 entries 0 (int (*)(...))0 8 (int (*)(...))(& typeinfo for C) 16 (int (*)(...))C::f0 24 (int (*)(...))C::f1 32 (int (*)(...))-16 // 类型转换偏移量 40 (int (*)(...))(& typeinfo for C) // 运行时类型信息(Run-Time Type Identification,RTTI) 48 (int (*)(...))C::non-virtual thunk to C::f1() Class C size=32 align=8 base size=28 base align=8 C (0x0x7f9ce2bde310) 0 vptr=((& C::vtable for C) + 16) A (0x0x7f9ce2d37ae0) 0 primary-for C (0x0x7f9ce2bde310) B (0x0x7f9ce2d37b40) 16 vptr=((& C::vtable for C) + 48)
代码中,类C继承自类A以及类B,内存布局发生了较大的变化(添加了末尾三行)。
g++的内存布局比较晦涩,使用clang导出内存布局(基本一致),会比较直观:
*** Dumping AST Record Layout 0 | struct C 0 | struct A (primary base) 0 | (A vtable pointer) 8 | int ax 16 | struct B (base) 16 | (B vtable pointer) 24 | int bx | [sizeof=32, dsize=28, align=8, | nvsize=28, nvalign=8]
由clang的内存布局可知,类C的实例中包含类A与类B的虚指针。
这是因为A与B完全独立,虚函数f0与f1之间没有顺序关系,相对于基类有着相同的起始位置偏移量。
因此在类C中,类A与类B的虚表信息必须保存在两个不相交的区域中,使用两个虚指针对其进行索引。
C Vtable (7 entities) +--------------------+ struct C | offset_to_top (0) | object +--------------------+ 0 - struct A (primary base) | RTTI for C | 0 - vptr_A -----------------------------> +--------------------+ 8 - int ax | C::f0() | 16 - struct B +--------------------+ 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() | +--------------------+
上图比较形象的描绘了虚指针,对应虚表的内容。
首先解释offset_to_top
: 基类转换到派生类时,this指针加上偏移量即可获得实际类型的地址。
至于Thunk
:
(1) 在B &b = c的场景中,引用的起始地址在C+16处,如果直接调用f1时,会因为this指针多了16字节的偏移量导致错误;
(2) Thunk提示this指针根据offset_to_top减去16字节偏移量,继而调用f1函数。
Thunk解释说明,当基类引用持有派生类实例时,调用相应虚函数,会利用到多态特性。
4.3 菱形继承
class A { public: virtual void foo() {} virtual void bar() {} private: int ma; }; class B : virtual public A { public: virtual void foo() override {} private: int mb; }; class C : virtual public A { public: virtual void bar() override {} private: int mc; }; class D : public B, public C { public: virtual void foo() override {} virtual void bar() override {} };
基类A中添加了成员变量ma,是因为类A中若不包含成员变量,派生类B/C/D会被优化,较难理解。
首先查看类B的内存布局:
*** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int mb 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int ma | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8]
需要注意,此时类B中包含两个虚指针,且类A的虚指针起始位置为B+16。
查看类B的虚表结构,如下所示:
Vtable for 'B' (10 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::foo() 4 | vcall_offset (0) 5 | vcall_offset (-16) 6 | offset_to_top (-16) 7 | B RTTI -- (A, 16) vtable address -- 8 | void B::foo() [this adjustment: 0 non-virtual, -24 vcall offset offset] 9 | void A::bar()
此时,虚表头部增加了vbase_offset
,这是因为在编译时,无法确定基类A在类B内存中的偏移量,因此需要在虚表中添加vbase_offset
,标记运行时基类A在类B内存中的位置。
此外,虚表中添加了两项vcall_offset
,这是应对使用虚基类A的引用调用类B实例的虚函数时,每一个虚函数相对于this指针的偏移量都可能不同,因此需要记录在vcall_offset中。
因此,当A引用调用B实例的A::bar函数时,因为this指针指向vptr_a,因此不需要进行调整;调用B::foo()时,因此foo函数被B重载,因此需要调整this指针指向vptr_b。
查看类D的内存布局:
*** Dumping AST Record Layout 0 | class D 0 | class B (primary base) 0 | (B vtable pointer) 8 | int mb 16 | class C (base) 16 | (C vtable pointer) 24 | int mc 32 | class A (virtual base) 32 | (A vtable pointer) 40 | int ma | [sizeof=48, dsize=44, align=8, | nvsize=28, nvalign=8]
此时,需要注意因为使用虚继承,所以类A在类D中只有一份,共拥有三个虚指针。
虚表内容相对较为复杂,不过基本可以参照类B的虚表进行解析,具体如下所示:
Vtable for 'D' (15 entries). 0 | vbase_offset (32) 1 | offset_to_top (0) 2 | D RTTI -- (B, 0) vtable address -- -- (D, 0) vtable address -- 3 | void D::foo() 4 | void D::bar() 5 | vbase_offset (16) 6 | offset_to_top (-16) 7 | D RTTI -- (C, 16) vtable address -- 8 | void D::bar() [this adjustment: -16 non-virtual] 9 | vcall_offset (-32) 10 | vcall_offset (-32) 11 | offset_to_top (-32) 12 | D RTTI -- (A, 32) vtable address -- 13 | void D::foo() [this adjustment: 0 non-virtual, -24 vcall offset offset] 14 | void D::bar() [this adjustment: 0 non-virtual, -32 vcall offset offset]
5 扩展
C++的虚表,以及运行时的内存模型是很复杂的问题,在编写的过程中也是不断的刷新自己的认知。
下面提供一些方式,dump出内存中对象的内存模型,和类型的虚表结构。
使用clang编译器:clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp
.
使用gcc编译器:
g++ -fdump-class-hierarchy -c main.cpp // g++ dump的内容比较晦涩,因此需要使用c++ filt导出具有可读性的文档 cat [g++导出的文档] | c++filt -n > [具有一定可读性的输出文档]
本文内存布局部分,参考于:https://zhuanlan.zhihu.com/p/41309205一文。
Copyright © 2004-2024 Ynicp.com 版权所有 法律顾问:建纬(昆明)律师事务所 昆明市网翼通科技有限公司 滇ICP备08002592号-4