C++多态与虚函数表

C++ Polymorphism and Virtual Function Table

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
# 生成LLVM-IR代码
$ 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
// class object layout
*** 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 memory align
%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
}

// B::B()
; 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 (...)***
// set vptr
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
// set vptr in _ZN1BC2Ev
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. 在基类的构造函数内调用虚函数会有多态行为吗?

带着上面的几个问题,来看下面这个简单的例子:

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
// vtable.cpp
#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;
}

// ouput
A::func
B::func

对象模型相关的其他资料推荐阅读《深度探索C++对象模型(inside the c++ object model)》。其实这本书里有的地方写的有点过时了,有些特性现在一些编译器实现不大一样了(都没规定过要怎么实现),不过毕竟是1996年出的还是C++荒古时代呢,有实现思想上的参考价值,目前也没有其他同类的书籍可以替代。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:C++多态与虚函数表
文章作者:查利鹏
发布时间:2019/02/26 13:36
更新时间:2019/02/27 11:47
本文字数:1.8k 字
原始链接:https://imzlp.com/posts/25558/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!