C++ 对象模型浅析

C++ 对象模型浅析

本文希望从这个角度来理解 C++ 对象模型:假设我们作为一门编程语言的设计者,要实现面向对象的三大基本特性:封装、继承、多态,同时要满足与 C 兼容和 zero overhead 这两点约束。我们将带着这种观点去剖析 C++ 部分语言特性的实现。

在学习对象模型的时候,要注意区分 C++ 标准和实现,代码的正确性不应该依赖于编译器的实现细节。本文所述的大部分内容都是实现相关的,示例程序的实验环境为 g++ 5.4.0。如果你使用的编译器表现出不同的行为,不必感到意外。

笔者能力有限,如有疏漏,恳请诸君指出。

注:下文部分示例代码是不完整的,甚至是语法或语义上错误的,它们的目的是为了研究对象模型的实现,请勿作为实际编码的参考。

C++ 设计的约束

与 C 兼容

C++ 与 C 的兼容性主要体现两方面[2]:

  • 兼容 C 的语法,比如隐式类型转换;
  • 兼容 C 的编译模型和运行模型,可以直接使用 C 的头文件和库。

zero overhead

The zero-overhead principle is a C++ design principle that states:

  1. You don't pay for what you don't use.
  2. What you do use is just as efficient as what you could reasonably write by hand.

zero overhead 是 C++ 设计特性(功能)时遵循的一种原则

  • 你不需要为你不使用的特性付出(时间或空间)开销;
  • 你使用的任何特性都应该尽可能地高效,至少要和你自己手写代码实现该特性的性能一致。

C++ 中仅有两个特性不符合 zero overhead 原则,即运行时类型识别(RTTI)和异常,因此大多数编译器都可以关掉它们。

了解 C++ 的设计约束后,我们来思考该怎样实现面向对象的三大基本特性。

面向对象的三大基本特性在 C++ 语法层面上的支持

先来看看这三种特性在 C++ 语法层面上的支持:

  • 封装
    • 主要体现在访问控制,C++ 用访问修饰符 private、public、protected 来表达成员的可访问性;
    • 此外,C++ 支持公有、私有和保护继承,以控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
  • 继承
    • C++ 支持单继承、多继承和虚继承。
  • 多态
    • C++ 实现了动态多态(虚函数和动态绑定)和静态多态(模板实例化);
    • 支持派生类指针到基类指针的隐式类型转换。

我们关注的是这些特性在 C++ 中的实现(更具体地,是这些特性对类布局的影响以及它们带来的运行时开销),这些语言特性的细节请参考 《C++ Primer》 中的相关章节,在此不再赘述。

C++ 对象布局

先来看看不考虑 OOP 特性的情况下(不包含继承和虚函数),C++ 对象的布局。

先介绍两种可选的对象模型设计(当然,它们并没有在实践中被使用)。

简单对象模型

简单对象模型的成员都放到对象外,对象里维护一系列 slot 指向成员,通过 slots 的索引来取对象的地址。简单对象模型的优势是避免了不同类型的对象需要不同大小空间的问题,该模型下对象的大小是成员的数量乘以指针的大小。

虽然简单对象模型没有被使用,但 slot 的思想演化为类成员指针的概念。

表驱动的对象模型

表驱动的对象模型中,引入了两个表格,成员数据表里放置数据成员,成员函数表里放置成员函数的地址,对象本身持有这两个表的地址。表驱动的对象模型的优势是对象成员变更不会影响对象本身的大小,缺点是访问成员函数需要一次间接寻址,这点违背了 zero overhead 的设计哲学。

虽然表驱动的对象模型没有被使用,但成员函数表是虚函数和动态绑定机制实现的基础。

C++ 对象模型

C++ 对象模型由简单对象模型演化而来(优化了空间和访问速度),它将静态数据成员和成员函数(包括静态的和非静态的)放在对象外,因为它们是在类对象之间共享的;将非静态数据成员放在对象内。

在此之上,我们再来考虑如何实现 OOP 特性。

封装

访问修饰符的检查是在编译期完成,不会做运行时的检查。

