c++ 利用虚函数实现了多态的能力,虚函数涉及到虚指针和虚表,本文将从汇编和虚表深入探索虚函数机制。

本文大纲如下:
1、环境
2、示例
3、看汇编指令
4、有多个虚函数,怎么从虚表找到特定的虚函数?
5、那多继承又是如何找到虚函数的?
6、虚表什么时候初始化?
7、typeinfo
8、top_offset
9、虚继承
10、Note
11、QA
12、参考&推荐阅读

refer https://www.tuicool.com/articles/iUB3Ebi

1、环境

x86_64-apple-macos10.15

Apple clang version 11.0.0

注:不同环境下不同编译器,对于虚表会有不同的实现(内存布局)。

2、示例

 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
class Mother {
public:
 virtual void MotherFoo() { printf("Mother - %p\n", this); }
 void simple() { printf("Simple - %p\n", this); }
 virtual void MotherFoo2() { printf("Mother222 - %p\n", this); }
};

class Father {
public:
 virtual void FatherFoo() {}
};

class Child : public Mother, public Father {
public:
 void MotherFoo() override { printf("Child - %p\n", this); }
};

int main(){
    Mother *b = new Mother();
    b->MotherFoo();
    b->simple();
    Child *c = new Child();
    c->MotherFoo();
    c->MotherFoo2();
    delete b;
    delete c;
    
    return 0;
}

3、看汇编指令

这里编译成 x86 汇编指令,不算复杂,下面会给出了相应的注释和对这个流程的整体解释。

 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
_main:
0000000100000cd0         push       rbp                        ;%rbp 是栈帧指针,用于标识当前栈帧的起始位置
0000000100000cd1         mov        rbp, rsp                   ;%rsp 是堆栈指针寄存器,通常会指向栈顶位置
0000000100000cd4         sub        rsp, 0x40
0000000100000cd8         mov        dword [rbp-4], 0x0         ;dword   双字 就是四个字节, 32位
0000000100000cdf         mov        edi, 0x8                   ;mov指令将第二个操作数复制到第一个
0000000100000ce4         call       imp___stubs___Znwm         ; operator new(unsigned long),上面edi为参数8字节,对应Mother大小
0000000100000ce9         xor        esi, esi                   ; argument "c" for method imp___stubs__memset,异或自己为0
0000000100000ceb         mov        rdi, rax                   ; argument "b" for method imp___stubs__memset
                                                               ;%rax 通常用于存储函数调用的返回结果,这里即是new返回的结果,即对象指针
0000000100000cee         mov        edx, 0x8                   ; argument "len" for method imp___stubs__memset
0000000100000cf3         mov        qword [rbp-32], rax        ;四字 就是8个字节,64位,把指针存到栈上
0000000100000cf7         call       imp___stubs__memset        ; memset
0000000100000cfc         mov        rdi, qword [rbp-32]        ;拿出指针,作为参数,给初始化
0000000100000d00         call       __ZN6MotherC1Ev            ; Mother::Mother(), 再把这部分内存给 Mother 初始化
0000000100000d05         mov        rax, qword [rbp-32]        ;初始化函数里面操作了一下,现在对象里的虚指针指向了虚表
0000000100000d09         mov        qword [rbp-16], rax        ;把-32的内容通过rax转到-16,其实是复制一份给栈上的变量
0000000100000d0d         mov        rdx, qword [rbp-16]        ;取栈上的对象指针
0000000100000d11         mov        rdi, qword [rdx]           ;解指针,获取堆上类对象的位置
0000000100000d14         mov        qword [rbp-40], rdi        ;把堆上对象位置拷贝到栈上
0000000100000d18         mov        rdi, rdx                   ;把栈上对象指针给参数 rdi,对应 this 指针
0000000100000d1b         mov        rdx, qword [rbp-40]        ; 栈上对象内存的位置
0000000100000d1f         call       qword [rdx]                ;虚函数 MotherFoo 的调用,[rdx] 取出对象虚指针指向的虚表
0000000100000d21         mov        rdi, qword [rbp-16]        ;把栈上对象指针给参数 rdi,对应 this 指针
0000000100000d25         call       imp___stubs___ZN6Mother6simpleEv     ; Mother::simple()
                                                                         ;下面的部分省略

