目录
三、虚基类表的偏移量是什么时候写入的?访问最底层的虚基类需要什么步骤?
在学习C++继承的底层原理的时候,我发现很多资料写的似乎不一致,有的资料说一个类的虚基类表指针数量永远只有一个,他记录着所有的虚基类偏移量。而有的资料则说一个类可能有多个虚基类表指针,他直接继承了多少个虚基类就会有多少个虚基类表指针。
于是产生了矛盾,让我也百思不得其解。后续查阅了大量的资料才发现上述二者说法都不对,正确的方式应该是独立路径的个数=虚基类表指针的个数。本文阐明了菱形继承、路径合并的关键,让我们对C++继承的理解更上一层楼。
一、路径独立的定义及判断方式
在路径中,我们喜欢把最上层的子类(继承了各个虚基类的子类)称为路径根。
这里
Final
就是 “路径根”,它的virtual
继承分支都以Final
为起点。
判断两条继承路径是否独立,核心看路径的中间基类是否完全无重叠,即 “中间路径原则”:
如果路径上有重叠,则可以合并成一个虚基类表指针。
(1)场景一:同根分支共享vbptr
class Base {};
class Base2 {};
// 路径根:Derived1
class Derived1 :
virtual public Base, // 分支1:从Derived1出发 → Base
virtual public Base2 // 分支2:从Derived1出发 → Base2
{ /* ... */ };
它们是 Derived1
继承列表里的平行分支(没有嵌套、没有继承关系),共享同一个 “根(Derived1)”。
核心:因为两个分支的起点(根)相同,编译器会把它们合并到同一张虚基类表里,用 1 个 vbptr
管理。
(2)场景二:锚点(vbptr的地址)不同,不能合并
class Base {};
// Derived1 有自己的虚基类路径
class Derived1 : virtual public Base { /* ... */ };
// Derived2 有自己的虚基类路径
class Derived2 : virtual public Base { /* ... */ };
// 路径根:Final
class Final :
virtual public Derived1, // 分支1:Final → Derived1 → Base
virtual public Derived2 // 分支2:Final → Derived2 → Base
{ /* ... */ };
你可能会疑惑:“场景 2 里 Final
继承 Derived1
和 Derived2
,根不也是 Final
吗?为啥路径不合并?”
—— 因为 Derived1
和 Derived2
本身有独立的虚基类分支,即他们自己有vbptr作为独立的锚点。导致路径无法合并。
为什么必须拆分路径?
- 两个
virtual
继承(Derived1
和Derived2
)的起点虽然都是Final
,但:
Derived1
自己有独立的虚基类路径(Derived1→Base
)Derived2
自己有独立的虚基类路径(Derived2→Base
)- 这两条路径在
Final
之外是分离的(Derived1
和Derived2
没有继承关系),无法合并。
也就是说想要合并到一个虚基类表中,要满足的条件是:
1.同一个根出发(锚点都在根处)
2.两条路径不是完全独立的,有相互继承关系
3.如果两条路径完全独立,且该路径上只有根和一个基类(没有独立的锚点),也可以合并
所以Final
的内存里,有 2 个 vbptr
(对应两条独立路径),每个 vbptr
指向不同的虚基类表:
(3)场景三:某一条路径是其他路径的一部分
class X {};
class A : virtual public X {};
// B 继承 A(有直接继承关系)
class B : virtual public A {};
// 路径根:Final,虚继承A和B
class Final : virtual public A, virtual public B {};
B
直接继承 A
(B
是 A
的子类),所以A有的B全都有,出现X为共享基类。两条分支有交叉依赖(B
的路径包含 A
);
→ 两条分支不独立 → Final
只需要 1 个 vbptr
(因为 B
的路径包含 A
,可合并)。
访问路径如下:
(4)判断步骤:3 步快速识别
(1)看是否有直接 / 间接继承:
检查两个分支的 “直接父类”(如A
和B
):如果A
是B
的父类(或反之),则不独立。(2)看是否共享基类但平行:
即使共享基类(如场景 2 的Base
),只要两个分支的直接父类(A
和B
)没有继承关系,就是独立的。(3)看继承链是否交叉:
从路径根(如Final
)出发,两条分支的继承链(Final→A→...
和Final→B→...
)如果没有重叠(除了可能的共享基类),则独立;如果有重叠(如场景 3 中B→A
),则不独立。
(5)总结
独立分支” 的核心是:从路径根出发后,两条分支的继承链像 “平行线” 一样分开,不会出现 “一条链包含另一条链” 的情况。
- 独立 → 需要多个
vbptr
(数量 = 分支数); - 不独立 → 可合并路径,只需 1 个
vbptr
。
二、为什么要虚基类表,而不是硬编码偏移量?
在上面的场景一中可以看到,他们是共用一个vbptr的。内存布局如下
如果不用虚基类表指针,而直接硬编码偏移量,则会发现需要两个偏移量,但是此时只需要一个指针大小即可存放。我们完全有理由可以推断:如果虚基类足够多,则可能需要记录很多个偏移量,此时采取指针和偏移量数组的方式才能更好的节省内存,否则一个类对象将会有大量的空间用于记录偏移量,得不偿失。
三、虚基类表的偏移量是什么时候写入的?访问最底层的虚基类需要什么步骤?
Derived也同理类似。
(1)为什么必须重新布局偏移量?
-
共享基类的地址变化:
Base
在FinalDerived
中被放到末尾,地址与Derived1
单独存在时完全不同,必须重新计算偏移。 -
继承体系的动态性:
当FinalDerived
新增成员(如int f
)时,Derived1
和Derived2
的子对象地址会变化,导致vbptr
中的偏移量必须动态调整。 -
虚基类表的复用性:
Derived1
的vbtable
可以被多个子类(如FinalDerived
、FinalDerived2
)复用,只需在子类中生成新的vbptr
和 vbtable,无需修改Derived1
本身的布局。
(2)总结:偏移量重新布局的核心逻辑
- 编译期计算:每个类的偏移量在编译时确定,子类继承时会重新计算父类
vbptr
的偏移量。 - 动态适配布局:虚基类表(vbtable)存储 “相对偏移”,确保无论父类在子类中的地址如何变化,都能正确找到共享基类。
- 复用与覆盖:父类的 vbtable 作为 “模板”,子类通过生成新的 vbtable 覆盖偏移量,保证继承体系的灵活性。
简单说:虚继承的偏移量会随着继承层次的加深动态重新布局,虚基类表是编译器应对这种动态性的核心机制,让复杂继承体系的地址计算始终正确。
(3)随着继承体系的加深,访问底层基类的跳转
注意:这里的偏移量是以当前虚基类表指针地址为锚点,所计算的偏移量。而并非是大类Finaldervied类的起始地址的偏移量。