下面这个程序如果你直接用对象 a 或指针 pa 访问成员 x,编译时都会报 error: ‘int A::x’ is private within this context 的错误,但用指针 pi 就可以访问这块内存区域(这是很危险的行为),因此可以看出 C++ 并没有真的对这块内存区域做运行时的保护措施。

class A {
public:
  A(int x) : x(x) {}

private:
  int x;
};

int main() {
  A a(1);
  // cout << a.x << endl;
  A *pa = &a;
  // cout << pa->x << endl;
  int *pi = (int *)&a;
  cout << *pi << endl;
}

Any number of access specifiers may appear within a class, in any order. Member access specifiers may affect class layout: the addresses of non-static data members are only guaranteed to increase in order of declaration for the members not separated by an access specifier (until C++11) with the same access (since C++11).

访问修饰符对类布局的影响主要体现在处于同一个访问区域(access section,即两个访问修饰符之间的区域)中的非静态成员变量保证以其声明顺序出现在内存布局中,也就是说后声明的变量,应该出现在高地址。这点在 C++11 中做了修改,改为了相同访问权限的变量都要满足该限制。

继承

继承对类布局的影响

C++ 支持单继承、多继承和虚继承,这里我们先不管虚继承,因为它的存在会引入太多的复杂度。

派生类对象中包含基类的所有数据成员,让我们来考虑这些成员应该放置在什么位置上。

先考虑两种可选的做法:

  • 给每个基类在派生类对象内分配一个 slot,slot 指向基类子对象。这种做法的缺点是访问基类子对象是间接访问,有额外开销;好处是派生类大小不受基类对象大小影响
  • 另一种做法是表驱动的思路,把所有基类子对象的地址放到一张表里,派生类对象里包含一个 bptr 指针指向这张表。缺点仍然是有间接访问开销;好处是派生类大小不受基类的数量影响。

两种做法的共同的弊端是间接访问的开销随继承链长度增加,一个可能的改进是空间换时间,让派生类持有它直接基类和所有间接基类的引用(无论是放到 slot 里还是放到基类表里)。

再来看看 C++ 对象模型对该问题的答案,C++ 将基类子对象直接放在派生类对象内部。这样的好处是空间紧凑、访问高效。相较于上面的两种方案,C++ 的模型去掉了所有的间接访问,符合 zero overhead 的设计原则。

C++ 保证出现在派生类中的基类子对象保持其原样,即基类子对象的内存布局和一个独立的基类对象是相同的(包括为了对齐而插入的填充)。

基类和派生类数据的布局没有先后的强制规定,但一般都会把基类放到前面。

一般来说,具体继承(与虚继承相对)并不会引入空间和时间上的开销。

如果你使用的是 g++ 编译器,可以用 -fdump-class-hierarchy 选项让它输出类的内存布局(新版本 g++ 中该选项变为 -fdump-lang-class)。

多继承下的地址调整

多继承中,如果将派生类指针转换为基类指针,需要编译器的干涉。

这种问题主要发生在派生类对象和它第二和后续基类之间的转换:

  • 将派生类指针赋值给它第一基类的指针,只需要单纯的赋值就行,因为它们两者的地址是相同的;
  • 但如果想要赋值给第二和后续基类的指针,就需要修改地址,加上(或减去)中间的基类子对象大小。
class X {
private:
  int x_;
};

class Y {
private:
  double y_;
};

class A : public X, public Y {
private:
  int a_;
};

int main(int argc, char const *argv[]) {
  A a;
  X *xp = &a;
  Y *yp = &a;
  printf("%p\n", &a);
  printf("%p\n", xp);
  printf("%p\n", yp);
}

示例程序的输出为:

0x7ffc1542efd0
0x7ffc1542efd0
0x7ffc1542efd8

明明 X 类型的大小是 4 字节,但为什么指针 yp 移动了 8 呢?这里我们打印类的布局:

Class X
   size=4 align=4
   base size=4 base align=4
X (0x0x7f2dd596d2a0) 0

Class Y
   size=8 align=8
   base size=8 base align=8
Y (0x0x7f2dd596d300) 0

Class A
   size=24 align=8
   base size=20 base align=8
