C++是一门支持面向对象编程(object-oriented Programming)的语言,继承和多态(Polymorphic)是其最重要的特性。 关于C++的继承和类内成员的各种内容在之前的文章中已经有了不少介绍,本篇文章主要是研究一下编译器对C++多态的一个实现方式:虚函数表 。 C++标准([IOS/IEC 14882:2014] )中写道:
Virtual functions support dynamic binding and object-oriented programming. A class that declares or inherits a virtual function is called a polymorphic class.
注意:C++标准并没有规定如何实现多态 ,所以编译器对多态的实现是Implementation-defined Behavior
,意思就是不同的编译器可能对多态的实现是不一样的,在不同的平台可能会无法得到相同的实验结果 。
所以有必要列出本篇文章中代码的编译环境,代码编译使用C++14标准(-std=c++14
):
1 2 3 4 5 6 C:\Users\imzlp\Desktop>clang -v clang version 7.0.0 (tags/RELEASE_700/final) Target: x86_64-w64-windows-gnu Thread model: posix gcc version 6.2.0 (x86_64-posix-seh-rev1, Built by MinGW-W64 project)
再次强调一遍:C++中多态是Implementation-defined Behavior.
本文会使用Clang生成IR和汇编代码来分析编译器的实现行为。 相关命令为:
1 2 3 4 5 6 7 8 $ clang++ -S -emit-llvm source.cpp $ clang++ -c -S source.cpp -o source.s $ clang++ -E source.cpp -o source_pp.cpp $ clang++ -cc1 -fdump-record-layouts source_pp.cpp
之前的文章:通过IR代码来分析C++代码语义 .
虽然一些文章说C++多态是虚函数表实现的balabala,但是没有看到能详细说清楚,到底虚函数表是怎么被初始化塞到类里面去的,这里先探讨这样一个问题:虚函数表是何时初始化的?
虚函数表是在何时初始化的? 虚函数表是什么时候放进类实例内的?从LLVM-IR分析一下下面这个简单的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class A {public : virtual void vfunc_one (int ) { std::cout<<"A::vfunc_one" <<std::endl; } virtual void vfunc_two (int ) { std::cout<<"A::vfunc_two" <<std::endl; } private : int ival; }; class B :public A{public : virtual void vfunc_one (int ) { std::cout<<"B::vfunc_one" <<std::endl; } virtual void vfunc_two (int ) { std::cout<<"B::vfunc_two" <<std::endl; } char cval; }; int main () { B bobj; return 0 ; }
来看一下A和B类型的对象布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 *** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int ival | [sizeof =16 , dsize=12 , align=8 , | nvsize=12 , nvalign=8 ] *** Dumping AST Record Layout 0 | class B 0 | class A (primary base) 0 | (A vtable pointer) 8 | int ival 12 | char cval | [sizeof =16 , dsize=13 , align=8 , | nvsize=13 , nvalign=8 ] %class .B = type { %class .A.base, i8, [3 x i8] } %class .A.base = type <{ i32 (...)**, i32 }> %class .A = type <{ i32 (...)**, i32, [4 x i8] }>
可以看到,在Clang的实现中,vptr是在对象空间的首部,是一个指针对象(在我的编译环境是8字节)。 A的内存布局是vptr(sizeof(void*)
)+ival(sizeof(int)
)+paading 4byte = 16byte. B的内存布局为:A的基类子对象(vptr(sizeof(void*)
)+ival(sizeof(int)
))+cval(sizeof(char)
)+padding 3byte = 16byte. 关于内存对齐的内容可以看我之前的文章:结构体成员内存对齐问题 .
上面的C++代码main函数内相关的LLVM-IR代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 ; Function Attrs: noinline norecurse nounwind optnone uwtable define dso_local i32 @main () #4 { %1 = alloca i32, align 4 %2 = alloca %class .B, align 8 store i32 0 , i32* %1 , align 4 call void @_ZN1BC2Ev(%class .B* %2 ) #3 ret i32 0 } ; Function Attrs: noinline nounwind optnone uwtable define linkonce_odr void @_ZN1BC2Ev(%class .B*) unnamed_addr #5 comdat align 2 { %2 = alloca %class .B*, align 8 store %class .B* %0 , %class .B** %2 , align 8 %3 = load %class .B*, %class .B** %2 , align 8 %4 = bitcast %class .B* %3 to %class .A* call void @_ZN1AC2Ev(%class .A* %4 ) #3 %5 = bitcast %class .B* %3 to i32 (...)*** store i32 (...)** bitcast (i8** getelementptr inbounds ({ [4 x i8*] }, { [4 x i8*] }* @_ZTV1B, i32 0 , inrange i32 0 , i32 2 ) to i32 (...)**), i32 (...)*** %5 , align 8 ret void } ; Function Attrs: noinline nounwind optnone uwtable define linkonce_odr dso_local void @_ZN1AC2Ev(%class .A*) unnamed_addr #5 comdat align 2 { %2 = alloca %class .A*, align 8 store %class .A* %0 , %class .A** %2 , align 8 %3 = load %class .A*, %class .A** %2 , align 8 %4 = bitcast %class .A* %3 to i32 (...)*** store i32 (...)** bitcast (i8** getelementptr inbounds ({ [4 x i8*] }, { [4 x i8*] }* @_ZTV1A, i32 0 , inrange i32 0 , i32 2 ) to i32 (...)**), i32 (...)*** %4 , align 8 ret void }
对vptr进行赋值的是这一行:
1 2 store i32 (...) ** bitcast (i8** getelementptr inbounds ({ [4 x i8*] }, { [4 x i8*] }* @_ZTV1B, i32 0 , inrange i32 0 , i32 2 ) to i32 (...)**) , i32 (...) *** %5, align 8
把_ZTV1B的地址赋值给vptr,_ZTV1B是vptr经过Name Mangling后的符号,可以通过c++filt
看到:
1 2 $ c++filt _ZTV1B vtable for B
综上,编译器是从构造函数里面(在对基类的构造函数调用之后,基类构造函数内也会对自己的vptr赋值)才对vptr执行初始化的,所以在基类的构造函数中调用虚函数,是没有多态行为的…
虚函数表的存储位置及从汇编分析其初始化 此部分是上一部分分析的拓展和扩充:
虚表指针是如何初始化的?
虚函数表是每个实例都有一份吗?
在基类的构造函数内调用虚函数会有多态行为吗?
带着上面的几个问题,来看下面这个简单的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 #include <iostream> class A {public : A (){func ();} virtual void func () {printf ("A::func\n" );} virtual void func2 () {printf ("A::func2\n" );} char pad20[20 ]; }; class B :public A{public : B (){func ();} virtual void func () {printf ("B::func\n" );} virtual void func2 () {printf ("B::func2\n" );} }; int main () { A *aobj=new A (); aobj->func (); std::cout<<std::endl; A *bobj = new B (); bobj->func (); delete aobj; delete bobj; }
先来看一下这个类的内存布局(我使用的是Clang(x64)):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ clang++ -E vtable.cpp -o vtable_pp.cpp $ clang++ -cc1 -fdump-record-layouts vtable_pp.cpp *** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | char [20] pad20 | [sizeof=32, dsize=28, align=8, | nvsize=28, nvalign=8] *** Dumping AST Record Layout 0 | class B 0 | class A (primary base) 0 | (A vtable pointer) 8 | char [20] pad20 | [sizeof=32, dsize=28, align=8, | nvsize=28, nvalign=8]
可以看到,类A的sizeof为32,因为其尾部内存对齐了4字节。 然后我们来将其编译为汇编代码:
1 $ clang++ -S vtable.cpp -o vtable.s
找到类A和B的构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 # constructor of class B _ZN1BC2Ev: # @_ZN1BC2Ev .seh_proc _ZN1BC2Ev # %bb.0: subq $56, %rsp .seh_stackalloc 56 .seh_endprologue movq %rcx, 48(%rsp) movq 48(%rsp), %rcx movq %rcx, %rax movq %rcx, 40(%rsp) # 8-byte Spill movq %rax, %rcx callq _ZN1AC2Ev # 调用基类A子对象的构造函数 leaq _ZTV1B(%rip), %rax # 取出虚函数表的地址 addq $16, %rax movq 40(%rsp), %rcx # 8-byte Reload movq %rax, (%rcx) movq (%rcx), %rax callq *(%rax) nop addq $56, %rsp retq # constructor of class A _ZN1AC2Ev: # @_ZN1AC2Ev .seh_proc _ZN1AC2Ev # %bb.0: subq $40, %rsp .seh_stackalloc 40 .seh_endprologue leaq _ZTV1A(%rip), %rax addq $16, %rax movq %rcx, 32(%rsp) movq 32(%rsp), %rcx movq %rax, (%rcx) movq (%rcx), %rax callq *(%rax) nop addq $40, %rsp retq # 类B的虚函数表 .lcomm _ZStL8__ioinit,1 # @_ZStL8__ioinit .section .rdata$_ZTV1B,"dr",discard,_ZTV1B .globl _ZTV1B # @_ZTV1B .p2align 3 _ZTV1B: .quad 0 .quad _ZTI1B .quad _ZN1B4funcEv .quad _ZN1B5func2Ev # 类A的虚函数表 .section .rdata$_ZTV1A,"dr",discard,_ZTV1A .globl _ZTV1A # @_ZTV1A .p2align 3 _ZTV1A: .quad 0 .quad _ZTI1A .quad _ZN1A4funcEv .quad _ZN1A5func2Ev
可以看到,类A和类B的虚函数表都是存放在_ZTV1B
/_ZTV1A
,其中存储的为虚函数的函数指针,在.data
区的,所以全局只有一份。而且他们的布局为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # LLVM-IR B: [4 x i8*] [ i8* null, i8* bitcast ({ i8*, i8*, i8* }* @_ZTI1B to i8*), i8* bitcast (void (%class.B*)* @_ZN1B4funcEv to i8*), i8* bitcast (void (%class.B*)* @_ZN1B5func2Ev to i8*) ] A: [4 x i8*] [ i8* null, i8* bitcast ({ i8*, i8* }* @_ZTI1A to i8*), i8* bitcast (void (%class.A*)* @_ZN1A4funcEv to i8*), i8* bitcast (void (%class.A*)* @_ZN1A5func2Ev to i8*) ]
类的构造函数会从.data
区得到虚函数表的地址,并赋值给该实例的vptr
,需要注意的是,虚函数表的结构中具有this的偏移(第一个元素)和类的类型信息。 在赋值给实例的vptr时做了偏移:
1 2 leaq _ZTV1A(%rip), %rax addq $16, %rax
这里是跳过了虚表结构的前两个元素,直接指向了第一个存储虚函数的地址的指针。
而且,更需要说明的是,在B类的构造过程中,会调用A的构造函数,这时B构造函数内的vptr操作还并未执行,所以如果在类A内调用虚函数,则不会有多态行为——因为在此时类实例的vptr指向的是A的虚函数表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class A {public : A (){func ();} virtual void func () {printf ("A::func\n" );} virtual void func2 () {printf ("A::func2\n" );} char pad20[20 ]; }; class B :public A{public : B (){func ();} virtual void func () {printf ("B::func\n" );} virtual void func2 () {printf ("B::func2\n" );} }; int main () { A* aobj=new B (); delete aobj; return 0 ; } A::func B::func
对象模型相关的其他资料推荐阅读《深度探索C++对象模型(inside the c++ object model)》 。其实这本书里有的地方写的有点过时了,有些特性现在一些编译器实现不大一样了(都没规定过要怎么实现),不过毕竟是1996年出的还是C++荒古时代呢,有实现思想上的参考价值,目前也没有其他同类的书籍可以替代。