📌 vtable

TL;DR 性能开销

在不开启优化选项时编译:

  1. 每次调用虚函数比普通函数多两次访存。
  2. 每次动态类型转换(除非类型精确命中)都是耗时的图搜索算法。
  3. 明确需要精确转换时用 std::type_indexstatic_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;
};

其中:

  1. 在 64 位环境下,虚表段最少占用 24 个字节。
  2. 虚表除了虚函数外中还存储了对象的其他信息,包括 type_info 表指针。type_info 表中指明了类型的直接继承关系,如果想要查找间接继承关系,则需要用到图搜索算法。
  3. whole_object 的偏移是相对于 vptr / vtable 来说的,不是相对于 vtable_prefix 来说的。同时,origin 的地址是 vptr 指向的地址,而并不是 origin 和 vptr 相等(origin 的类型是指针,是因为它至少需要占用一个指针的地址,而且往后的每一项都要按照指针的要求来对齐)。
  4. 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  -|
}

虚函数调用

能观察到:

  1. 查虚表在不打开优化选项时比普通函数需要多访问两次内存
  2. 来自不同基类的虚函数会在不同的虚表段里去查,将上面的 p->f1(); 改成 p->f2(); 重新编译就能看出来。
  3. 在虚表内,虚函数地址通过编译期确定的下标访问,所以下标本身不会增加访存次数。例如给 A 增加虚函数 f4,并修改 p->f1();p->f4(); 会发现只在 first entry of vtable 之前增加了一个将 rax 增 8 的操作。
  4. 一个虚表指针占 8 字节,一个对象有几个继承链存在多态行为,就需要存储几个虚表指针。
  5. 对象中存储的虚函数表指针其实对应于虚表段 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 的实现:

  1. 空检查

  2. 根据虚函数表找到虚表段,再找到整个表开始地址。

  3. 获得 src_ptr 的类型信息。

  4. 确认约束:拿到的表指针必须以含这个类信息的虚表段开头。这一点是不一定满足的(比如正在构造对象时),毕竟如果必定满足,则可以简化查找虚表开始地址的操作,不用通过表段找开头了。不满足时转换不成功。

  5. 特殊情况优化:在编译器提示下去找虚表中的某一段,如果是本类型,则能直接成功。

后面的就比较复杂了:

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 精确判断类型

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 有了偏移,从而虚表指针值 *pvtable.C.C 变成了 vtable.C.B。(C 的大表上有 A 和 B 的两张小表。这里我用第一个类名表示大表是谁的,第二个类名表示小表段是谁的。)

那么 deletenew 出来但动态转换后的指针安全吗?

如果没有虚析构函数 虚表 则不安全:

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)。

实验的时候还应该保证用到的基类非空,否则有空基类优化,影响对结果的观察。