A Research on Virtual Functions and Dynamic Dispatch¶
1 引言¶
面向对象程序设计语言如 C++ 和 Java 将多态(polymorphism)作为一种重要的抽象机制。通过继承和虚函数,程序可以使用基类接口调用某个操作,而实际执行的函数实现则由对象在运行时的真实类型决定。例如:
在典型的 C++ 实现中,动态分派通常通过虚函数表机制完成。一个多态对象内部通常包含一个隐藏的 虚函数表指针(vptr) ,它指向一张由同类对象共享的 虚函数表(vtable) 。vtable 中保存虚函数入口地址以及运行时类型信息等数据。
本文通过对比 C++ 源代码与对应的汇编代码研究虚函数与动态分派(dynamic dispatch)在机器层面的实现机制,以及编译器在其中扮演的角色。
研究由简单情况逐步扩展到复杂情况。首先分析单继承条件下的对象布局和虚函数调用机制,随后研究纯虚函数、默认参数等特殊行为,最后研究多继承与菱形继承下的动态分派实现。
2 实验环境说明¶
- 实验在 https://www.godbolt.org/ 上进行
- 源码语言: C++
- 编译器:x86-64 gcc 16.2
- 默认采用
-O0优化等级
- 默认采用
3 单继承下的动态分派¶
本节以最基本的单继承关系为研究对象,分析 C++ 中虚函数在机器层面的实现方式。
实验定义了一个包含两个虚函数和虚析构函数的基类 Base,以及一个继承自 Base 的派生类 Derived:
struct Base {
virtual int calc(int x) {
return x + 1;
}
virtual int id() {
return 10;
}
virtual ~Base() = default;
};
struct Derived : Base {
int factor;
Derived(int f) : factor(f) {}
__attribute__((noinline))
int calc(int x) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
};
随后通过:
建立一个静态类型为
Base*、动态对象类型为 Derived 的指针,并执行:从而观察典型的虚函数动态分派过程。
3.1 虚函数表¶
汇编文件中直接存在 vtable 中的静态符号:
"vtable for Base":
.quad 0
.quad "typeinfo for Base"
.quad "Base::calc(int)"
.quad "Base::id()"
.quad "Base::~Base()"
.quad "Base::~Base()"
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::calc(int)"
.quad "Derived::id()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
这说明 vtable 是编译器生成的静态数据,而不是在每次构造对象时动态创建。
分析 Base 的虚函数表布局:
- .quad 代表后面存放 8 字节数据。
- 第一行的 .quad 0 是 offset-to-top ,代表了某个父类对象距离子类对象顶部的内存偏移。Offset-to-top 在单继承和无继承下都是 0 ,我们在这里跳过,将在多继承中进一步介绍。
- 第二行指向 Base 类的一些元信息。
- 第三、四、五、六行指向 Base 的各个函数地址。
分析Derived 的虚函数表布局,可以发现布局与 Base 完全相同,但第三、四、五、六行的函数地址都被替换为了 Derived 重载的函数地址,同时顺序完全相同。
虚函数不重载
如果我们删除 Derived 中的 id() 重载,可以看到虚函数表变成
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::calc(int)"
.quad "Base::id()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
只有
id() 函数位置变成了父函数的 Base::id() ,其余不变。
添加非虚函数
如果我们为 Derived 或 Base 添加非虚函数,可以看到虚函数表没有变化,这说明非虚函数无需动态分配,也不参与虚函数表。
子类添加虚函数
如果我们为 Derived 添加 Base 没有的虚函数 derived() ,可以看到虚函数表变成
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::calc(int)"
.quad "Base::id()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
.quad "Derived::derived()"
且无论源代码中
derived() 被放在哪里,虚函数表中 derived() 都在最后而不会放在父类函数中间。
删除 override 声明
删除子类 override 声明不造成任何影响,这说明 override 只用于编译器检查,不影响编译内容。
删除 virtual 声明
删除 calc 函数的子类的 override 声明和父类的 virtual 声明,此时虚函数表中不再有 calc 项,说明 calc 不再参与动态分派。实际上,由于 name hiding 子类将不再能够调用父类的 calc 函数。
我们可以发现子类虚函数表仿佛复制了一份父类虚函数表,具有相同的函数顺序,但表中被重载的函数具有不同的地址。
综上,可以得出结论:虚函数表以槽结构记录了类及其父类的所有虚函数,每个槽唯一对应一个函数名称。派生类虚表的前半部分直接沿用基类的虚表布局,重写某个虚函数时,修改对应槽中的地址,未重写的槽位保持原样。派生类新增的虚函数追加到表尾。因此,对于派生类与基类共有的槽,它们的顺序相同、位置不变。需要注意,这套布局是 Itanium ABI 的实现约定,并非 C++ 标准强制规定,跨编译器不保证一致。
在多继承中,我们还将看到子类虚函数表具有多个分区,每个分区对应一个父类的虚函数表,连续存放父类的槽,且顺序相同。
3.2 对象内存布局¶
3.2.1 Base 内存布局¶
我们可以从构造函数中窥见子类与父类对象的内存布局。
分析 Base::Base() 构造函数(略去了构造函数栈帧和返回的部分)。
"Base::Base()":
...
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:"vtable for Base"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
...
其中 rdi 保存隐藏参数 this 对象指针,该指针被存放到内存 [rbp-8] 中,又被读取到 rax 中。
OFFSET FLAT:"vtable for Base" 代表了虚函数表 vtable for Base 的地址。这里有两个注意点,一个是使用 edx 而非 rdx ,另一个是该地址被加了 16 。
使用 edx 是因为编译器约定了静态数据被存放在 32 位可表达的地址中,因此可以用 edx 。前 32 位会被逻辑扩展为 0 。
加十六则是为了更方便地调用虚函数。观察上一节中展示的 vtable 可以很清晰的发现,加十六后指针跳过第一行 offset-to-top ,和第二行 typeinfo ,从而指向第一条函数地址。
将
rdx 中的内容,存放到内存 [rax] 。这意味着编译器会在对象起始位置写入一个指向 Base 虚函数表的指针。这说明对象并不保存整张虚函数表,而是保存一个指向虚函数表的指针,所有的 Base 对象中的虚函数指针都指向同一张虚函数表。
3.2.2 Derived 对象复用 Base 子对象中的 vptr¶
Derived 继承自 Base,并额外增加:
其构造函数包含:
"Derived::Derived(int)":
...
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call "Base::Base()"
mov edx, OFFSET FLAT:"vtable for Derived"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rbp-12]
mov DWORD PTR [rax+8], edx
初始 rdi 存储 Derived 对象的 this 指针,esi 存储构造函数参数,一个 32 位整型。这两个参数分别被写入函数栈帧 rdi -> [rbp-8], esi -> [rbp-12] 。
接着,Base 类构造函数被调用,注意此时传递给 Base::Base() 的 this 指针正是 Derived 对象的 this 指针。因此,Base::Base() 会在 Derived 对象的开头构造一个 Base 子对象。且这个子对象的开头包含 Base 虚函数表的地址。
但是这一虚函数表地址在后续被覆盖!
mov edx, OFFSET FLAT:"vtable for Derived"+16
mov rax, QWORD PTR [rbp-8] ; rax <- rax
mov QWORD PTR [rax], rdx ; [this] <- vtable for Derived + 16
这一段代码将对象开头的 vptr 从
Base 的 vtable 改写为 Derived 的 vtable。这说明在单继承中,Derived 并没有额外增加第二个 vptr,而是直接覆盖了继承自 Base 的那个 vptr。
随后
将成员变量
factor 写入 [this+8]
于是 Derived 的布局为:
Derived object
offset 0
+------------------------+
| Base subobject |
| vptr | 8 bytes
+------------------------+
offset 8
+------------------------+
| factor | 4 bytes
+------------------------+
offset 12
+------------------------+
| padding | 4 bytes
+------------------------+
其中最后 4 字节为 padding,用于满足整个对象的 8 字节对齐要求。
3.2.3 多个同类型对象共享同一张 vtable¶
Derived 构造函数每次都执行:
这里引用的是同一个静态符号 "vtable for Derived" ,因此若创建多个对象,对象的 vptr 值通常相同,Base 对象同理。
这说明虚函数表由同类型对象共享,同类型对象的 vptr 指向同一个 vtable 。
3.3 类型转换¶
在 main 中:
对应汇编:
即
Derived* 和 Base* 在数值上相同,普通单继承中的 upcast 不需要调整指针地址。
在上一节中我们看到,Base 对象被构造在 Derived 对象的偏移 0 处,因此 p 也指向 Derived 对象中的 Base 对象,这也是指针地址无需调整的原因。
注意,后续研究多继承时,这一性质将不再总是成立。
3.4 vptr 的内容代表对象当前的动态分派状态¶
值得注意的是,vptr 并不是从对象构造开始到销毁结束始终保持不变。
在构造 Derived 时,首先 Base::Base() 赋值 vptr = Base vtable 。随后 Derived::Derived() 使 vptr = Derived vtable 。
因此对象布局本身虽然没有改变,但 vptr 中保存的值会随着对象生命周期阶段发生变化。
在 Base 构造阶段,同一块内存曾暂时处于指向 Base vtable 的状态。
因此可以认为 vptr 的值反映对象当前处于哪个动态类型阶段。
3.5 虚函数调用¶
接下来研究虚函数 call 和 id 的调用过程来理解动态分派的基本实现。
首先分析多态调用函数 call_calc 。
其接受一个基类指针,并间接调用
calc 方法,实际分派根据对象类型决定。
从 call_calc 的汇编中,我们可以看出动态分派是如何实现的。
"call_calc(Base*, int)":
; 栈帧初始化
push rbp
mov rbp, rsp
sub rsp, 16
; 参数存储
mov QWORD PTR [rbp-8], rdi ; [rbp-8] <- rdi(= p)
mov DWORD PTR [rbp-12], esi ; [rbp-12] <- esi(= x)
; 取出 calc 地址
mov rax, QWORD PTR [rbp-8] ; rax <- [rbp-8](= p)
mov rax, QWORD PTR [rax] ; rax <- [rax](= *p = vptr)
mov rcx, QWORD PTR [rax] ; rcx <- [rax](= *vptr = calc)
; 准备 calc 参数
mov edx, DWORD PTR [rbp-12] ; edx <- [rbp-12](= x)
mov rax, QWORD PTR [rbp-8] ; rax <- [rbp-8](= p)
mov esi, edx ; esi <- edx(= x)
mov rdi, rax ; rdi <- rax(= p)
; 调用 calc
call rcx ; call calc
leave
ret
进入函数时:
第一步:取得对象地址
第二步:取得 vptr
由于 vptr 存储在对象顶部,因此直接从对象指针开始取 64 位就可以得到 vptr 。
第三步:读取 calc slot ,发生动态分派
从虚函数表中可以看出
calc 函数在第一个 slot ,因此 vptr[0] 取出 calc 的地址。这里发生动态分派,calc 的地址取决于对象类型。
第四步:重新准备参数
第五步:间接调用
静态 vtable 与动态 vptr
从调用过程中我们可以发现,虚函数调用并不是所有信息都在运行时决定。编译器在编译时已经知道 vtable 布局,知道 calc 位于 vtable 的第一个槽,因此可以用 vptr[0] 调用。但它不知道 vtable 属于 Base 还是 Derived 。
综上,动态分派需要两类信息:
- 编译期决定 vtable 的 slot 布局,这是静态信息
- 运行时决定 vptr 指向哪个 vtable ,这是动态信息
再来看 call_id 的汇编中
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
add rax, 8 ; 修改 slot ,rax 指向 vptr[1]
mov rdx, QWORD PTR [rax]
...
call rdx
前两条指令仍然获取对象的 vptr 指针:rax <- vptr
但随后:
说明目标虚函数位于 vptr+8 。而一个函数指针占 8 字节,加上 8 后 rax 指向第二个槽,从虚函数表中可以看到第二个槽是 id 函数。
后续流程与 calc 一致。
通过上述实验,我们可以总结出不同虚函数通过不同的 vtable slot 区分,但一个虚函数具有一个编译期已知的、固定的 slot 。当发生间接调用时,编译器先判断对象是否是多态类,函数是否是虚函数。若是,则通过虚函数表调用。
3.6 单继承小结¶
综合上述分析,本实验得到以下结论。
-
含虚函数的 C++ 对象通常包含一个由编译器加入的隐藏的 vptr,用于指向其当前使用的 vtable。vtable 代表了动态类型。
-
vtable 不是对象实例的一部分,而是编译器生成的静态数据结构。对象只需要维护 vptr,而更多虚函数只会增加 vtable 中的 slot 数量。同一动态类型的多个对象可以共享同一张 vtable。
-
在普通单继承下,派生类通常复用基类子对象中的 vptr,而不是为派生类新增一个独立 vptr。
-
基类子对象位于子类完整对象的起始位置,因此在该单继承情况下具有相同的数值地址,向上造型时
this无需调整。 -
派生类 override 虚函数时,对应变化主要体现在派生类 vtable 的同一 slot 改为指向派生类实现。
-
虚函数调用的底层过程可以概括为:
vptr->vtable slot->function address->indirect call -
动态分派实际上结合了静态信息与动态信息:
- vtable 由编译器静态确定
- vptr 由对象的运行时状态确定
4 单继承模型中的进阶行为¶
第三章通过一个基本的单继承模型分析了 C++ 虚函数动态分派的实现机制。本章在第三章建立的基本模型上逐步引入这些特殊情况,来进一步探讨动态分派的实现机制和边界,包括纯虚函数、默认参数、构造与析构过程和虚析构函数。
4.1 纯虚函数¶
在第三部分的基本模型中,Base 为虚函数提供了具体实现,而 Derived 通过覆盖对应虚函数改变 vtable 中相应槽位保存的函数地址。本节进一步研究纯虚函数(Pure Virtual Function)是否会改变这一动态分派机制。
纯虚函数是在虚函数声明后添加 = 0 的特殊虚函数。实验在第三部分程序的基础上,将 Base::calc() 修改为纯虚函数:
struct Base {
virtual int calc(int x) = 0;
virtual int id() {
return 10;
}
virtual ~Base() = default;
};
struct Derived : Base {
int factor;
Derived(int f) : factor(f) {}
__attribute__((noinline))
int calc(int x) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
};
__attribute__((noinline))
int call_calc(Base* p, int x) {
return p->calc(x);
}
__attribute__((noinline))
int call_id(Base* p) {
return p->id();
}
int main() {
Derived d(6);
Base* p = &d;
int a = call_calc(p, 66);
int b = call_id(p);
return a + b;
}
4.1.1 虚函数表中的纯虚函数¶
编译得到的两个虚函数表如下:
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::calc(int)"
.quad "Derived::id()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
"vtable for Base":
.quad 0
.quad "typeinfo for Base"
.quad "__cxa_pure_virtual"
.quad "Base::id()"
.quad 0
.quad 0
可以看到 Base::calc() 对应的槽位变为:
这说明将 calc() 声明为纯虚函数并没有使它从 vtable 中消失。calc() 仍然占据相同位置的虚函数槽位,只是 Base vtable 中该槽位不再保存 Base::calc() 的地址,而是保存 __cxa_pure_virtual。这说明纯虚函数仍然遵循第三章总结的规则,即同一个虚函数在 vtable 中保持对应的槽位,而派生类通过改变该槽位中的目标函数地址提供自己的实现。
__cxa_pure_virtual 是当前 ABI 用于处理纯虚函数调用的函数。当程序在异常的对象生命周期状态等情况下实际通过该槽位执行纯虚函数调用时,程序通常会被终止。它属于具体 ABI 的实现方式,而不是 C++ 标准要求必须使用的函数名称。
另外,本实验中的 Base 的 vtable 最后两个槽位为:
而
Derived 中则保存了两个 Derived::~Derived() 条目。这是因为当前实验中的 Base 为抽象类,不应当被实例化,于是 GCC 在该 vtable 中没有为基类析构入口填入正常的可调用地址。该现象属于当前编译器的 ABI 约定,使用 clang 时可以看到不同的结果,这里不再展开。
4.1.2 纯虚函数的动态调用¶
实验中的:
生成的汇编代码的逻辑与第三部分普通虚函数的 call_calc() 完全一致。这说明纯虚函数并没有独立的动态分派机制,还是仍然采用一般虚函数的动态分派机制,只是通过一个特殊的地址来记录该函数是纯虚函数。
4.1.3 纯虚函数小结¶
结合第三部分与本节实验,可以得到如下结论:
纯虚函数并没有改变第三章建立的动态分派基本模型。声明为纯虚函数不会改变动态分派机制,而是改变抽象类 vtable 中对应槽位的地址,通过一个特殊的、 ABI 约定的地址来表明该槽位是纯虚函数。
4.2 默认参数¶
在前面的实验中,虚函数调用的最终目标由对象运行时的 vptr 决定。然而,并不是虚函数调用表达式中的所有内容都由动态分派决定。默认参数(Default Arguments)就是一个典型例子。
C++ 中,虚函数的最终调用目标可以在运行时确定,而默认参数则在编译阶段根据调用表达式所使用的静态类型确定。因此,当基类与派生类为同一个虚函数指定不同的默认参数时,一次调用中可能同时出现静态绑定与动态分派。
为了观察这一现象,在第三章实验程序的基础上,为 Base::calc() 和 Derived::calc() 设置不同的默认参数。
struct Base {
virtual int calc(int x = 10) {
return x + 1;
}
virtual int id() {
return 10;
}
virtual ~Base() = default;
};
struct Derived : Base {
int factor;
Derived(int f) : factor(f) {}
__attribute__((noinline))
int calc(int x = 20) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
};
__attribute__((noinline))
int call_base(Base* p) {
return p->calc();
}
__attribute__((noinline))
int call_derived(Derived* p) {
return p->calc();
}
int main() {
Derived d(6);
Base* bp = &d;
Derived* dp = &d;
int a = call_base(bp);
int b = call_derived(dp);
return a + b;
}
在该程序中,Base::calc() 的默认参数为 10 ,而 Derived::calc() 的默认参数为 20:
4.2.1 Default Arguments 的静态绑定¶
首先观察 call_base():
其汇编代码为:
"call_base(Base*)":
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov esi, 10
mov rdi, rax
call rdx
leave
ret
其中
说明编译器已经在
call_base() 中直接将默认参数 10 作为实际参数传入。
之所以选择 10,是因为 p 的静态类型为:
因此编译器在处理调用表达式时查找的是 Base::calc() 声明中的默认参数。
再观察 call_derived 的汇编代码为
"call_derived(Derived*)":
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov esi, 20
mov rdi, rax
call rdx
leave
ret
这里相应位置变为:
因为 p 的静态类型为 Derived*,所以编译器使用 Derived::calc() 声明中的默认参数 20。这一过程在编译阶段已经完成,不需要访问对象中的 vptr,也不需要在运行时从 vtable 中查询默认参数。
虽然两个调用使用了不同的默认参数,但它们选择实际函数实现的过程仍然遵循虚函数动态分派机制。
call_base() 和 call_derived() 中都有同样的代码:
与第三章中对虚函数调用的类似,第一条指令取得 p,第二条指令读取对象中的 vptr,第三条指令读取 vtable address point 的第一个槽位,即 calc() 对应的函数地址。
因此调用的具体函数仍然会根据虚函数表进行动态分派。
4.2.2 默认参数小结¶
默认参数并不是函数本身在运行时自动补充的参数,而是由编译器在调用前补充完成。编译器会根据指针或对象的静态类型设置默认参数,再根据 vtable 动态调用函数。可以认为默认参数由静态类型决定,而具体函数由动态类型决定。
另外,vtable 中也只保存函数地址,不存在与默认参数的相关信息。
4.3 对象生命周期中的虚函数调用¶
第三章已经研究了一个已经完成构造的对象是如何实现动态分派的。但是在对象不同的生命周期阶段,其中最重要的也就是构造和析构过程中,其 vptr 并不是稳定不变的,这可能导致特殊的动态分派情况。
本节分别通过构造和析构实验,研究对象生命周期与 vptr 状态之间的关系。
4.3.1 构造过程中的虚函数调用¶
我们让 Base 和 Derived 的构造函数分别调用虚函数 id()。
struct Base {
int base_result;
Base() {
base_result = id();
}
virtual int calc(int x) {
return x + 1;
}
__attribute__((noinline))
virtual int id() {
return 10;
}
virtual ~Base() = default;
};
struct Derived : Base {
int factor;
int derived_result;
Derived(int f) : factor(f) {
derived_result = id();
}
__attribute__((noinline))
int calc(int x) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
};
__attribute__((noinline))
int test_constructor() {
Derived d(6);
return d.base_result * 100 + d.derived_result;
}
我们知道,Derived 对象的构造中包含了一次 Base 对象的构造,这会带来两次 id() 调用。如果按照一个已经完整构造的 Derived 对象进行理解,两次 id() 调用似乎都应该执行 Derived::id() 。但实际结果并非如此。
在 Derived 构造函数 Derived::Derived(int) 中:
"Derived::Derived(int)":
...
call "Base::Base()"
mov edx, OFFSET FLAT:"vtable for Derived"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
...
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call "Derived::id()"
mov rdx, QWORD PTR [rbp-8]
mov DWORD PTR [rdx+16], eax
...
Derived 构造函数首先调用 Base 构造函数。
Base 类构造函数 Base::Base() 的汇编如下:
"Base::Base()":
...
mov edx, OFFSET FLAT:"vtable for Base"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call "Base::id()"
mov rdx, QWORD PTR [rbp-8]
mov DWORD PTR [rdx+8], eax
...
进入 Base 构造函数之后,如第三章分析的,首先将当前对象的 vptr 设置为指向 Base 类的 vtable。
随后,最关键的,构造函数中的 id() 被编译为:
这是直接调用 Base::id() 而不是通过 vtable 分派。
这里存在编译器优化,我们会在后面解释,只需注意这里调用的是
Base::id()而非Derived::id()。
当 Base::Base() 返回之后,首先将 vptr 调整为指向 Derived 类的 vtable,随后
被编译为:
综上,在 Derived 对象的构造过程中,Base 子对象的构造调用 Base::id() ,随后 Derived 部分的构造调用 Derived::id() 。
从上面的实验中可以看出,在多级构造函数中,虚函数调用选择的版本由当前执行的构造函数所属的类型决定,而不是根据上级构造决定。
同时我们还可以发现,vptr 从先指向 Base 到再指向 Derived ,这一变化过程与先构造 Base 再构造 Derived 是同步的。这更进一步地说明了构造过程中动态分派状态的变化:在多级构造中,当前的动态分派状态由当前执行的最低级构造决定。
更本质的,这是因为 C++ 规定:构造函数中的虚函数调用不会分派到尚未构造的派生类。
4.3.2 析构过程中的虚函数调用¶
析构过程与构造过程具有近似相反的变化方向。我们用下面的代码进行实验。
int destructor_result = 0;
struct DestructorBase {
__attribute__((noinline))
virtual int id() {
return 30;
}
virtual ~DestructorBase() {
destructor_result =
destructor_result * 100 + id();
}
};
struct DestructorDerived : DestructorBase {
__attribute__((noinline))
int id() override {
return 40;
}
~DestructorDerived() override {
destructor_result =
destructor_result * 100 + id();
}
};
__attribute__((noinline))
int test_destructor() {
destructor_result = 0;
{
DestructorDerived d;
}
return destructor_result;
}
DestructorDerived 析构函数生成:
"DestructorDerived::~DestructorDerived()":
...
mov edx, OFFSET FLAT:"vtable for DestructorDerived"+16
mov rax, QWORD PTR [rbp-24]
mov QWORD PTR [rax], rdx
mov eax, DWORD PTR "destructor_result"[rip]
imul ebx, eax, 100
mov rax, QWORD PTR [rbp-24]
mov rdi, rax
call "DestructorDerived::id()"
add eax, ebx
mov DWORD PTR "destructor_result"[rip], eax
mov rax, QWORD PTR [rbp-24]
mov rdi, rax
call "DestructorBase::~DestructorBase()"
...
进入 DestructorDerived::~DestructorDerived() 后,首先将 vptr 设置为指向派生类 vtable 。
随后直接调用 DestructorDerived::id() 。
完成 DestructorDerived 析构函数自身的主体之后,调用基类析构函数,进入基类析构阶段。
DestructorBase::~DestructorBase() 的汇编为:
"DestructorBase::~DestructorBase()":
...
mov edx, OFFSET FLAT:"vtable for DestructorBase"+16
mov rax, QWORD PTR [rbp-24]
mov QWORD PTR [rax], rdx
mov eax, DWORD PTR "destructor_result"[rip]
imul ebx, eax, 100
mov rax, QWORD PTR [rbp-24]
mov rdi, rax
call "DestructorBase::id()"
add eax, ebx
mov DWORD PTR "destructor_result"[rip], eax
...
进入 DestructorBase 析构阶段后,首先发生:
即对象中的 vptr 被重新设置为指向基类 vtable 。
随后 id() 被直接编译为:
整个析构过程中,vptr 的变化方向与构造过程正好相反,且指向的 vtable 始终保持与正在执行的析构同步。析构过程中对虚函数的调用也采用了正在执行的析构函数所属的类的版本。
4.3.3 构造、析构函数中的直接调用¶
本实验有一个值得注意的汇编现象。
第三部分普通虚函数调用生成的是:
即通过 vtable 的间接调用。
但是本节构造和析构函数中的 id() 调用分别生成:
以及:
即编译器直接生成了目标函数的调用,而没有进行 vtable 查找。
这是因为在构造函数或析构函数执行期间,根据 C++ 的虚调用规则,调用目标不会越过当前正在构造或析构的类层次阶段。例如在 Base::Base() 中,id() 不可能因为最终对象是 Derived 而分派到 Derived::id()。因此,编译器已经能够确定调用目标,并直接生成:
这实际是编译器进行了一次去虚拟化的优化。根据前面的分析,我们知道 vptr 是与构造(析构)阶段同步变化的,当执行某一类的构造(析构)时,vptr 也指向该类的 vtable 。因此即使通过 vtable 间接调用,也能分派到相同的函数实现。
4.3.4 生命周期小结¶
将两个实验结合起来,可以看到对象生命周期中的 vptr 变化具有明显的对称性。构造过程中 vptr 指向的 vtable 从基类变化到派生类,析构过程则反向进行。这也对应了对象的动态分派状态在构造中从基类变化到派生类,在析构中从派生类变化到基类。
这种行为并不仅仅是编译器为了实现方便而偶然产生的结果,而是与 C++ 的对象生命周期语义一致。
在执行 Base 构造函数时,Derived 部分尚未完成构造。如果此时允许 Base 构造函数中的虚调用分派到 Derived::id(),那么派生类函数可能访问尚未初始化的派生成员。
例如:
在 Base 构造阶段,factor 尚未由 Derived 构造函数完成初始化,此时将调用分派到 Derived::id() 会导致未定义行为。
析构过程同理。当程序已经进入 Base::~Base() 时,Derived 部分的析构已经开始并完成了其析构函数主体,此时继续向 Derived 的覆盖函数进行动态分派同样可能访问已经结束生命周期的派生类状态。
因此,对象生命周期与动态分派状态之间存在直接关系:虚函数分派考虑的并不是对象的最终状态,而是对象当前处于继承层次中的哪个有效生命周期阶段。
4.4 虚析构函数¶
我们知道存在继承关系时,析构函数应当被声明为虚函数,这是因为当程序通过基类指针删除派生类对象时,需要识别对象的实际类型选择正确的析构入口。
本节通过非虚析构函数与虚析构函数的对照实验,研究虚析构函数是如何保证多态对象的正确销毁。
4.4.1 非虚析构函数¶
首先考虑析构函数不是虚函数的情况。实验定义 NonVirtualBase 和 NonVirtualDerived,二者均包含析构函数,但基类析构函数没有使用 virtual 修饰。
struct NonVirtualBase {
virtual int calc(int x) {
return x + 1;
}
virtual int id() {
return 10;
}
~NonVirtualBase() {}
};
struct NonVirtualDerived : NonVirtualBase {
int factor;
NonVirtualDerived(int f) : factor(f) {}
__attribute__((noinline))
int calc(int x) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
~NonVirtualDerived() {}
};
__attribute__((noinline))
void delete_nonvirtual(NonVirtualBase* p) {
delete p;
}
首先观察 GCC 生成的虚函数表:
"vtable for NonVirtualDerived":
.quad 0
.quad "typeinfo for NonVirtualDerived"
.quad "NonVirtualDerived::calc(int)"
.quad "NonVirtualDerived::id()"
"vtable for NonVirtualBase":
.quad 0
.quad "typeinfo for NonVirtualBase"
.quad "NonVirtualBase::calc(int)"
.quad "NonVirtualBase::id()"
与第三部分的实验相比,两个 vtable 中均不存在析构函数条目。
进一步观察 delete_nonvirtual() 的汇编:
"delete_nonvirtual(NonVirtualBase*)":
...
mov rbx, QWORD PTR [rbp-24]
test rbx, rbx
je .L15
mov rdi, rbx
call "NonVirtualBase::~NonVirtualBase()"
mov esi, 8
mov rdi, rbx
call "operator delete(void*, unsigned long)"
.L15:
...
其中:
表明 GCC 直接调用了 NonVirtualBase 的析构函数,并没有读取对象的 vptr。
这是因为 delete_nonvirtual() 的参数类型为 NonVirtualBase* ,而 NonVirtualBase 的析构函数不是虚函数,所以编译器根据指针的静态类型确定析构函数调用目标。
4.4.2 虚析构函数¶
接下来,将基类析构函数声明为虚函数:
struct VirtualBase {
virtual int calc(int x) {
return x + 1;
}
virtual int id() {
return 10;
}
virtual ~VirtualBase() {}
};
struct VirtualDerived : VirtualBase {
int factor;
VirtualDerived(int f) : factor(f) {}
__attribute__((noinline))
int calc(int x) override {
return x * factor;
}
__attribute__((noinline))
int id() override {
return 20;
}
~VirtualDerived() override {}
};
__attribute__((noinline))
void delete_virtual(VirtualBase* p) {
delete p;
}
此时,GCC 生成的 vtable 为:
"vtable for VirtualDerived":
.quad 0
.quad "typeinfo for VirtualDerived"
.quad "VirtualDerived::calc(int)"
.quad "VirtualDerived::id()"
.quad "VirtualDerived::~VirtualDerived()"
.quad "VirtualDerived::~VirtualDerived()"
"vtable for VirtualBase":
.quad 0
.quad "typeinfo for VirtualBase"
.quad "VirtualBase::calc(int)"
.quad "VirtualBase::id()"
.quad "VirtualBase::~VirtualBase()"
.quad "VirtualBase::~VirtualBase()"
可以发现,虚析构函数出现在 vtable 中,并占据两个连续槽位。这里析构函数为什么占据两个槽位读者可以自行搜索 “析构函数的三种变体” 了解,限于篇幅这里不再展开。
接下来观察 delete_virtual() 的汇编:
"delete_virtual(VirtualBase*)":
...
mov rax, QWORD PTR [rbp-8]
test rax, rax
je .L32
mov rdx, QWORD PTR [rax]
add rdx, 24
mov rdx, QWORD PTR [rdx]
mov rdi, rax
call rdx
.L32:
...
与 delete_nonvirtual() 不同,这里没有直接生成:
而是通过一组间接寻址指令获取析构函数地址,实现动态分派。这说明,虚析构函数使 delete 表达式能够利用对象的动态类型选择正确的析构入口,从而保证多态继承体系中派生类与基类的析构过程能够正常进行。
4.4.3 虚析构函数小结¶
结合两个实验,可以发现 delete 表达式的本质是通过指针进行析构函数的间接调用。如果析构函数没有虚声明,则会根据指针的静态类型选择析构函数实现。
本节的实验是前文研究的又一佐证。
5 多继承下的动态分派¶
在前面的单继承模型中,派生类对象通常复用基类子对象中的 vptr,通过一个 vtable 即可完成虚函数的动态分派。同时,由于基类子对象位于派生类对象的起始位置,基类指针与派生类指针通常具有相同的地址,虚函数调用不需要额外调整 this 指针。
然而,当一个类同时继承多个基类时,其对象可能包含多个具有独立 vptr 的基类子对象。不同基类子对象位于不同的内存位置,使得基类指针转换不再只是简单的类型变化,而可能涉及实际的地址调整。此外,当派生类覆盖不同基类中的虚函数时,编译器还需要确保通过不同基类指针发起的调用,最终都能以正确的 this 指针执行派生类的函数实现。
当继承关系进一步形成菱形结构时,同一个基类可能沿不同继承路径重复出现,导致某一基类在派生类对象中存在多个子对象。此时基类的位置不再总能通过固定偏移确定,必须引入虚基类以及更加复杂的指针调整方式。
本章首先研究普通多继承下的对象布局、多个 vptr、vtable 结构以及 thunk 的实现。随后通过非虚菱形继承与虚菱形继承的对照实验,分析共享虚基类如何改变对象布局和动态分派过程。
5.1 普通多继承¶
本节研究不含菱形继承的简单多继承关系。实验使用两个包含虚函数的基类 Base1 和 Base2,并使 Derived 同时继承二者。两个基类不仅分别定义了 f() 和 g(),还定义了同名虚函数 common() 和同名数据成员 value。
实验中的继承关系为:
struct Base1 {
int value;
Base1(int x) : value(x) {}
__attribute__((noinline))
virtual int f() {
return value + 1;
}
__attribute__((noinline))
virtual int common() {
return value + 10;
}
virtual ~Base1() = default;
};
struct Base2 {
int value;
Base2(int x) : value(x) {}
__attribute__((noinline))
virtual int g() {
return value + 2;
}
__attribute__((noinline))
virtual int common() {
return value + 20;
}
virtual ~Base2() = default;
};
struct Derived : Base1, Base2 {
int extra;
Derived(int x, int y, int z)
: Base1(x), Base2(y), extra(z) {}
__attribute__((noinline))
int f() override {
return Base1::value + extra;
}
__attribute__((noinline))
int g() override {
return Base2::value + extra;
}
__attribute__((noinline))
int common() override {
return Base1::value + Base2::value + extra;
}
};
5.1.1 多继承下的构造¶
与单继承不同,Derived 同时包含 Base1 和 Base2 两个基类子对象。二者都具有虚函数,因此都需要能够通过相应的基类指针完成动态分派。
首先观察 Derived::Derived(int, int, int)。Derived 构造会首先进行 Base1 和 Base2 子对象的构造。
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rbp-12]
mov esi, edx
mov rdi, rax
call "Base1::Base1(int)"
mov rax, QWORD PTR [rbp-8]
lea rdx, [rax+16]
mov eax, DWORD PTR [rbp-16]
mov esi, eax
mov rdi, rdx
call "Base2::Base2(int)"
其中,调用 Base1 构造函数时,直接将 Derived 对象的起始地址作为 this:
而调用 Base2 构造函数前,先执行:
将
this 指针增加 16 字节。
这说明 Base1 子对象位于 Derived 对象的起始位置,而 Base2 子对象位于偏移 16 字节的位置。
在 Base1 和 Base2 的构造函数中,分别申请 8 bytes 的 vptr 和 4 bytes 的 value 成员变量。可以分析出 Derived 对象布局:
Derived object
offset 0
+-----------------------------+
| Base1 subobject |
| |
| vptr1 | 8 bytes
+-----------------------------+
offset 8
| Base1::value = 10 | 4 bytes
+-----------------------------+
offset 12
| padding | 4 bytes
+-----------------------------+
offset 16
| Base2 subobject |
| |
| vptr2 | 8 bytes
+-----------------------------+
offset 24
| Base2::value = 20 | 4 bytes
+-----------------------------+
offset 28
| Derived::extra = 30 | 4 bytes
+-----------------------------+
offset 32
sizeof(Derived) = 32
因此,Derived 不再像单继承模型一样只具有一个 vptr,而是分别在偏移 0 和偏移 16 字节的位置保存两个 vptr。
在单继承实验中,Derived 构造函数只需要将对象起始位置的 vptr 修改为指向 Derived vtable。而在当前多继承实验中,Derived 构造函数包含两次 vptr 写入:
mov edx, OFFSET FLAT:"vtable for Derived"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov edx, OFFSET FLAT:"vtable for Derived"+72
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], rdx
分别将对象起始位置的 vptr1 和偏移 16 字节处的 vptr2 设置为 vtable for Derived + 72 。
下面我们分析虚函数表,来看看 vptr 究竟指向的是什么。
5.1.2 虚函数表¶
基类的 vtable 没有太大区别,我们重点分析派生类虚函数表:
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::f()"
.quad "Derived::common()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
.quad "Derived::g()"
.quad -16
.quad "typeinfo for Derived"
.quad "non-virtual thunk to Derived::g()"
.quad "non-virtual thunk to Derived::common()"
.quad "non-virtual thunk to Derived::~Derived()"
.quad "non-virtual thunk to Derived::~Derived()"
可以发现,Derived 的 vtable 并非只有一组虚函数入口,而是由两个连续的部分组成。
第一部分起始于:
对应基类
Base1 。
第二部分起始于:
对应基类
Base2 。
5.1.2.1 表头¶
两个表的表头中,分别出现 .quad 0 和 .quad -16 。我们在第三章中讲过,这是 offset-to-top,代表子对象距离完整对象的头部的偏移。在单继承中,这一项总是 0 ,因为只有一个子对象,且子对象总是位于对象头部。但是在多继承中,不同子对象会位于不同位置。正如上一节分析的,Base1 子对象位于 Derived 对象头部,而 Base2 子对象位于 16 bytes 偏移处。这里 .quad -16 正代表完整对象地址位于子对象地址的 -16 偏移处。
offset-to-top 是 ABI 为虚函数表提供的对象布局信息,尤其可用于从基类子对象定位完整对象。我们会在下一节中看到存储这一偏移的意义。
另外,两个虚函数表头都使用:
说明它们虽然服务于不同的基类子对象,但都属于同一个动态类型 Derived。
5.1.2.2 虚函数重写与 thunk 入口¶
Primary vtable 中,
分别覆盖
Base1::f() 和 Base1::common()。析构函数也是一样。
Secondary vtable 中,
覆盖了
Base2::g() 和 Base::common 。
这里 g() 和 common() 仍然位于 Base2 原有的槽位位置,但这些槽位并没有直接保存 Derived 成员函数的地址,而是保存了对应的 thunk 地址。
这是因为在多继承中,通过 Base2* 发起虚函数调用时,传入的 this 指针指向偏移 16 字节处的 Base2 子对象,而 Derived 的成员函数需要以完整对象的起始地址作为 this。因此,编译器需要在进入目标函数之前进行额外的指针调整。
这一机制将在 5.2 中进一步分析。
5.1.2.3 同名虚函数的覆盖关系¶
Base1 和 Base2 都定义了虚函数 common() ,而 Derived 只提供一个重写实现。可以看到派生类实现同时覆盖了两个基类的 common() 。
Primary vtable 中:
对应
Base1::common() 的槽位。
Secondary vtable 中:
对应
Base2::common() 的槽位。
vtable 中保留了两个基类的 common() 槽位,但都指向同一个派生类实现。
这一现象说明:派生类同时覆盖所有基类的同名虚函数,但不同基类提供的虚函数接口需要在各自对应的虚函数表中保留正确的槽位布局。这是为了协同不同基类子对象的内存位置和调用约定。
5.1.2.4 多出来的 g¶
我们还能看到一个比较特殊的现象,属于 Base2 的方法 g() 出现在了对应 Base1 的 primary vtable 的末尾。
这是为了方便通过 Derived* 指针间接调用,我们将在 5.2 中具体说明。
5.1.3 小结¶
通过普通多继承实验,可以发现第三章建立的单继承动态分派模型在多个基类子对象存在时发生了明显扩展:
1. 派生类同时包含多个基类子对象,每个子对象分别保存独立的 vptr。
2. 各个子对象的 vptr 指向的虚函数表被组织在同一个 vtable 中。
3. 发生偏移的子对象使用 thunk 入口而非普通入口,这将在 5.2 中进一步介绍。
4. 派生类重写会覆盖所有基类的同名虚函数,但各个基类的虚函数接口仍然在各自对应的 vtable 中保留相应槽位。
综上,多继承并没有改变通过 vptr → vtable → slot 查找虚函数地址的基本机制,而是通过多个基类子对象、多个 vptr、分段 vtable 以及必要的指针调整,使同一个完整对象能够同时支持不同基类接口的动态分派。
5.2 Thunk¶
在 5.1 中,我们观察到 Derived 对象包含两个基类子对象,其 vptr 分别指向 Primary 和 Secondary vtable。同时,Secondary vtable 中的虚函数槽位并没有直接保存 Derived 成员函数的地址,而是保存了对应的 non-virtual thunk。
这一现象与多继承下的对象内存布局密切相关。在单继承模型中,基类子对象通常位于完整对象的起始位置,因此基类指针与派生类指针具有相同的地址。而在多继承模型中,不同基类子对象可能位于不同的内存偏移位置。当通过基类指针调用派生类覆盖的虚函数时,编译器必须确保传递给目标函数的 this 指针指向正确的位置。
本节继续使用 5.1 的实验程序,分析多继承中的指针转换,以及 GCC 如何通过 thunk 完成动态分派所需的 this 指针调整。
5.2.1 类型转换带来指针转换¶
在 main() 中,我们将派生类指针转换为基类指针:
对应的汇编代码为:
lea rax, [rbp-96] ; rax <- &d
mov QWORD PTR [rbp-24], rax ; p1 <- &d
lea rax, [rbp-96] ; rax <- &d
add rax, 16 ; rax <- &d + 16
mov QWORD PTR [rbp-32], rax ; p2 <- &d + 16
可以发现,初始化 p1 时,编译器直接使用 Derived 对象的起始地址;而初始化 p2 时,需要先将对象地址增加 16 字节。
依据 5.1 中分析的内存布局,我们知道 p1 指向了 Derived 头部的 Base1 子对象,而 p2 发生偏移,指向 Base2 子对象。
这一转换并不涉及虚函数调用,也不需要在运行时查询 vtable,因为 Base2 子对象相对于 Derived 对象的位置在编译阶段已经确定。
之所以要进行这样的转换,是因为当我们通过 Base2* 间接调用 Derived 对象继承且未重写的 Base2 方法时,需要传入的 this 指针应当指向 Base2 子对象。
由此可见,多继承下的 upcasting 不一定只改变指针的静态类型,也可能改变指针保存的实际地址。
5.2.2 通过不同基类指针进行动态分派¶
接下来观察虚函数调用:
两个函数生成的核心汇编完全相同:
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
这一逻辑与第三章中的单继承动态分派相同,其执行过程仍然是:vptr -> vtable -> slot ,但由于两个参数指向的基类子对象不同,实际读取的 vptr 也不同。
这说明调用端并不需要知道完整对象的具体类型。它只需要根据参数的静态类型,使用相应虚函数在 vtable 中的固定槽位。
5.2.3 Non-Virtual Thunk¶
考虑这样的情形,我们用 Base2* 指针指向了一个 Derived 对象,因此发生了偏移,指向的是其中的 Base2 子对象。现在我们间接调用 g() ,如果 g() 没有被重写,那么传入 Base2 子对象的 this 指针是合理的,因为一切都发生在 Base2 子对象内部。但是一旦 Derived 重写了 g() ,我们就应当传入完整对象的地址,而不是现在 Base2* 指针指向的子对象地址,这要求我们调整指针,即 non-virtual thunk。
在C++中,non-virtual thunk 是编译器生成的一种特殊函数,用于处理多重继承和虚函数调用时的指针调整。它的主要作用是确保在多重继承的情况下,正确地调用子类重写的虚函数。
观察 GCC 生成的 thunk:
.set .LTHUNK0,"Derived::g()"
"non-virtual thunk to Derived::g()":
sub rdi, 16 ; 调整 this
jmp .LTHUNK0 ; jmp "Derived::g()"
可以看到 non-virtual thunk 仅仅只是在调用前对 this 做了一次调整,将传入的 Base2* 向前调整 16 字节,使其重新指向完整 Derived 对象的起始位置。
为什么必须进行这样的调整,可以通过 Derived::g() 的汇编进一步说明:
"Derived::g()":
...
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rax+24]
mov rax, QWORD PTR [rbp-8]
mov eax, DWORD PTR [rax+28]
add eax, edx
其中 [rax+24] 对应 Base2::value,[rax+28] 对应 Derived::extra。
这两个偏移均以完整 Derived 对象的起始地址为基准。
如果不经过 thunk,而直接将 Base2* 传入 Derived::g(),那么 rax 将指向 Derived+16,函数访问的实际位置就会变为:
而不是正确的:
另外,这里的 non-virtual 并不是指 g() 不再是虚函数,而是指 thunk 使用的 this 调整量是编译期确定的固定偏移。在本实验中,这个偏移为 -16。
5.2.4 多出来的 g¶
在 5.1 中,我们观察到 Derived::g() 不仅通过 Secondary vtable 中的 thunk 提供调用入口,还直接出现在 Primary vtable 中:
"vtable for Derived":
...
.quad "Derived::f()"
.quad "Derived::common()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
.quad "Derived::g()"
...
.quad "non-virtual thunk to Derived::g()"
这与前面分析的指针调整机制有关。
当调用表达式使用 Base2* 时,this 指针已经指向 Base2 子对象,因此需要通过 Secondary vtable 中的 thunk 将其调整为 Derived*。
但如果调用表达式使用 Derived*:
那么 this 本身已经指向完整 Derived 对象的起始位置,不需要任何调整。如果通过 Derived* 调用时仍然使用 Secondary vtable 中的 thunk,那么必须先将 this 从 Derived* 调整为 Base2*,再通过 thunk 调整回 Derived*,产生不必要的地址计算。
因此,Primary vtable 中额外提供的 Derived::g() 入口,可以使通过 Derived* 发起的虚函数调用直接进入实际函数实现,避免这一额外的指针调整过程。
作为比较,如果我们删去 Derived 中的 g() 重写,我们可以发现 primary vtable 中不再有 Derived::g() 入口,secondary vtable 中也不再是 thunk 入口,而是变成:
"vtable for Derived":
.quad 0
.quad "typeinfo for Derived"
.quad "Derived::f()"
.quad "Derived::common()"
.quad "Derived::~Derived()"
.quad "Derived::~Derived()"
.quad -16
.quad "typeinfo for Derived"
.quad "Base2::g()"
.quad "non-virtual thunk to Derived::common()"
.quad "non-virtual thunk to Derived::~Derived()"
.quad "non-virtual thunk to Derived::~Derived()"
5.2.5 虚析构函数中的 Thunk¶
除了普通虚函数,Derived 的虚析构函数同样需要处理多继承带来的 this 指针偏移。这是为了程序能够通过 Base2* 正确删除实际类型为 Derived 的对象。
5.2.6 小结¶
在单继承模型中,基类指针通常与派生类指针具有相同的地址,因此动态分派只需要通过 vptr 找到目标函数即可。
而在本实验的多继承模型中,Base1 和 Base2 子对象分别位于完整对象的不同位置,这使得通过不同基类指针进行的虚函数调用具有不同的 this 指针起始位置。
为解决这一问题,GCC 使用 non-virtual thunk 作为虚函数入口。thunk 在调用实际派生类函数之前,先将 this 指针调整回完整对象的起始地址。
通过这种方式,编译器既能保留不同基类原有的虚函数接口布局,又能保证这些接口最终以正确的 this 指针调用派生类中的覆盖函数。
5.3 菱形继承¶
在前面的普通多继承模型中,Derived 同时继承 Base1 和 Base2,两个基类分别形成独立的子对象。而当多个基类继承自同一个共同基类时,继承关系便可能形成菱形结构,即菱形继承(Diamond Inheritance)。
普通菱形继承的一个重要特点是:同一个基类沿不同继承路径出现时,不同路径对应的基类子对象不会自动合并。 因此,最派生类对象中可能包含多个相同类型的基类子对象。这不仅影响对象布局与类型转换,还会使同一个虚函数在不同继承路径上具有多个动态分派入口。
本节使用以下非虚菱形继承模型进行实验:
struct A {
int value;
A(int x) : value(x) {}
__attribute__((noinline))
virtual int f() {
return value;
}
virtual ~A() = default;
};
struct NormalB : A {
NormalB() : A(10) {}
};
struct NormalC : A {
NormalC() : A(20) {}
};
struct NormalD : NormalB, NormalC {
int extra;
NormalD() : extra(30) {}
__attribute__((noinline))
int f() override {
return extra;
}
};
其中,NormalB 和 NormalC 都继承 A,而 NormalD 又同时继承这两个类。我们称 A 为 最基类 ,称 NormalD 为 最分派类 。
5.3.1 菱形继承下的 vtable¶
首先观察 GCC 为 NormalD 生成的虚函数表:
"vtable for NormalD":
.quad 0
.quad "typeinfo for NormalD"
.quad "NormalD::f()"
.quad "NormalD::~NormalD()"
.quad "NormalD::~NormalD()"
.quad -16
.quad "typeinfo for NormalD"
.quad "non-virtual thunk to NormalD::f()"
.quad "non-virtual thunk to NormalD::~NormalD()"
.quad "non-virtual thunk to NormalD::~NormalD()"
与 5.1 中的普通多继承类似,NormalD 的虚函数表仍然采用 virtual table group 的组织方式,包含 Primary 和 Secondary 两部分。其中,Primary vtable 对应 NormalB 继承路径,Secondary vtable 对应 NormalC 继承路径。
这里值得注意的是 NormalD 的两张子表都提供了 f() 对应的虚函数槽位,这说明基类 A 的两条继承路径会各自产生槽位,而不是产生一个 A::f() 槽位。
5.3.2 重复的基类子对象¶
为了确定两个 A 子对象的实际位置,观察 NormalD 的构造函数:
"NormalD::NormalD()":
...
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call "NormalB::NormalB()"
mov rax, QWORD PTR [rbp-8]
add rax, 16
mov rdi, rax
call "NormalC::NormalC()"
mov edx, OFFSET FLAT:"vtable for NormalD"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov edx, OFFSET FLAT:"vtable for NormalD"+56
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], rdx
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+28], 30
...
首先,NormalD 分别调用两个直接基类的构造函数:
以及:
说明 NormalB 子对象位于 NormalD 对象的起始位置,而 NormalC 子对象位于偏移 16 字节处。
两个子对象的构造又会分别调用 A 的构造:
因此,在构造一个完整的 NormalD 对象时,实际上存在两次独立的 A 子对象构造过程。
在 NormalB 和 NormalC 子对象构造完成后,NormalD::NormalD() 对它们的 vptr 进行覆写,分别指向 "vtable for NormalD"+16 和 "vtable for NormalD"+56 ,这正是 NormalD vtable 的两张子表。
最终可以得到 NormalD 的对象布局:
NormalD object
offset 0
+-----------------------------+
| NormalB subobject |
| |
| A subobject |
| +---------------------+ |
| | vptr1 | | 8 bytes
| +---------------------+ |
+-----------------------------+
offset 8
| A::value = 10 | 4 bytes
+-----------------------------+
offset 12
| padding | 4 bytes
+-----------------------------+
offset 16
| NormalC subobject |
| |
| A subobject |
| +---------------------+ |
| | vptr2 | | 8 bytes
| +---------------------+ |
+-----------------------------+
offset 24
| A::value = 20 | 4 bytes
+-----------------------------+
offset 28
| NormalD::extra = 30 | 4 bytes
+-----------------------------+
offset 32
sizeof(NormalD) = 32
这一布局与 5.1 中普通多继承的对象布局非常相似,但存在一个重要区别:两个子对象最终都包含 A 子对象。因此,一个 NormalD 对象存在两个不同的 A* 地址。
这也解释了为什么不能直接进行:
由于 NormalD 中存在两个 A 子对象,编译器无法确定应该选择哪一个,因此转换具有歧义,编译器报错。
必须显式指定继承路径:
5.3.3 不同继承路径下的动态分派¶
虽然 NormalD 中存在两个独立的 A 子对象,但 NormalD::f() 可以同时覆盖两个 A::f()。而在调用时,选择不同的继承路径会带来不同的分派路径。
实验通过以下函数进行验证:
其汇编为:
"call_f(A*)":
...
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
...
可以看到,这仍然是第三部分建立的基本动态分派过程。但是,当传入的 A* 指向不同的基类子对象时,读取的 vptr 不同。
对于:
p1 指向 NormalB 子对象的 A 子对象,其 vptr 指向 NormalD 的 primary vtable ,对应的槽位是 NormalD::f()。
对于:
p2 指向 NormalC 子对象的 A 子对象,其 vptr 指向 NormalD 的 secondary vtable ,对应的槽位是 "non-virtual thunk to NormalD::f()" 。根据上一节中的内容,thunk 入口先调整 this ,然后调用真正的 NormalD::f() 。
综上,两条继承路径通过不同的分派路径最终都执行同一个 NormalD::f() ,且分派并没有引入新的方式,而是继续使用 5.2 中介绍的分段 vtable 与 non-virtual thunk 机制。
不同的是,在非虚菱形继承中,同一个基类类型 A 可以对应多个独立的子对象,因此即使调用函数的参数类型完全相同,也可能通过不同的 vptr 和 vtable address point 完成动态分派。
5.3.4 本节结论¶
菱形继承导致最分派类中基类可能存在复数的子对象,不同子对象对应不同继承路径。因此,派生类指针转换为基类指针时,必须明确选择继承路径,而不能直接进行存在歧义的基类转换。
此外,不同子对象对应不同的子虚函数表,最基类的方法会在每个子表中都占据槽位,这导致同一函数的调用存在不同分派路径。
虽然菱形继承引入了子对象重复的问题,但并没有改变多继承下虚函数动态分派的基本机制,而是进一步体现了基类子对象与虚函数调用入口之间的对应关系。
为了避免重复的基类子对象,并允许不同继承路径共享同一个共同基类,C++ 提供了虚继承(Virtual Inheritance)机制。下一节将通过相同的菱形继承结构,研究虚继承如何改变对象布局、vtable 结构以及动态分派过程。
5.4 虚继承¶
在 5.3 的非虚菱形继承实验中,NormalD 通过两条继承路径分别继承 A,因此对象内部存在两个独立的 A 子对象。这两个子对象具有不同的地址、数据成员和 vptr,并通过各自的 vtable 入口完成虚函数动态分派。
C++ 提供虚继承(Virtual Inheritance)机制,使多个继承路径能够共享同一个虚基类子对象。然而,共享虚基类也带来了新的问题:虚基类子对象相对于直接派生类的位置,不再一定能够通过编译期确定的固定偏移表示。
本节在 5.3 实验的基础上,将对 A 的普通继承修改为虚继承,构建以下模型:
struct VirtualB : virtual A {
VirtualB() : A(10) {}
};
struct VirtualC : virtual A {
VirtualC() : A(20) {}
};
struct VirtualD : VirtualB, VirtualC {
int extra;
VirtualD() : A(30), extra(40) {}
__attribute__((noinline))
int f() override {
return extra;
}
};
5.4.1 共享虚基类与对象内存布局¶
首先观察 VirtualD::VirtualD():
"VirtualD::VirtualD()":
...
mov rax, QWORD PTR [rbp-8]
add rax, 24
mov esi, 30
mov rdi, rax
call "A::A(int)"
mov rax, QWORD PTR [rbp-8]
mov edx, OFFSET FLAT:"VTT for VirtualD"+8
mov rsi, rdx
mov rdi, rax
call "VirtualB::VirtualB()"
mov rax, QWORD PTR [rbp-8]
add rax, 8
mov edx, OFFSET FLAT:"VTT for VirtualD"+24
mov rsi, rdx
mov rdi, rax
call "VirtualC::VirtualC()"
mov edx, OFFSET FLAT:"vtable for VirtualD"+24
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-8]
add rax, 24
mov edx, OFFSET FLAT:"vtable for VirtualD"+120
mov QWORD PTR [rax], rdx
mov edx, OFFSET FLAT:"vtable for VirtualD"+72
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], rdx
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+16], 40
...
与 5.3 不同,VirtualD 并没有分别通过 VirtualB 和 VirtualC 构造两份 A 子对象,而是由最派生类直接构造唯一的共享虚基类:
这直接通过 VirtualD 对象的起始地址加上 24 字节,得到 A 虚基类子对象的位置,并调用 A(30) 构造。
虽然 VirtualB 和 VirtualC 的构造函数分别写有:
和:
但在构造完整的 VirtualD 对象时,这两个虚基类初始化表达式不会负责构造 A,实际执行的是:
也就是说 A 的构造也被 VirtualD 重写了。
进一步观察三个 vptr 的写入位置:
可以确定当前实验中的对象布局为:
VirtualD object
offset 0
+-----------------------------+
| VirtualB subobject |
| vptr1 | 8 bytes
+-----------------------------+
offset 8
| VirtualC subobject |
| vptr2 | 8 bytes
+-----------------------------+
offset 16
| VirtualD::extra = 40 | 4 bytes
+-----------------------------+
offset 20
| padding | 4 bytes
+-----------------------------+
offset 24
| A virtual subobject |
| vptr3 | 8 bytes
+-----------------------------+
offset 32
| A::value = 30 | 4 bytes
+-----------------------------+
offset 36
| padding | 4 bytes
+-----------------------------+
offset 40
sizeof(VirtualD) = 40
与 5.3 的 NormalD 相比,A 子对象不再位于 NormalB 和 NormalC 子对象内部,而是放在了 VirtualD 的末尾。
5.4.2 虚继承下的 vtable¶
首先观察 VirtualD 虚函数表:
"vtable for VirtualD":
.quad 24
.quad 0
.quad "typeinfo for VirtualD"
.quad "VirtualD::~VirtualD()"
.quad "VirtualD::~VirtualD()"
.quad "VirtualD::f()"
.quad 16
.quad -8
.quad "typeinfo for VirtualD"
.quad "non-virtual thunk to VirtualD::~VirtualD()"
.quad "non-virtual thunk to VirtualD::~VirtualD()"
.quad -24
.quad -24
.quad -24
.quad "typeinfo for VirtualD"
.quad "virtual thunk to VirtualD::f()"
.quad "virtual thunk to VirtualD::~VirtualD()"
.quad "virtual thunk to VirtualD::~VirtualD()"
与非虚菱形继承相比,最明显的变化是 vtable 中出现了更多偏移信息。
在 5.3 中,NormalD 的 vtable 主要由 offset-to-top、RTTI 和虚函数入口组成。而在当前虚继承实验中,除了这些信息,还出现了 Virtual Base Offset 和 Virtual Call Offset。
前两张子表的第一行,.quad 24 和 .quad 16 是 Virtual Base Offset ,代表从 VirtualB 和 VirtualC 子对象定位共享虚基类 A 所需的偏移量。通过前面对内存布局的分析,我们可以发现两个偏移量实际指向的是同一个 A 子对象。
前两张子表的第二行是 offset-to-top ,代表子对象到完整对象头部的偏移,我们已经很熟悉了。
第三张子表,即 A 的 vptr 指向的表,尤其不一样。前两行对应 VirtualD::f() 、VirtualD::~VirtualD() 的 Virtual Call Offset ,这一偏移指向 A 子对象,代表调用函数时需要对 this 做的调整。第三行代表 offset-to-top 。
虚基类对应的子表为每个虚函数都提供了 Virtual Call Offset ,这是因为不同虚函数的最终覆盖函数可能不属于同一个类,例如一个被 VirtualB 重写,另一个被 VirtualC 重写。
5.4.3 Virtual Base Offset 与指针转换¶
在 5.3 中,普通继承下的基类子对象偏移在编译期已经确定。然而,在虚继承中,虚基类子对象的位置取决于完整对象的布局。
例如一个 VirtualB 对象既可能作为完整的对象存在,也可能包含在 VirtualD 或其他派生类对象中。不同完整对象中,共享虚基类 A 相对于 VirtualB 的位置可能不同。
因此,编译器不能始终在 VirtualB* → A* 的转换中使用固定偏移。
实验定义:
__attribute__((noinline))
long virtual_base_offset(VirtualB* p) {
A* pa = p;
return reinterpret_cast<char*>(pa)
- reinterpret_cast<char*>(p);
}
观察其汇编:
"virtual_base_offset(VirtualB*)":
... ; 参数 VirtualB* p
mov rax, QWORD PTR [rbp-24] ; rax <- p
mov rax, QWORD PTR [rax] ; rax <- vptr
sub rax, 24 ; rax <- vptr - 24
mov rax, QWORD PTR [rax] ; rax <- [vptr-24] = vbase offset
mov rdx, rax ; rdx <- vbase offset
mov rax, QWORD PTR [rbp-24] ; rax <- p
add rax, rdx ; rax <- p + vbase offset
...
获得子对象指针后,先从 vtable 中得到 vbase offset ,然后通过偏移计算共享虚基类 A 的地址。这一机制是动态的,而不是普通多继承中的固定指针调整。
5.4.4 VTT 与 Construction vtable¶
除了虚基类偏移,虚继承还使构造和析构过程更加复杂。
在 4.3 中,我们已经观察到,构造函数会随着对象当前的构造阶段修改 vptr,从而保证虚函数调用不会分派到尚未构造的派生类。
而对于虚继承,编译器不仅需要设置正确的虚函数入口,还需要保证不同构造阶段使用正确的虚基类偏移。
GCC 使用 Construction vtable 和 VTT(Virtual Table Table)辅助这一过程。
首先观察:
"VTT for VirtualD":
.quad "vtable for VirtualD"+24
.quad "construction vtable for VirtualB-in-VirtualD"+24
.quad "construction vtable for VirtualB-in-VirtualD"+72
.quad "construction vtable for VirtualC-in-VirtualD"+24
.quad "construction vtable for VirtualC-in-VirtualD"+72
.quad "vtable for VirtualD"+120
.quad "vtable for VirtualD"+72
VTT 本身不是虚函数表,而是保存一组 vtable address point 的地址,用于支持复杂继承结构中的构造和析构。
在 VirtualD::VirtualD() 中存在:
以及:
可以看到,调用 VirtualB 和 VirtualC 构造函数时,除了传递 this 指针,还额外传递了 VTT 中对应子表的位置。
随后观察 VirtualB::VirtualB():
"VirtualB::VirtualB()":
...
mov rax, QWORD PTR [rbp-16]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
...
这里通过传入的 VTT 指针获取 Construction vtable address point,并将其写入当前子对象的 vptr。
这说明在 VirtualB 构造阶段,编译器并不是简单地使用独立 VirtualB 对象的普通 vtable,而是使用一个特殊的、与当前完整 VirtualD 对象布局相适应的构造期虚函数表。
在 VirtualD 的直接基类构造完成后,构造函数再将相关 vptr 设置为最终的 VirtualD vtable address point:
对象的动态分派状态最终切换到 VirtualD 状态。
5.4.5 Virtual Thunk 与动态分派¶
在 5.2 和 5.3 中,我们观察到普通多继承通过 non-virtual thunk 调整 this 指针:
这里的调整量 16 是编译期已经确定的固定值。
但在虚继承中,虚基类子对象相对于完整对象的位置可能随着最派生类的不同而改变,因此不能始终使用固定偏移调整 this ,而是使用 Virtual Call Offset 。
观察 GCC 生成的:
.set .LTHUNK5, "VirtualD::f()"
"virtual thunk to VirtualD::f()":
mov r10, QWORD PTR [rdi] ; 读取 vptr
add rdi, QWORD PTR [r10-24] ; 读取 vcall offset ,并修改 this
jmp .LTHUNK5
thunk 最终仍然跳转到 VirtualD::f(),但在跳转之前通过 vtable 读取了 vcall offset 偏移量,并加到了 this 上。
5.4.6 小结¶
通过与 5.3 中非虚菱形继承的对照,可以发现虚继承在对象布局与动态分派机制上引入了三个主要变化。
首先,虚继承使不同继承路径共享同一个虚基类子对象。在当前实验中,VirtualD 仅包含一份 A 子对象,因此通过 VirtualB 和 VirtualC 两条路径得到的 A* 指向同一地址,避免了普通菱形继承中的共同基类重复问题。
其次,由于虚基类相对于中间派生类的位置可能随完整对象类型变化,编译器在 vtable 中加入 vbase offset 和 vcall offset,并通过读取这些偏移信息完成虚基类指针转换。对象的构造与析构还需要使用 VTT 和 Construction vtable,以保证不同生命周期阶段具有正确的虚函数分派状态和虚基类布局信息。
总体而言,虚继承并没有替代此前建立的虚函数动态分派模型,而是在其基础上进一步扩展了对象布局和指针调整机制。
6 总结¶
本文以 C++ 虚函数与动态分派机制为研究对象,使用 x86-64 GCC 16.2 编译器,通过分析不同继承模型下生成的汇编代码,研究了虚函数在对象内存布局、虚函数表、函数调用及对象生命周期等方面的底层实现,并逐步将研究范围从单继承扩展到普通多继承、菱形继承和虚继承。
首先,在单继承模型中,实验验证了 GCC 基于 vptr 和 vtable 实现虚函数动态分派的基本机制。对象内部保存指向虚函数表的 vptr,同一动态类型的对象共享相应的虚函数表。编译器在编译阶段确定虚函数对应的槽位,而在运行阶段通过对象中的 vptr 获取实际函数地址,并通过间接调用完成动态分派。派生类覆盖虚函数时,主要表现为对应虚函数表槽位中的函数地址发生变化,而不需要改变调用端的基本代码结构。
其次,本文进一步研究了单继承下若干特殊情况。纯虚函数仍然占据虚函数表中的固定槽位,不会改变基本动态分派方式。默认参数在编译阶段根据静态类型确定,而虚函数实现由运行时动态分派选择,体现了同一次函数调用中静态绑定与动态分派的共存。对象构造与析构期间,动态分派状态随生命周期阶段发生变化。此外,虚析构函数通过虚函数表提供相应的析构入口,使程序能够通过基类指针正确销毁派生类对象。
随后,在普通多继承模型中,本文观察到一个完整对象包含多个具有独立 vptr 的基类子对象。GCC 将多个虚函数表组织为一个多段虚函数表,使不同基类接口能够通过各自的 address point 完成动态分派。由于不同基类子对象可能位于不同的内存位置,基类指针转换及虚函数调用还需要进行相应的地址调整。
最后,通过研究菱形继承和虚继承,本文进一步分析了复杂继承关系对动态分派的影响。非虚菱形继承会沿不同继承路径产生多个独立的共同基类子对象,而虚继承则允许这些路径共享同一个虚基类子对象。为支持复杂继承关系,GCC 引入了更多的偏移量以及 VTT、construction vtable 等信息。
综合上述实验,可以发现 C++ 动态分派的核心始终是编译期确定虚函数槽位、运行期通过对象的虚函数表指针选择调用入口。 从单继承到多继承,再到虚继承,复杂度的增加主要体现在基类子对象的组织方式、虚函数表的结构,以及调用目标所需的 this 指针调整。