整个过程描述如下:

  1. 为存储指针本身开辟栈内存
  2. 准备 operator new(unsigned long) 的参数,即对象的大小,这里为 0x8 字节,即 8 字节,因为 Mother 对象只有虚指针,编译器在编译阶段是可以知道类的大小的,所以汇编指令直接使用立即数 0x8
  3. 调用 new,然后我们得到了一块 8 字节大小的内存,但尚未填入内容(指的是用 Mother 填充)
  4. 调用 memset 对这块内存进行初始化
  5. 调用 Mother::Mother() 正式对这块内存进行类初始化,初始化函数会把虚指针指向虚表中的第一个虚函数的地址
  6. 调用虚函数 MotherFoo,会先解开栈上指针得到它指向的堆内存地址,首地址为虚指针,通过解开虚指针到虚表偏移处得到对应的虚函数
  7. 直接通过函数地址调用普通成员函数 simple,此时栈上指针其实就充当 this 指针,将其作为第一参数,调用成员函数

看到这里大家可能有疑惑:虚函数不是在运行时解析吗?为什么静态指令就完成了调用?

其实不是,如果能直接知道要调用的函数,那就不用通过指针偏移的方式来拿了。

只是这里的例子我们一眼就能看出调用的是谁,换个例子,比如下面这个:

1
2
3
void fun(Mother *p){
    p->vfunc1();
}

这个例子就没法一眼看出调用的是谁,需要动态绑定,通过虚表来查调用的函数

1、首先,根据虚表指针 p->vptr 来访问对象 bObject 对应的虚表。

虽然指针 p 是基类 A* 类型,但是 *vptr 也是基类的一部分,所以可以通过 p->vptr 可以访问到对象对应的虚表。

2、然后,在虚表中查找所调用的函数对应的条目。

由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。

对于 p->vfunc1() 的调用,B vtbl 的第一项即是 vfunc1 对应的条目。

3、最后,根据虚表中找到的函数指针,调用函数。

4、有多个虚函数,怎么从虚表找到特定的虚函数?

上面的汇编代码,调用的是第一个虚函数 MotherFoo,所以直接解虚指针就得到虚表的第一个函数。

那如果我调用第二个虚函数 MotherFoo2 呢?

就会发现多了一条汇编代码做偏移:

1
add rdx 0x8

编译器是怎么知道要偏移 0x8 的呢?

可以提出一个大胆的猜想:因为继承关系的类的虚表结构相似。

看下真实的结构是怎样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
vtable for Child:
    .xword  0
    .xword  typeinfo for Child
    .xword  Child::MotherFoo()
    .xword  Mother::MotherFoo2()
    .xword  -8
    .xword  typeinfo for Child
    .xword  Father::FatherFoo()
vtable for Father:
    .xword  0
    .xword  typeinfo for Father
    .xword  Father::FatherFoo()
vtable for Mother:
    .xword  0
    .xword  typeinfo for Mother
    .xword  Mother::MotherFoo()
    .xword  Mother::MotherFoo2()

验证成功,可以看到不管是 Child 的虚表,还是 Mother 的虚表,MotherFoo2 都会处于偏移 0x8 的位置。

5、那多继承又是如何找到虚函数的?

Child::Child 在初始化的时候,会按照继承顺序先调用Mother::Mother,然后再调用Father::Father

我们知道 Child 是有两个虚指针的,可以自行 sizeof 验证。

后面会解释这两个虚指针有什么作用。

它会获取 Child vtable + 16 的指针存在对象前面,然后获取 Child vtable + 48 的指针接在后面,这样就能直接切换虚指针来获取不同类的虚表了。

如果多继承的类中有同名虚函数怎么办?编译器会报错的。

小结一下:编译器是靠匹配函数位置来确定偏移量的。

6、虚指针什么时候初始化?

虚表是编译器制造的,不同编译器构造可能不同,但编译器会构造相关指令找到虚表。

