【C++】菱形继承深度解析+实际内存分布
这里写目录标题
- 概述
- 虚继承、虚函数涉及数据结构
- 菱形继承
- 虚继承接解决方案
- C++ 中虚继承 + 多态下vptr、vbptr分布机制
- MSVC(Microsoft ABI)对象布局
- GCC
概述
在 C++ 中,虚继承用于解决菱形继承中重复基类的问题。通过 virtual
继承,编译器保证无论通过多少条路径继承,虚基类只会保留一份副本。为了实现这一点,编译器会在中间类(如 Derived1、Derived2)中插入一个 vbptr(虚基类表指针),它指向一张 vbtable,表中记录虚基类在最终派生类对象中的偏移。
当通过中间类指针访问虚基类成员(如 ((Derived1*)&f)->a
)时,编译器不能确定虚基类 Base
在对象内的具体位置,必须通过 vbptr → vbtable → 偏移
查表获得最终地址,再加到 this
指针上完成访问。这是虚继承访问的“查表 + 加偏移”机制。
相比之下,若通过 Final*
直接访问虚基类成员,编译器可以直接写死偏移量,无需查表。这也是为什么虚继承的访问路径取决于你是通过哪条继承路径访问的。
虚继承还要求虚基类的构造只能由最派生类完成,确保共享子对象只被构造一次。虚继承对象除了可能包含 vptr(虚函数表指针)用于虚函数多态外,还一定包含 vbptr + vbtable,用于虚基类定位,即使没有虚函数,也依然存在虚继承的结构开销。
虚继承、虚函数涉及数据结构
名称 | 中文叫法 | 用于什么机制 | 存在哪? | 存什么内容? | 何时出现? |
---|---|---|---|---|---|
vtable | 虚函数表 | 虚函数调用(多态) | 独立在代码段中 | 虚函数地址数组 | 类有虚函数时生成 |
vptr | 虚函数表指针 | 指向 vtable | 每个含虚函数对象内 | 指向对应的 vtable | 类含虚函数 → 对象含 vptr |
vbtable | 虚基类偏移表 | 虚继承偏移管理 | 独立在代码段中 | 虚基类的偏移信息 | 类虚继承时生成 |
vbptr | 虚基类表指针 | 指向 vbtable | 每个虚继承对象内 | 指向对应的 vbtable | 类虚继承 → 对象含 vbptr |
菱形继承
🎯 一、虚继承为什么出现?(问题背景)
当一个类 Final
同时继承自 Derived1
和 Derived2
,而这两个类又继承自同一个基类 Base
,就会形成菱形继承结构:
Base/ \
Derived1 Derived2\ /Final
问题
- 如果是普通继承,
Final
对象中会包含两份Base
的副本。 - 导致:
- 数据冗余:有两份
Base::value
,哪个才是有效的? - 二义性:访问
value
时不明确该用哪一个。 - 资源释放多次:如果
Base
管理资源,可能析构两次导致崩溃。
- 数据冗余:有两份
虚继承接解决方案
🚧 二、虚继承解决方案与代价
class Derived1 : virtual public Base {};
class Derived2 : virtual public Base {};
- 虚拟继承保证 Base 子对象只存在一份,无论通过多少条路径继承过来。
- “虚继承为了解决菱形继承重复基类问题,引入了 vbptr(虚基类表指针),通过它在运行时动态定位虚基类子对象。虚基类子对象通常放在派生类对象末尾,因此虚继承对象比普通继承对象更大,访问虚基类成员也多了一次间接寻址。”
🧠 三、内存布局详解(ASCII图)
class Base { int a; };
class Derived1 : virtual public Base { int b; };
class Derived2 : virtual public Base { int c; };
class Final : public Derived1, public Derived2 { int d; };
Final 对象 f 内存结构(简化布局):+----------------------+ ← Final对象起始地址 (f)
| Derived1::vbptr | ← Derived1 的虚基指针
+----------------------+
| b (Derived1 成员) |
+----------------------+
| Derived2::vbptr | ← Derived2 的虚基指针
+----------------------+
| c (Derived2 成员) |
+----------------------+
| d (Final 自身成员) |
+----------------------+
| a (Base::a) | ← ✅ 唯一的 Base 子对象
+----------------------+
- 中间派生类(如
Derived1
):并不拥有基类(如Base
)的成员变量,也不真正包含Base
子对象,只有最派生类(如Final
)才真正拥有Base
子对象的内存空间。 - 中间派生类的内存布局里没有基类的成员函数吗
- 虽然
Derived1
声明了继承Base
,但由于是虚继承,它并不会在自己的对象中嵌入Base
子对象; - 取而代之的是编译器插入一个 vbptr(虚基类表指针),用于在最终派生类里共享唯一一份
Base
子对象。
- 虽然
- ✅ 2. 那
Base
的成员函数去哪了?- 虽然
Derived1
不直接拥有Base
的数据成员(如int a
)或函数成员(如f()
),但只要Base::f()
是虚函数,那么:Derived1
的 虚函数表(vtable) 中仍然会保留Base::f()
的指针;- 真正的
Base
数据成员和虚表指针,只有在 最终派生类(如Final
) 中才会被实际分配并共享。 - 只要 Base 的函数是虚函数,它会在 Derived1 的虚表中出现,支持多态调用;但 Base 的实际数据和对象布局,只会在最终类(如 Final)中统一存在一份,由 vbptr 指向。
- 虽然
在虚继承中,由于 Base 只存在一份,它的位置由最派生类决定,不能提前写死偏移。所以中间类会插入一个 vbptr
,指向虚基类表 vbtable
,表中记录虚基类在最终对象中的偏移。访问时,先通过 vbptr
查出 Base
的偏移,加到 this
指针上,最终定位到 Base
成员。这就是虚继承访问路径的“查表 + 加偏移”机制。
🏗️ 五、构造函数执行顺序图(虚继承)
class Base { Base() { cout << "Base\n"; } };
class D1 : virtual public Base { D1() { cout << "D1\n"; } };
class D2 : virtual public Base { D2() { cout << "D2\n"; } };
class Final : public D1, public D2 { Final() { cout << "Final\n"; } };int main() { Final f; }Base
D1
D2
Final
C++ 中虚继承 + 多态下vptr、vbptr分布机制
#include <iostream>
using namespace std;class A {
public:virtual void f(); // ✅ 虚函数 ⇒ vptrint a = 1;
};class B : virtual public A {
public:virtual void g(); // ✅ 虚函数 ⇒ vptrint b = 2;
};class C : virtual public A {
public:virtual void h(); // ✅ 虚函数 ⇒ vptrint c = 3;
};class D : public B, public C {
public:virtual void i(); // ✅ 虚函数 ⇒ vptrint d = 4;
};int main() {A a;B b;C c;D d;cout << "A: " << sizeof(A) << endl;cout << "B: " << sizeof(B) << endl;cout << "C: " << sizeof(C) << endl;cout << "D: " << sizeof(D) << endl;return 0;
}// ✅ 实现部分(必须加上)
void A::f() {}
void B::g() {}
void C::h() {}
void D::i() {}
实验发现,不同编译器下优化策略不一样
MSVC(Microsoft ABI)对象布局
A对象; 16字节
A a;
// 内存布局
8字节:a的虚表指针
4字节:成员变量
4字节:padding
B对象:40字节
B b;
// 内存布局
8字节:b的虚表指针
8字节:b的虚基表指针
4字节:b的成员变量
4字节:padding
8字节:a的虚表指针
4字节:a的成员变量
4字节:padding
C对象和B对象一样;
D:72字节 = b:24+c:24+d:8 + a:16
D d;
// 内存布局
8字节:b的虚表指针
8字节:b的虚基表指针
4字节:b的成员变量
4字节:padding
8字节:a的虚表指针
4字节:a的成员变量
4字节:padding
发现MVSC下c d成员竟然没优化到一起(这点不知道为啥???);
- d直接访问abcd的成员,全是通过首地址+偏移访问;
- 类
D
自身通常不会额外持有一个独立的虚表指针(vptr)。其虚函数D::i()
会被编译器优化地插入到继承路径中的某个已有虚表中(如B
或C
的虚表)。因此,在内存布局中,你会看到D
对象拥有B
、C
、A
各自的 vptr 和 vbptr,但没有单独的D::vptr
,这体现了 C++ 编译器在虚函数表布局上的重用与优化策略。 - ✅ D 如何访问
f()
?- D → B (vbptr_B) → offset_to_A → A 子对象 → vptr_A → f()
- 而
B
自己并不会再“拷贝”A::f()
进自己的虚表中。
D
直接访问A::a
- 编译器知道 A 在
D
中的偏移(比如 0x38),会直接用偏移访问;
- 编译器知道 A 在
D
通过 B 或 C 的方式访问 A
GCC
🌿 1. 类 A(普通虚函数类)
offset 0x00: vptr_A
offset 0x08: int a
offset 0x0C: padding
sizeof(A) = 16
B对象 (32字节) - 虚拟继承A:> 虽然 B 虚继承 A,在多继承中只保留一份 A,但 B 单独存在时还是附带一份自己的 A 虚基副本。
B 对象(大小:32 bytes)
┌──────────── offset 0x00
│ [0x00..0x07] vptr_B ← B 的虚函数表指针
│ [0x08..0x0B] int b ← 自己的成员
│ [0x0C..0x0F] padding ← 对齐填充
├──────────── offset 0x10
│ A 虚基子对象(附带) ← vptr_A + a
│ [0x10..0x17] vptr_A ← 虚基类 A 的虚表指针
│ [0x18..0x1B] int a
│ [0x1C..0x1F] padding
└──────────── sizeof(B) = 32
C对象 (32字节) - 虚拟继承A:和 B 一样,虚继承 A;布局、大小、vptr 结构都完全一样,只需将 b
改为 c
,vptr_B
改为 vptr_C
C 对象(大小:32 bytes)
┌──────────── offset 0x00
│ [0x00..0x07] vptr_C ← B 的虚函数表指针
│ [0x08..0x0B] int c ← 自己的成员
│ [0x0C..0x0F] padding ← 对齐填充
├──────────── offset 0x10
│ A 虚基子对象(附带) ← vptr_A + a
│ [0x10..0x17] vptr_A ← 虚基类 A 的虚表指针
│ [0x18..0x1B] int a
│ [0x1C..0x1F] padding
└──────────── sizeof(C) = 32
🧩 D 对象内存布局(按字节)
假设在 64 位系统下,指针大小为 8 字节。
🎯 关键点(GCC Itanium):
- 没有
vbptr
A
虚基类在 D 的尾部(只保留一份)- 虚基偏移存在于 vtable 的负索引槽中
- 所有虚函数表指针放在子对象头部
D::d
会被尽可能塞入空隙区(如 C 的 padding)
在 GCC(Itanium ABI)下,
B
即使虚继承了A
,它的对象中❌不包含显式的“虚基表指针(vbptr)”字段。
D 对象(大小:48 bytes)
┌──────────── offset 0x00
│ B 子对象(16 bytes)
│ [0x00..0x07] vptr_B-in-D ← 指向 D 的 vtable(B 视图)
│ [0x08..0x0B] int b = 2
│ [0x0C..0x0F] padding
├──────────── offset 0x10
│ C 子对象(16 bytes)
│ [0x10..0x17] vptr_C-in-D ← 指向 D 的 vtable(C 视图)
│ [0x18..0x1B] int c = 3
│ [0x1C..0x1F] int d = 4 ← D 的成员变量被紧贴在此处(复用 C 尾部空间)
├──────────── offset 0x20
│ A 虚基子对象(16 bytes)
│ [0x20..0x27] vptr_A-in-D ← 指向 D 的 vtable(A 视图)
│ [0x28..0x2B] int a = 1
│ [0x2C..0x2F] padding
└──────────── sizeof(D) = 48