0%

C++ 对象模型实战(七):菱形继承与虚继承——vbptr 与偏移表

这一篇解决什么问题

多继承再叠一层就会出现菱形:A、B 都继承 V,C 同时继承 A 和 B。这时 V 在 C 里有几份?怎么解决?这一篇回答:

  1. 普通继承的菱形为什么会带两份基类子对象,有什么问题?
  2. 虚继承怎么做到只留一份,代价是什么?
  3. vbptr 和 vbtable(虚基类偏移表)是怎么工作的?

普通菱形:两份 V 的尴尬

1
2
3
4
5
  V          普通继承下, C1 的布局:
/ \ [A1 子对象: V + a][B1 子对象: V + b]
A1 B1 V 出现了两次
\ /
C1

后果不只是浪费内存:c1.v 编译直接报歧义错误,必须写 c1.A1::v 或 c1.B1::v 显式指定——同一个对象里有两个 v,编译器不知道你要哪个。

虚继承:一份 V,用间接换唯一

A2、B2 改成 virtual public V 后,C2 里 V 只有一份。实现思路(MSVC):

  1. V 被挪出常规子对象序列,放到对象尾部,由最派生类(C2)负责构造——A2、B2 不再各自携带 V。
  2. A2、B2 子对象里改放一个 vbptr(虚基类表指针,8 字节),指向一张 vbtable:表里存的是”从我的位置到 V 的偏移量”。
  3. 访问 a2.v 变成:取 vbptr → 查表得偏移 → 加上去 → 找到 V。多了一次间接,但任何路径找到的 V 都是同一份。

代价清晰可见:每个含虚基类的子对象多 8 字节 vbptr,每次访问虚基类成员多一次查表间接。

验证工程

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
// 菱形继承与虚继承: 两份基类子对象 vs vbptr 定位的单份虚基类.
#include <cstdio>

class V { public: int v = 0; };

// 普通继承的菱形: C1 里有两份 V 子对象.
class A1 : public V { public: int a = 1; };
class B1 : public V { public: int b = 2; };
class C1 : public A1, public B1 {};

// 虚继承的菱形: C2 里只有一份 V, 靠 vbptr 查表定位.
class A2 : virtual public V { public: int a = 1; };
class B2 : virtual public V { public: int b = 2; };
class C2 : public A2, public B2 {};

int main() {
printf("plain : sizeof(A1)=%zu sizeof(C1)=%zu\n", sizeof(A1), sizeof(C1));
printf("virtual : sizeof(A2)=%zu sizeof(C2)=%zu\n", sizeof(A2), sizeof(C2));

C1 c1;
printf("C1 two V's: &A1::v=%p &B1::v=%p (different)\n", // 两份 V 子对象, 地址不同.
(void*)&c1.A1::v, (void*)&c1.B1::v);

C2 c2;
printf("C2 one V: &A2::v=%p &B2::v=%p (same)\n", // 只有一份 V, 两条路径地址相同.
(void*)&c2.A2::v, (void*)&c2.B2::v);
// 配合 /d1 reportAllClassLayout 观察: vbptr, vbtable, 以及 V 被挪到对象尾部.
}

预测输出

动手跑之前先推一遍:

  • A1 = V 4 + a 4 = 8;C1 = A1 8 + B1 8 = 16,两份 V 各归各的子对象。
  • A2 变大:vbptr 8 + a 4 + padding + V(被挪到尾部)≈ 24;C2 里 A2、B2 各自的无 V 部分 + 共享的一份 V,估计 40 上下。虚继承的精确尺寸和对齐处理关系大,算错正常,关键是下面两条定性结论。
  • 定性结论 1:c1 的两条路径取到两个不同地址(两份 V 实锤)。
  • 定性结论 2:c2 的两条路径取到同一个地址(一份 V 实锤,歧义消失)。

实测输出

VS2022 x64 Debug 下实际运行:

1
2
3
4
plain   : sizeof(A1)=8  sizeof(C1)=16
virtual : sizeof(A2)=24 sizeof(C2)=40
C1 two V's: &A1::v=0000004102CFF9C8 &B1::v=0000004102CFF9D0 (different)
C2 one V: &A2::v=0000004102CFFA18 &B2::v=0000004102CFFA18 (same)

逐行比对

  • sizeof(A1)=8、sizeof(C1)=16:命中。
  • sizeof(A2)=24、sizeof(C2)=40:命中。C2 的 40 可以拆开看:A2 部分(vbptr 8 + a 4 + padding 4 = 16)+ B2 部分(16)+ 尾部共享的一份 V(v 4 + padding 4 = 8)。V 被挪到对象尾部、每个子对象多带一个 vbptr,这就是虚继承比 C1(16 字节)大 2.5 倍的代价。
  • C1 两个 v 地址不同,且正好差 8:命中。A1 子对象是 [v][a] 共 8 字节,B1 的 v 紧跟其后,差值 8 和 sizeof(A1)=8 互相印证。
  • C2 两条路径地址相同:命中,一份 V 实锤,c2.v 不再有歧义。

用 /d1 看 vbtable

这个工程的重点其实在编译输出里。/d1 reportAllClassLayout 对虚继承类会打印:

  • vbptr 的位置(通常在子对象偏移 0);
  • vbtable 的内容:形如 vbtable entry 0: 到自身偏移; entry 1: 到 V 的偏移;
  • V 子对象被放在整个对象布局的尾部,并用 virtual base 标注。

把这份报告和打印的地址对照,”V 在尾部 + vbptr 查表”这套机制就完全对上了。

小结

1
2
3
4
普通菱形: 每路一份 V, c.v 歧义, 必须 A::v / B::v 显式区分
虚继承: V 只有一份, 挪到对象尾部, 由最派生类构造
机制: vbptr(每个子对象 8 字节) → vbtable(存到 V 的偏移) → 间接定位
代价: 每个子对象多 8 字节 + 每次访问虚基类成员多一次间接