A (0x0x7f2dd59f8070) 0
  X (0x0x7f2dd596d360) 0
  Y (0x0x7f2dd596d3c0) 8

可以看到 Y 的对齐要求是 8 字节,因此编译器在 X 子对象后面插入了 4 字节的 padding。

只要不存在虚继承,多继承中存取数据成员并不会带来额外的开销,因为所有数据成员的偏移量在编译期就已经确定了。

虚继承的影响

默认情况下,派生类中含有继承链上每个类对应的子部分,如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。

虚继承主要用来解决钻石继承(或菱形继承)带来的问题,比如标准库中的 IO 类:

base_ios 抽象基类负责保存流的缓冲内容并管理流的状态,且 iostream 希望在同一个缓冲区中进行读写操作,也要求条件状态能同时反映输入和输出操作的情况。因此如果 iostream 包含有两份 base_ios 的子对象,显然是会产生歧义的。

虚继承使得某个类做出声明,承诺愿意共享它的基类。不管虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。

虚继承和构造函数

在虚继承中,虚基类是由“最底层的派生类”初始化的。

继承体系中的每个类都可能在某个时刻成为“最底层的派生类”。比如,当我们创造 iostream 的对象时,它是最底层, base_ios 的初始化由它负责;但当我们构造 istreamostream 的对象时,它们就变成了最底层,base_ios 的初始化由它们的构造函数负责。

因此任何继承自虚基类的派生类的构造函数都应该初始化它的虚基类。

对象的构造流程

因此考虑虚继承的情况下,对象的构造流程是

  • 初始化该对象的虚基类部分,一个类可以有多个虚基类,这些虚的子对象按照它们在派生列表中出现的位置从左向右依次构造;
  • 按照直接基类在派生类列表中出现的次序依次对其进行初始化,要注意,直接基类的构造顺序与它们在初始化列表中出现的顺序无关;
  • 设置 vptr (参考章节 《vptr 的设置》);
  • 构造初始化列表中的数据成员;
  • 如果有类类型的数据成员不在初始化列表中,它们的默认构造函数会按照声明顺序被调用,但内置类型的非静态数据成员的初始化是程序员的责任;
  • 调用构造函数体。

此外,编译器也会插入代码做适当的设置,使得虚函数和虚继承机制能生效。

虚继承的开销

本章节内容和虚函数表相关,如果不了解这部分知识,请先阅读《多态》这一章节。

通过对象来存取虚基类成员是不会引发开销的,因为偏移量在编译期就能确定下来,开销发生在用指针和引用取用虚基类成员时。

class X {
public:
  int i;
};

class A : public virtual X {
public:
  int j;
};

class B : public virtual X {
public:
  double d;
};

class C : public A, public B {
public:
  int k;
};

// cannot resolve location of pa->X::i at compile-time
void foo(A *pa) { pa->i = 1024; }

int main() {
  foo(new A);
  foo(new C);
  // ...
}

示例代码中,pa->i 在对象中的偏移量在编译期无法确定,因为无法知道 pa 所指对象的具体类型,编译器会插入代码以允许在运行时确定它(比如在派生类中放置一个指针指向虚基类的位置)。

虚继承的实现

一般来说虚继承的实现会将类分割为两部分:不变部分和可变部分

  • 不变部分的偏移量是确定的,存取时不会有额外开销;
  • 可变部分即虚基类,只能间接存取(各家编译器对间接存取的实现是不同的)。

cfront 的实现方案

在每个派生类对象中安插一些指向虚基类的指针。

这种方案的缺点在于:

  • 指针的个数随虚基类的数量上升;
  • 虚继承链层数增加,间接存取的次数也会增加。

MetaWare 的改进

为了解决上面的第二个问题,MetaWare 将虚继承链中每一个虚基类的指针都放到派生类里,付出一些空间的开销,这样间接存取的时间就是固定的了。

微软的改进

为了解决第一个问题,一般有两种方案,微软采取引入一个虚基类表,将虚基类的指针都放在表格里,并在对象内放置一个虚基类表指针,指向这个表。

另一种改进方法