1
0000000100000d00         call       __ZN6MotherC1Ev     ; Mother::Mother(), 再把这部分内存给 Mother 初始化

看下 Mother::Mother 的初始化

1
2
3
4
__ZN6MotherC1Ev:        // Mother::Mother()
;... ...
0000000100000dc0         call       __ZN6MotherC2Ev     ; Mother::Mother()
;... ...

套娃了,再看它调用的函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

__ZN6MotherC2Ev:        // Mother::Mother()
0000000100000e20         push       rbp                             ; CODE XREF=__ZN6MotherC1Ev+16, __ZN5ChildC2Ev+26
0000000100000e21         mov        rbp, rsp
0000000100000e24         mov        rax, qword [qword_100001010]    ; qword_100001010
0000000100000e2b         add        rax, 0x10
0000000100000e2f         mov        qword [rbp+var_8], rdi
0000000100000e33         mov        rdi, qword [rbp+var_8]
0000000100000e37         mov        qword [rdi], rax
0000000100000e3a         pop        rbp
0000000100000e3b         ret
                        ; endp

这里有添加虚表的操作,关注 qword [qword_100001010]

1
2
qword_100001010:
0000000100001010         dq         0x0000000100001020       ;define quadword, 64位

再看目标文件该地址是什么

1
2
3
Section64(__DATA_CONST,__got)
Non-Lazy Symbol Pointers
0000000100001020    Indirect Pointer     __ZTV6Mother

_ZTVvtable 的前缀。

_ZTStype-string 的前缀。

_ZTItype-info的前缀。

拿到虚表后,可以看到上面的 add rax, 0x10,实际上是越过虚表开头的 top_offsettypeinfo 到第一个虚函数的地址。

所以在初始化阶段如果存在虚函数的话,虚表指针被初始化指向虚表的第一个虚函数的位置。

7、typeinfo

对于虚函数我们有了大致的了解,但虚表里面还有很多内容,如 typeinfo 这些都是需要了解的。
虚表里面存放着 typeinfo 指针,指向实际的 typeinfo 内容。

 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
typeinfo for Child*:
        .xword  _ZTVN10__cxxabiv119__pointer_type_infoE+16
        .xword  typeinfo name for Child*
        .word   0
        .zero   4
        .xword  typeinfo for Child
typeinfo name for Child*:
        .string "P5Child"
typeinfo for Child:
        .xword  _ZTVN10__cxxabiv121__vmi_class_type_infoE+16
        .xword  typeinfo name for Child
        .word   0
        .word   2
        .xword  typeinfo for Mother
        .xword  2
        .xword  typeinfo for Father
        .xword  2050
typeinfo name for Child:
        .string "5Child"
typeinfo for Father:
        .xword  _ZTVN10__cxxabiv117__class_type_infoE+16
        .xword  typeinfo name for Father
typeinfo name for Father:
        .string "6Father"
typeinfo for Mother:
        .xword  _ZTVN10__cxxabiv117__class_type_infoE+16
        .xword  typeinfo name for Mother
typeinfo name for Mother:
        .string "6Mother"

这部分内容是紧接在虚表后面的。

正常的编译后,只会有第 9 行及之后的内容,这些是用来在运行时获取描述类的信息的。

可能大家会疑惑 1~8 行是干什么的?因为 Child 已经有 9~19 行的信息记录了。

1
2
3
Child *c = new Child();
typeid(c).name();          // 其实是由于这一句导致的,如果去掉这句,1~8行就没了
// 第8行的 P5Child 代表 Child*,即 p 代表 point

其实就是:如果运用运行时 rtti 的功能,编译器就需要生成这部分的辅助信息。

那么虚表与 typeinfo 的关系是怎样的呢?

如上图所示是一个虚表,可以看到这里 typeinfo 是一个指针,指向 typeinfo 的信息。

0x400b48 -> 0x400b90

从这张图也可以看出 typeinfo 的大致结构。

