虚函数表与多态的认知
虚函数表与多态
虚函数表与多态,是c++开发人员终究要面对的问题。
虽然很久没写c++了,此处还是将其整理一下进行记录。
编译器信息:
- gcc: gcc (debian 7.3.0-19) 7.3.0;
- clang: 7.0.1-8 (tags/release_701/final).
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字节。
关于编译器在背后做的事情,建议看<<深度探索c++对象模型>>(虽然看了就忘,但是比没看要好一些)。
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实例的内存布局为:
- vptr(红线部分,指向empty的虚表);
- m1,m2。
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
均有对应的虚表。
虚表主要包含三类信息:
- 类型转换偏移量;
- 运行时类型信息(run-time type identification,rtti);
- 虚函数地址(可以包含多项),具体信息详见注释部分。
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中。
- vcall_offset (0): 对应a::bar();
- vcall_offset (-16): 对应b::foo()。
因此,当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一文。
ps:
如果您觉得我的文章对您有帮助,请关注我的微信公众号,谢谢!