解决第一个问题的另一种方法也是用表格,不过表格里放的不是指针,而是偏移量。(和微软的方法没有本质上区别,选择这种方法的原因好像是因为知识产权方面的问题)

Effective C++ 关于虚继承给我们的建议是:使用虚继承的建议是:非必须不使用虚继承。如果要使用虚继承,尽可能不要在虚基类中放入数据成员。

我使用的编译器采用的就是这种方法的变体,不过它没有使用单独的虚基类表,而是作为虚函数表的一部分。我们查看示例代码 dump 出来的类结构:

Vtable for C
C::_ZTV1C: 6u entries
0     36u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI1C)
24    20u
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI1C)

虚表中有两项 36u 和 20u,表示在 this 指针的基础上加上这个偏移量就能拿到虚基类。

多态

Wiki 上将多态定义为:在编程语言和类型论中,多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示多个不同的类型

C++ 中支持两种多态机制:动态多态表现为通过基类指针或引用调用虚函数时会执行动态绑定,根据绑定到指针或引用上的动态类型来决定调用调用虚函数的哪个版本;静态多态(编译期多态)表现为用不同的模板实参实例化模板会导致不同的函数调用,STL 的实现中大量使用静态多态。

本文主要关注动态多态的实现,即虚函数和动态绑定机制是怎么体现在对象模型中的。

虚函数表

实现上,C++ 通过虚函数表来支持虚函数机制,继承体系中的每个类都会有一张虚函数表(继承体系中的类一定要有虚函数,关于这一点请看 Effective C++ 条款 7),里面记录的是虚函数实例的地址,包括:

  • 这个类定义的函数实例
    • 可能会 override 掉基类的函数实例
  • 继承自基类的函数实例
  • 纯虚函数的表项指向pure_virtual_called()
    • 当它被错误地调用时,该程序会被结束掉

每个虚函数都会被分配一个索引值(由编译器来决定),通过该索引值查虚函数表以实现动态绑定。假设 normalize() 是一个虚函数,则对它的调用 ptr->normalize() 会被转换为 ( * ptr->vptr[1])( ptr )

那么对象该怎么拿到自己的虚函数表呢,为此,如果某个类型有虚函数,它的对象内就会包含虚函数表指针(vptr),指向该类型的虚函数表。此外,RTTI 相关的信息往往也是通过 vptr 获得的。添加 vptr 后,C++ 对象的布局如下图所示:

这里我们展示一个示例程序,希望读者能对虚函数表的实现能有更形象的感知:

using VoidFunc = void();

class A {
public:
  virtual void vfunc1() { cout << "A::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "A::vfunc2()" << endl; }
};

class B : public A {
public:
  virtual void vfunc1() override { cout << "B::vfunc1()" << endl; }
};

class C : public B {
public:
  virtual void vfunc1() override { cout << "C::vfunc1()" << endl; }
};

int main() {
  A a;
  VoidFunc **vptr_a = ((VoidFunc **)(*(long *)&a));
  vptr_a[0]();
  vptr_a[1]();
  printf("%p\n", vptr_a);
  printf("%p\n", vptr_a[0]);
  printf("%p\n", vptr_a[1]);

  B b;
  VoidFunc **vptr_b = ((VoidFunc **)(*(long *)&b));
  vptr_b[0]();
  vptr_b[1]();
  printf("%p\n", vptr_b);
  printf("%p\n", vptr_b[0]);
  printf("%p\n", vptr_b[1]);

  C c;
  VoidFunc **vptr_c = ((VoidFunc **)(*(long *)&c));
  vptr_c[0]();
  vptr_c[1]();
  printf("%p\n", vptr_c);
  printf("%p\n", vptr_c[0]);
  printf("%p\n", vptr_c[1]);
}

这个示例程序的输出为:

A::vfunc1()
A::vfunc2()
0x55f619c17d30
0x55f619c15400
0x55f619c1543c
B::vfunc1()
A::vfunc2()
0x55f619c17d10
0x55f619c15478
0x55f619c1543c
C::vfunc1()
A::vfunc2()
0x55f619c17cf0
0x55f619c154b4
0x55f619c1543c

让我们将这个程序的输出画出来,可以看到 B 和 C override 了 vfun1 后,虚函数表中的表象指向了各自的函数实例。

分析 -fdump-class-hierarchy 的输出我们也能得到同样的结论:

Vtable for A
A::_ZTV1A: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1A)
16    (int (*)(...))A::vfunc1
24    (int (*)(...))A::vfunc2