首先是 type_info 方法的辅助类,是 __cxxabiv1 里的某个类。 对于启用了 RTTI 的类来说,
所有的基础类(没有父类的类)都继承于_class_type_info,
所有的基础类指针都继承自__pointer_type_info
所有的单一继承类都继承自__si_class_type_info
所有的多继承类都继承自__vmi_class_type_info

然后是指向存储类型名字的指针,
如果有继承关系,则最后是指向父类的 typeinfo 的记录。

8、top_offset

Father 和 Mother 的虚表中,top_offset 都是 0,我们就看 Child 的虚表。

Child 有两个虚指针,Mother 和 Child 合用一个,另一个是 Father 的。

为什么会需要有两个虚指针?Mother 和 Child 合用一个指针会有什么问题?

因为如果通过函数传递,只有 this 指针,无法知道传来的是什么对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void fun(void *h){}

void fun1(Mother *h){}

void fun2(Father *h){}

int main(){
    Child *c = new Child();
    fun(c);
    fun1(c);
    fun2(c);
    delete c;
}

在这个代码中,函数体都为空,可以看下进入函数体之前的操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    ldr     x0, [sp, 32]                 ;取出对象指针,下面的指令一样,不再重复
    bl      fun(void*)                   ;fun
    ldr     x0, [sp, 32]
    bl      fun1(Mother*)                ;fun1
    ldr     x0, [sp, 32]
    cmp     x0, 0                        ;需要判断是否为空指针
    beq     .L13                         ;如果是空指针则跳转
    ldr     x0, [sp, 32]
    add     x0, x0, 8                    ;提前偏移8个字节,到时解指针会拿到的是第二个虚指针
    b       .L14                         ;fun2
.L13:
    mov     x0, 0                        ;指针置空
.L14:
    bl      fun2(Father*)

从 arm 的汇编可以看出合用指针会出现的问题:

1
2
3
4
5
6
Child c;
(void*)&c != (void*)static_cast<Father*>(&c)

void fun(void *c){
    static_cast<Father*>(h)->FatherFoo();       // 你会惊奇的发现这里调用的是 MotherFoo()
}

重新看回 top_offset,这玩意的用处是什么?

1
2
3
4
5
6
7
8
vtable for Child:
    .xword  0
    .xword  typeinfo for Child
    .xword  Child::MotherFoo()
    .xword  Mother::MotherFoo2()
    .xword  -8
    .xword  typeinfo for Child
    .xword  Father::FatherFoo()

其实就是用来告诉编译器,要把 this 指针偏移多少字节到所需的类型,这里到 Father 就是 8 字节,可以多继承一个类看看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
vtable for Child:
     .xword  0
     .xword  typeinfo for Child
     .xword  Mother::MotherFoo()
     .xword  Mother::MotherFoo2()
     .xword  Child::FatherFoo()
     .xword  -8
     .xword  typeinfo for Child
     .xword  non-virtual thunk to Child::FatherFoo()
     .xword  -16
     .xword  typeinfo for Child
     .xword  haha::hahaFoo()

可以看到偏移 haha 这个类需要偏移 16 字节。

上面出现了 non-virtual thunk to Child::FatherFoo() ,这个又是什么呢?

其实是我在子类重写了 FatherFoo 方法之后才出现的,可以看到 Child::FatherFoo() 也被写到了第一个虚指针的区域。

但是如果是 Father 类型,即用了第二个虚指针,要调用 FatherFoo 方法怎么办?*

根据前面说到的函数偏移,会调到 non-virtual thunk to Child::FatherFoo() 这个函数,来看下这个函数结构。

1
2
3
4
non-virtual thunk to Child::FatherFoo():
     sub     x0, x0, #8
     b       .LTHUNK0                        ;调用 thunk 方法

第二个虚指针的 top_offset,这里是 -8,负数的效果出现了,即 this 指针需要 -8,然后去调用 FatherFoo 方法,之后解指针会得到第一个虚指针。

再看看如果把 hahaFoo 也重写的效果:

1
2
3
4
non-virtual thunk to Child::hahaFoo():
     sub     x0, x0, #16
     b       .LTHUNK1

如果父类有成员变量,top_offset 还会加上这部分的偏移,这说明虚指针之间还夹着父类的成员变量。

