📌 vtable
TL;DR 性能开销
在不开启优化选项时编译:
- 每次调用虚函数比普通函数多两次访存。
- 每次动态类型转换(除非类型精确命中)都是耗时的图搜索算法。
- 明确需要精确转换时用
std::type_index
和static_cast
会更快。
相关代码下载:
git clone -n --depth=1 --filter=tree:0 https://github.com/gcc-mirror/gcc.git
cd gcc
git sparse-checkout set --no-cone "libstdc++-v3/libsupc++/"
git checkout
虚表内存结构
代码
首先:在共享代码区域存储的虚表记录了特定类的信息。对象中的虚表指针其实是虚函数表指针,指向虚表中的一块位置。
Caution
这里我所称的虚表对应于 GCC 代码中的 vtable_prefix
,虚函数表对应于 GCC 代码中的 vtable
。可能不是很正规。
对于下面的代码,使用 gcc 13.1 编译:
#include <iostream>
// 会存储一个虚表指针,8字节
struct A { virtual void f1() { std::cout << "f1 from A\n"; } };
struct C { virtual void f2() { std::cout << "f2 from C\n"; } };
struct D { virtual void f3() { std::cout << "f3 from D\n"; } };
struct B: A, C, D {
B() {}
virtual void f1() override {
std::cout << "f1 from B\n";
}
};
int main() {
B *p = new B;
p->f1();
std::cout << "sizeof(A) = " << sizeof(A) << '\n'; // 8
std::cout << "sizeof(B) = " << sizeof(B) << '\n'; // 24
}
虚表和类型表的布局
vtable
typeinfo
虚表由虚表段组成,一个表段由三个信息组成:
struct vtable_prefix
{
// Offset to most derived object.
ptrdiff_t whole_object;
// Pointer to most derived type_info.
const __class_type_info *whole_type;
// What a class's vptr points to.
const void *origin;
};
其中:
- 在 64 位环境下,虚表段最少占用 24 个字节。
- 虚表除了虚函数外中还存储了对象的其他信息,包括
type_info
表指针。type_info
表中指明了类型的直接继承关系,如果想要查找间接继承关系,则需要用到图搜索算法。 whole_object
的偏移是相对于 vptr / vtable 来说的,不是相对于vtable_prefix
来说的。同时,origin
的地址是 vptr 指向的地址,而并不是origin
和 vptr 相等(origin 的类型是指针,是因为它至少需要占用一个指针的地址,而且往后的每一项都要按照指针的要求来对齐)。vtable_prefix
上面还存储了到虚基类子对象的偏移。这个例子中没有虚基类子对象。
再给个有虚基类子对象的例子:
代码:
#include <cassert>
#include <iostream>
struct A {
A() { std::cout << "A\n"; }
virtual void first() {}
virtual void second() {}
int data;
};
struct B : virtual A {
B() { std::cout << "B\n"; }
};
int main() {
B *pb = new B{};
pb->data = 42;
assert(*(int *)((unsigned char *)pb + 16) == 42);
// vptr.B vptr.A A::data padding
// 8B 8B 4B 4B
// |--- object of type B ---|
// |- object of type A -|
}
虚函数调用
能观察到:
- 查虚表在不打开优化选项时比普通函数需要多访问两次内存。
- 来自不同基类的虚函数会在不同的虚表段里去查,将上面的
p->f1();
改成p->f2();
重新编译就能看出来。 - 在虚表内,虚函数地址通过编译期确定的下标访问,所以下标本身不会增加访存次数。例如给 A 增加虚函数 f4,并修改
p->f1();
为p->f4();
会发现只在 first entry of vtable 之前增加了一个将 rax 增 8 的操作。 - 一个虚表指针占 8 字节,一个对象有几个继承链存在多态行为,就需要存储几个虚表指针。
- 对象中存储的虚函数表指针其实对应于虚表段 vtable_prefix 的 origin 域的地址。虚函数表至少有一个函数,否则这个表项可以被消除。
第 1 点可以和 C 语言常用的数据驱动编程进行对比:
对于一个含有 tag 的结构体,读 tag 是一次访存,用 tag 查表得到函数指针,又需要访存一次,一共也是两次。数据驱动编程下函数指针数组的位置已知,但 tag 未知;虚函数查找时虚表项下标已知(虚表在编译完成后各虚函数的下标就能确定,见 虚函数在编译时如何确定下标? ),但是虚函数表地址未知。
动态类型转换
对代码稍作修改:
#include <iostream>
// 会存储一个虚表指针,8字节
struct A { virtual void f1() { std::cout << "f1 from A\n"; } };
struct C: A { virtual void f2() { std::cout << "f2 from C\n"; } };
// ^^^ 继承于A
struct D { virtual void f3() { std::cout << "f3 from D\n"; } };
struct B: C, D {
// ^^ 不再继承于A
B() {}
virtual void f1() override {
std::cout << "f1 from B\n";
}
};
int main() {
A *p = new B;
auto bp = dynamic_cast<C *>(p);
bp->f2();
}
动态转换的汇编如下:
这就成了有向无环图的搜索问题。
下面是 GCC 的实现:
空检查
根据虚函数表找到虚表段,再找到整个表开始地址。
获得 src_ptr 的类型信息。
确认约束:拿到的表指针必须以含这个类信息的虚表段开头。这一点是不一定满足的(比如正在构造对象时),毕竟如果必定满足,则可以简化查找虚表开始地址的操作,不用通过表段找开头了。不满足时转换不成功。
特殊情况优化:在编译器提示下去找虚表中的某一段,如果是本类型,则能直接成功。
后面的就比较复杂了:
class_type_info 类型继承于 std::type_info,其 do_dyncast 函数是虚函数。从前面的图(离这里有点远)可以看到 B 的 type_info 是 **cxxabiv1::**vmi_class_type_info 类型的,其实现在 gcc/libstdc++-v3/libsupc++/vmi_class_type_info.cc:79
中,这是一个具体的图搜索实现,还考虑了可见性,理解起来会比较困难,这里略去。
用 type_index 精确判断类型
虚函数在编译时如何确定下标?
这是由于 C++ 需要支持多继承,因而指针转换时地址需要偏移。(不过空指针不需要做偏移处理)举例:
#include <iostream>
using namespace std;
struct A { int v; };
struct B { int u; };
struct C: A, B {};
int main() {
C *p = new C;
A* pa = (A *)p;
B* pb = (B *)p;
cout << p << '\n'; // 0x14612b0
cout << pa << '\n'; // 0x14612b0
cout << pb << '\n'; // 0x14612b4 注意地址变化了!
}
上面只涉及到了基本的多继承,还没有涉及到虚函数。再考虑虚函数(有虚表指针,所以 A 和 B 都不是空基类):
#include <iostream>
using namespace std;
struct A { virtual void a() {} };
struct B { virtual void b() {} };
struct C: A, B {};
int main() {
C *p = new C;
A *pa = (A *)p;
B *pb = (B *)p;
pa->a();
pb->b();
cout << p << endl; // 0xc3a2b0
cout << pa << endl; // 0xc3a2b0
cout << pb << endl; // 0xc3a2b8,同样变化了!
}
可以看出 p 转换成 pb 之后,地址也变化了。
地址偏移之后,新的指针指向区域的开头部分就是新的虚表指针。这也是为什么编译器能够确定虚函数在各自表中的下标——类型转换时只有虚表指针改变了,虚函数在表中的下标不变。
一个 C*
转换成 B*
。指针 p
有了偏移,从而虚表指针值 *p
从 vtable.C.C
变成了 vtable.C.B
。(C 的大表上有 A 和 B 的两张小表。这里我用第一个类名表示大表是谁的,第二个类名表示小表段是谁的。)
那么 delete
掉 new
出来但动态转换后的指针安全吗?
如果没有虚析构函数 虚表 则不安全:
struct A { int x; };
struct B { int y; };
struct C: A, B {};
int main() {
C *p = new C;
B* pb = (B *)p;
delete pb;
}
// Program returned: 139
// free(): invalid pointer
std::unique_ptr
也支持类型转换,但是类型转换之后仍然不安全:
#include <memory>
struct BaseObject {
int x;
};
struct AnotherBase {
int y;
};
struct DerivedObject : BaseObject, AnotherBase {
};
int main() {
std::unique_ptr<AnotherBase> uptr = std::make_unique<DerivedObject>();
}
// Program returned: 139
// free(): invalid pointer
如果 B 有虚析构函数有虚表(也就是 vptr
:不一定得是虚析构函数;也不一定得是多态类,通过虚拟继承引入的 vptr
也行):
#include <iostream>
#include <cassert>
using namespace std;
struct A { virtual ~A() {} };
struct B { virtual ~B() {} };
struct C: A, B { };
int main() {
C *p = new C;
A *pa = (A *)p;
B *pb = (B *)p;
assert((void *)pb != (void *)p);
delete pb; // pb 是 B* 类型,而 B 又有虚析构函数,因此做偏移可以找到真正的指针起始位置
}
结果:Program returned: 0
如果把 A 中的析构函数定义换成定义一个数据成员,上面的 assert
语句就会被触发,可能是编译期倾向于把有虚表的数据段放在尽可能开头的位置(2023 年 6 月 13 日:是的,这样可以共用 vptr)。
实验的时候还应该保证用到的基类非空,否则有空基类优化,影响对结果的观察。