Vtable for B
B::_ZTV1B: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1B)
16    (int (*)(...))B::vfunc1
24    (int (*)(...))A::vfunc2

Vtable for C
C::_ZTV1C: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1C)
16    (int (*)(...))C::vfunc1
24    (int (*)(...))A::vfunc2

c++filt 命令解析这里的符号会发现,_ZTV 是虚表符号的前缀,比如 _ZTV1B 表示 vtable for B_ZTI 是类型信息的前缀,比如 _ZTI1C 表示 typeinfo for C,虚函数表中这个表项就是指向 typeinfo 的指针。所以这里也验证了我们之前说的,可以通过 vptr 拿到 RTTI 相关信息。

虽然我们的示例程序在对象的开头拿到了 vptr,但事实上标准并没有指定 vptr 的位置,甚至没有规定实现必须通过虚函数表的方式来实现动态绑定(虽然基本上所有编译器都是这么做的)。编译器可以将 vptr 放在对象的开头、结尾,甚至中间的某个位置上(虽然并没有编译器这样做),因此你的代码不应该依赖于 vptr 的位置,否则是没有可移植性的。

vptr 的设置

我们现在知道动态绑定是用虚函数表和 vptr 来实现的,但问题在于 vptr 是由谁来设置的呢?很显然,我们自己的代码里没有做这样的设置。其实是编译器将初始化 vptr 的代码插入到我们的构造函数中。这会带来一些微妙的后果,主要发生在我们在构造函数和析构函数中调用虚函数的情况下。(参考 Effective C++ 条款 9)

我们先来看一个示例程序:

class Base {
public:
  Base() {
    cout << "In Base ctor" << endl;
    func();
  }
  virtual ~Base() {
    cout << "In Base destructor" << endl;
    func();
  }
  virtual void func() { cout << "type is Base" << endl; }
};

class D : public Base {
public:
  D() {
    cout << "In D ctor" << endl;
    func();
  }
  virtual ~D() {
    cout << "In D destructor" << endl;
    func();
  }

  virtual void func() override { cout << "type is D" << endl; }
};

int main(int argc, char const *argv[]) { D d; }

示例程序的输出为:

In Base ctor
type is Base
In D ctor
type is D
In D destructor
type is D
In Base destructor
type is Base

可以看到在基类 Base 的构造和析构函数中调用虚函数时,调用到的是基类的版本;而在派生类 D 的构造和析构函数中调用虚函数时,调用到的是派生类的版本。

原因是显而易见的,不考虑虚继承的情况下,C++ 中对象的构造顺序是先按派生列表中的顺序构造基类、再构造派生类;析构时的顺序与构造相反。(参考章节《对象的构造流程》,里面我们给出了一个较为完善的对象构造流程)在基类构造函数中,派生类的成员还没有被初始化,而派生类虚函数可能会去访问这些成员,此时调用它是危险的;同理,在析构函数中,派生类的成员已经被销毁了,也不应该允许派生类虚函数被调用。

其实这个特殊规则是通过在合理的时机设置 vptr 来实现的:编译器插入的代码会在调用完虚基类和直接基类的构造函数后,再将 vptr 指向当前对象类型的虚函数表。这样产生的效果就是,在 Base 的构造函数中,vptr 指向的是 Base 的虚函数表;在 D 的构造函数中,vptr 指向的是 D 的虚函数表。同样地,在析构对象时,也会对 vptr 做相应的调整。

为了避免这种场景下的 BUG,《Effective C++》给我们的建议是 Never call virtual functions during construction or destruction

动态绑定的开销

讲完动态绑定的实现,我们来看看它会带来哪些开销:

空间开销上,继承体系中的每个类都需要虚函数表,且这些类的每个对象都需要 vptr。