为什么不直接在虚表中覆盖,而是通过 thunk 函数的方式?

1
2
3
4
5
6
7
8
9
                     __ZThn8_N5Child9FatherFooEv:        // non-virtual thunk to Child::FatherFoo()
0000000100003ee0         push       rbp
0000000100003ee1         mov        rbp, rsp
0000000100003ee4         mov        qword [rbp+var_8], rdi
0000000100003ee8         mov        rax, qword [rbp+var_8]
0000000100003eec         add        rax, 0xfffffffffffffff8    ; 即 -8
0000000100003ef0         mov        rdi, rax
0000000100003ef3         pop        rbp
0000000100003ef4         jmp        __ZN5Child9FatherFooEv     ; Child::FatherFoo()

this 指针被减 8,也就是 Father 类型被强行转成了 Child 类型。

因为如果直接覆盖的话,this 指针还是 Father 部分的,一些 Child 使用到的成员变量(比如 Mother 里的)就无法用到。

而 Child::FatherFoo() 里的代码指令是写死的,即对于成员变量的偏移都固定了,如果不强转 this 会出问题。

而 Mother 和 Child 合用一个虚指针,所以就不会有这种问题。

其实是编译器偷了个懒
来看看调用 FatherFoo 时的汇编指令

1
2
3
4
5
6
7
  ldr     r3, [r7, #8]      ; [r7, #4] 存放的是栈上指针
  adds    r2, r3, #8        ; 由于 FatherFoo 是 Father 相关,所以先 +4 偏移到第二个虚指针
  ldr     r3, [r7, #8]      ; 获取虚函数
  ldr     r3, [r3, #8]
  ldr     r3, [r3]
  mov     r0, r2
  blx     r3

可以看到在调用函数之前,由于编译器先判断是调用 Father 相关的虚函数,所以先提前 +8 偏移到第二个虚指针了。
因为编译器偷懒没去判断 FatherFoo 是否被子类重写了,所以直接提前 +8 了,那么造成的后果就是:
如果是子类重写了的虚函数,得在虚表把对应的位置变成 thunk,把虚指针偏移回去。
如果没有重写,那么可以正常调用 Father 的 Father:: FatherFoo 方法。

9、虚继承

1
2
3
4
class ios ...
class istream : virtual public ios ...
class ostream : virtual public ios ...
class iostream : public istream, public ostream

虚继承主要是为了解决菱形继承问题,如果这里没有虚继承的话,则 iostream 会存在两份 ios 的实例,很容易出问题且同步困难。

看下虚继承会有什么不同:假设 parent1 和 parent2 都虚继承自 grand,而 child 多继承自 parent1 和 parent2。

其他结构都一样,但可以看到 top_offset 上面都多出了 virtual-base offset。

虚继承是如何控制只有一份 grand 实例?

在虚继承时,Child::Child() 会先构造 grand,然后才是 parent1、parent2。

如果不是虚继承,则 Child::Child() 直接调用 parent1::parent1()、parent2::parent2(),然后间接构造 grand。

但还有问题,虚继承时,轮到构造 parent1 时,它怎么知道去哪里找已经构造好的 grand 的数据?

在虚表和 typeinfo 表之间还有一些如 construction vtable for Parent1-in-Child 的信息。

至于 grand 的数据,这就用到了上面的 virtual-base offset,告诉 this 指针偏移多少字节去拿。

这里表中第一个 virtual-base offset 是 32 字节,代表构造 Mother 时的 this 指针需要偏移 32 字节到之前构造 grand 的地方。

还有一个 VTT 的信息,这个又是什么呢?

VTT 代表 virtual-table table,即记录虚表的表,看下它的主要结构:

用于帮助编译器指令找到它想要的表。

接下来看看这部分的整体逻辑,以 Child 和其中的 Mother(parent1) 为例:

下面代码部分较长,截取重要讲解部分,需要从 Child::Child() 方法的逻辑看起。

 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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
VTT for Child:
        .xword  vtable for Child+24
        .xword  construction vtable for Mother-in-Child+24
        .xword  construction vtable for Mother-in-Child+64
        .xword  construction vtable for Father-in-Child+24
        .xword  construction vtable for Father-in-Child+56
        .xword  vtable for Child+104
        .xword  vtable for Child+72
construction vtable for Mother-in-Child:
        .xword  24
        .xword  0
        .xword  typeinfo for Mother
        .xword  Mother::MotherFoo()
        .xword  Mother::MotherFoo2()
        .xword  0
        .xword  -24
        .xword  typeinfo for Mother
        .xword  grand::Foo()

grand::grand() [base object constructor]:
        sub     sp, sp, #16
        str     x0, [sp, 8]
        adrp    x0, vtable for grand+16                    ;初始化 grand 的虚表指针
        add     x1, x0, :lo12:vtable for grand+16
        ldr     x0, [sp, 8]
        str     x1, [x0]                                   ;存入 x0 内存
        nop
        add     sp, sp, 16
        ret
Mother::Mother() [base object constructor]:
        sub     sp, sp, #16
        str     x0, [sp, 8]                                ;[sp, 8] 放的是指向 new 的内存空间首地址的指针
        str     x1, [sp]                                   ;[sp] 放的是 VTT+8 指针
        ldr     x0, [sp]
        ldr     x1, [x0]                                   ;现在 x1 是 construction vtable表+24的位置了
        ldr     x0, [sp, 8]                                ;恢复 x0
        str     x1, [x0]                             ;把表+24 存入 x0 内存,表+24即第一个方法指针,即初始化虚指针
        ldr     x0, [sp, 8]                                ;恢复 x0
        ldr     x0, [x0]                                   ;解指针,即现在是虚指针
        sub     x0, x0, #24                                ;-24,即到了构造表的 virtual-base offset 的位置
        ldr     x0, [x0]                                   ;取 offset 的值
        mov     x1, x0                                     ;给 x1
        ldr     x0, [sp, 8]                          ;恢复 x0,是指向 new 的内存空间首地址的指针
        add     x0, x0, x1                           ;加上 offset 的偏移,拿到了内存中 grand 构造的位置 
        ldr     x1, [sp]                                   ;恢复 x1,即 VTT+8 的指针
        ldr     x1, [x1, 8]                                ;解指针+8,现在取到构造表+64的指针
        str     x1, [x0]                                   ;存入 x0 内存,也是类似虚指针的方式
        nop                                          ;这里构造函数都是空的,所以没什么操作
        add     sp, sp, 16                           ;但通过上述操作,知道了编译器如何拿到它想要的东西了
        ret
Child::Child() [complete object constructor]:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        str     x0, [sp, 24]                                ;当前 x0 是刚 new 出来的内存空间
        ldr     x0, [sp, 24]
        add     x0, x0, 24                                  ;偏移 24 个字节,这里用于初始化 grand
        bl      grand::grand() [base object constructor]    ;调用完后,x0+24 放着 grand 的虚指针
        ldr     x2, [sp, 24]
        adrp    x0, VTT for Child+8                         ;使用 VTT 辅助查表
        add     x0, x0, :lo12:VTT for Child+8               
                                             ;VTT+8 是 construction vtable for Mother-in-Child+24 的指针

        mov     x1, x0                                       ;这个指针放到了 x1
        mov     x0, x2                                       ;x0 还是 new 的内存空间首位置
        bl      Mother::Mother() [base object constructor]
        ldr     x0, [sp, 24]
        add     x2, x0, 8
        adrp    x0, VTT for Child+24
        add     x0, x0, :lo12:VTT for Child+24
        mov     x1, x0
        mov     x0, x2
        bl      Father::Father() [base object constructor]   ;Father 构造用内存+8的地方,所以 offset 肯定为 8
        adrp    x0, vtable for Child+24
        add     x1, x0, :lo12:vtable for Child+24
        ldr     x0, [sp, 24]
        str     x1, [x0]
        ldr     x0, [sp, 24]
        add     x0, x0, 24
        adrp    x1, vtable for Child+104
        add     x1, x1, :lo12:vtable for Child+104
        str     x1, [x0]
        adrp    x0, vtable for Child+72
        add     x1, x0, :lo12:vtable for Child+72
        ldr     x0, [sp, 24]
        str     x1, [x0, 8]
        nop
        ldp     x29, x30, [sp], 32
        ret

整体流程描述如下:

  1. 调用 Child::Child() 对内存进行初始化,这块内存的大小是由编译器算好的,足以容纳 grand 和 parent 的内容,下面称这块内存的首地址为 base
  2. 在 base 偏移 24 个字节处,调用 grand::grand() 来初始化,之后 base+24 就存放着 grand 的虚指针,称为 grand_vptr
  3. 读取 VTT 获取 construction vtable for Mother-in-Child+24 的位置,实际上是拿到了指向 Mother 虚表里第一个虚函数的地址的指针,下面称为 Mother_vptr
  4. 调用 Mother::Mother() 对 base 进行初始化
  5. Mother_vptr - 24,即获取 Mother 虚表的 virtual-base offset,让 Mother_vptr 加上这个 offset 得到 grand 的虚指针
  6. 下面需要更新 grand_vptr,因为之前调用 grand::grand() 初始化时,指向的是 grand 的虚表,更新 grand_vptr 指向 VTT+16 即 construction vtable for Mother-in-Child+64 的位置,是 Mother 虚表里属于 grand 的那部分
  7. 之后调用 Father 的初始化也是类似的操作

– 如果代码里有 Mother *m = new Mother() 实际上还会有一个 Mother::Mother() 方法:

一个给 Child 类型的初始化用。

另一个给 Mother 类型的初始化用(这个里面会有调用 grand::grand() 初始化的操作)。

Father(parent2) 如果有的话同理。

10、Note

  • 拥有虚函数的类会有一个虚表,而且这个虚表存放在类定义模块的数据段中。模块的数据段通常存放定义在该模块的全局数据和静态数据,这样我们可以把虚表看作是模块的全局数据或者静态数据。

  • 类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表,从这个意义上说,我们可以把虚表简单理解为类的静态数据成员。值得注意的是,虽然虚表是共享的,但是虚表指针并不是,类的每一个对象有一个属于它自己的虚表指针。

  • 虚表中存放的是虚函数的地址。

  • 使用 -fdump-class-hierarchy 参数可以导出 g++ 生成的类内存结构

  • C++ 提供了 dynamic_cast 函数用于动态转型。相比 C 风格的强制类型转换和 C++ reinterpret_cast,dynamic_cast 提供了类型安全检查,是一种基于能力查询(Capability Query)的转换,所以在多态类型间进行转换更提倡采用 dynamic_cast

11、QA

Q1:构造函数可以为虚函数吗?

A1:肯定是不可以的,因为虚指针也是在构造函数里面初始化的,没有初始化的虚指针无法调用虚函数

Q2: 析构函数可以为虚函数吗?

A2: 如果我们需要删除一个指向派生类的基类指针时,应该把析构函数声明为虚函数。事实上,只要一个类有可能会被其它类所继承,就应该声明虚析构函数(哪怕该析构函数不执行任何操作)。

Q3: 虚函数可以为私有函数吗?

A3: 基类指针指向继承类对象,则调用继承类对象的函数;int main() 必须声明为 Base 类的友元,否则编译失败。编译器报错:ptr 无法访问私有函数。当然,把基类声明为 public, 继承类为 private,该问题就不存在了。

12、参考&推荐阅读

C++ vtables - Part 1 - Basics:https://shaharmike.com/cpp/vtable-part1/

C++ vtables - Part 2 - Multiple Inheritance:https://shaharmike.com/cpp/vtable-part2/

C++ vtables - Part 3 - Virtual Inheritance:https://shaharmike.com/cpp/vtable-part3/

C++ vtables - Part 4 - Compiler-Generated Code:https://shaharmike.com/cpp/vtable-part4/

探索C++虚函数在g++中的实现:https://www.tuicool.com/articles/iUB3Ebi

virtual那些事:https://light-city.club/sc/basic_content/virtual/