The time it takes to call a virtual member function is a few clock cycles more than it takes to call a non-virtual member function, provided that the function call statement always calls the same version of the virtual function. If the version changes then you may get a misprediction penalty of 10 - 20 clock cycles.

时间开销上,虚函数调用需要查找虚表做间接寻址,要比一般的函数调用更慢,而且如果 CPU 分支预测错误,需要冲刷流水线,就会消耗更多的时钟周期。

另一个可能影响程序性能的问题是,编译器可能没法对虚函数调用做内联优化,因为具体调用的版本是在运行时才确定的。但某些情况下,如果编译期能确定下来你调用的到底是哪个版本的虚函数(比如通过类对象,而不是类引用来调用),其实是可以做内联优化的。因此将函数声明为 inline virtual 其实是有意义的[7],这也从另一个侧面说明了,inline 关键字是对编译器的建议,而不是一种强制性要求。(关于内联的更多细节请看 Effective C++ 条款 30)

如果虚函数是程序的性能瓶颈,可以考虑不使用多态来实现类似的功能(Effective C++ 条款 35 中给出了一些虚函数以外的其他选择),或尝试编译期多态,它的效率更高,因为模板参数的解析的在编译期完成的,不涉及运行时的开销,但缺点在于模板的语法过于复杂。

多继承和虚函数

接下来,我们讨论多继承下虚函数的实现。多继承对虚函数的影响主要体现在两方面:调整 this 指针和产生额外的虚函数表。

调整 this 指针

为了在多继承下支持虚函数,其困难来自于第二和后续的基类,必须在执行期调整 this 指针。对 this 指针的调整会发生在三种情况下:

  • 通过第二和后续的基类类型的指针调用派生类虚函数;
  • 通过派生类指针,调用从第二和后续的基类类型继承来的虚函数;
  • 如果某个虚函数的返回值是基类指针类型,派生类重写它的时候,可以改变它的返回值类型为派生类指针类型。
class Base1 {
public:
  virtual ~Base1() {}
  virtual Base1 *clone() { cout << "Base1::clone()" << endl; }

private:
  int i_;
};
class Base2 {
public:
  virtual ~Base2() {}
  virtual Base2 *clone() { cout << "Base2::clone()" << endl; }

private:
  int j_;
};
class Derived : public Base1, public Base2 {
public:
  virtual ~Derived() {}
  virtual Derived *clone() { cout << "Derived::clone()" << endl; }

private:
  int k_;
};

int main() {
  Base2 *bp2 = new Derived;    // <1>
  Base2 *other = bp2->clone(); // <2>
  // ...
  delete bp2; // <3>
}

第 <1> 行动态分配 bp2 时,需要将新分配的 Derived 对象地址调整到 Base2 子对象的地址上再赋值给 bp2,这个调整必须在编译期完成的。否则,即使通过非多态的方式(比如用 bp2 来访问 Base2 的数据成员)来使用 bp2 指针也会造成错误。

编译器会插入类似的代码来做调整:

Derived *temp = new Derived;  
Base2 *pbase2 = temp ? temp + sizeof( Base1 ) : 0;

第 <2> 行 clone() 返回的 Derived 指针要被调整为 Base2 子对象的地址;第 <3> 行通过 bp2 删除 Derived 对象时,必须调整 bp2 的值,让它指向对象的开头。这些调整,并不能在编译期完成,因为此时无法确定 bp2 指向的具体对象的类型,这种调整一般放到执行期来完成,为了实现这点编译器必须在某处插入调整的代码。

实现调整的一种做法

扩大虚函数表,让它包括虚函数地址和可能的调整值(不需要做调整的情况下,offset 等于 0)。

使用这种方法,虚函数调用 ( *pbase2->vptr[1])( pbase2 ); 会被编译器转换为:

( *pbase2->vptr[1].faddr)  
   ( pbase2 + pbase2->vptr[1].offset );

这种做法的缺陷在于它事实上给所有虚函数调用都带来了开销,不管它是否需要做调整。

thunk 技术

thunk 是指一段汇编代码,它需要做两件事:用适当的偏移量调整 this 指针,然后再跳到虚函数去。

比如用 Base2 指针调用 Derived 析构函数的 thunk 可能类似于:

// Pseudo C++ code  
pbase2_dtor_thunk:  
   this += sizeof( base1 );
   Derived::~Derived( this );

使用 thunk 技术,虚函数表中仍可以仅包含指针,它可以指向虚函数,(需要调整 this 指针时)也可以指向一个 thunk。

额外的虚函数表

Base1 *bp1 = new Derived;
Base2 *bp2 = new Derived;
delete bp1;
delete bp2;

虽然上面两个 delete 最终调用的都是同一个析构函数,但它们需要不同的虚函数表 slot(一个指向虚函数实例,一个指向 thunk)。

由于派生类自己的虚函数表是和第一个基类的虚函数表共享的,因此多继承中一个派生类包含 n-1 个额外的虚函数表,n 表示直接基类的个数。每个虚函数表都会以外部对象的形式产生出来,并被给予一个独一无二的名字。

另一种可选的做法是将多个虚函数表连为一个,通过偏移量来获得额外表格的内容。我所使用的编译器就是采取了这种做法:

Class Derived
   size=32 align=8
   base size=32 base align=8
Derived (0x0x7f8b8239e8c0) 0
    vptr=((& Derived::_ZTV7Derived) + 16u)
  Base1 (0x0x7f8b82255900) 0
      primary-for Derived (0x0x7f8b8239e8c0)
  Base2 (0x0x7f8b82255960) 16
      vptr=((& Derived::_ZTV7Derived) + 56u)

Vtable for Derived
Derived::_ZTV7Derived: 10u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Derived::~Derived
24    (int (*)(...))Derived::~Derived
32    (int (*)(...))Derived::clone
40    (int (*)(...))-16
48    (int (*)(...))(& _ZTI7Derived)
56    (int (*)(...))Derived::_ZThn16_N7DerivedD1Ev
64    (int (*)(...))Derived::_ZThn16_N7DerivedD0Ev
72    (int (*)(...))Derived::_ZTchn16_h16_N7Derived5cloneEv

从偏移量 16 开始是 Derived 和 Base1 共用的虚函数表,从偏移量 56 开始是 Base2 的虚函数表,_ZThn8_N7DerivedD1Ev 表示 non-virtual thunk to Derived::~Derived()_ZTchn8_h8_N7Derived5cloneEv 表示 covariant return thunk to Derived::clone(),由此可以判断该编译器是通过 thunk 技术来做 this 指针的调整。

因此当你用 bp2 调析构函数时,就会去查找 Base2 的虚函数表,调用到的就是 thunk,而用 bp1 调用时,调用到的是真正的析构函数实例。

两个虚表的 typeinfo 上面都有一个数字,一个是 0,一个是 -16,这个数字称为 top_offset,这个数字就是说当前的 this 指针要调整多少个字节,才能拿到整个对象的开始位置,如果是 Base2*,就需要减 16 个字节才能拿到对象的开头,可以试着增加 Base1 的大小,观察这个数字的变化。

至于这里的虚表里为什么有两个虚析构函数,其实是 Itanium C++ ABI 的要求,感兴趣的读者可以看参考材料[9]。

虚表和 vague linkage

之前我们讲过 C++ 继承了 C 语言的编译模型,也就是说 C++ 程序要先将每个 .cpp 文件编译为 .o 文件,再链接在一起成为可执行文件。这种编译模型决定了编译的时候看不到其他编译单元的信息,因此在某些情况下[8],编译器(这里说的是狭义的编译器,只产生 .o 文件,不做链接)可能没法确定其他编译单元是否可能会产生虚函数表,只能自己产生一份,结果是多个编译单元中都可能产生同一张虚函数表的定义。

这种同一个符号有多份互不冲突的定义的情形称为 vague linkage。这些重复的定义最终由链接器来处理,链接器往往会消除重复代码,只留下一份定义。除虚函数表以外,vague linkage 还可能发生在内联函数、模板实例化和 type_info 对象上。

参考材料

posted @ 2022-06-01 23:20  路过的摸鱼侠  阅读(950)  评论(0编辑  收藏  举报