内存布局和多态不同编译器支持方式不同,以下基于 Itanium C++ ABI(GCC/Clang/x86-64 主流环境)讲解,MSVC 的差异会单独标注。先建立整体框架,再逐个击破。
1.空类型(Empty Class)
c
class Empty {};
Empty e;
sizeof(e) // == 1,不是 0
1.1.内存布局
为什么不是 0 字节?
C++ 保证任何两个独立对象拥有不同的地址。如果 sizeof(Empty) == 0,数组 Empty arr10 中所有元素地址相同,取地址、比较指针全都失效。因此:
- 空类占位 1 字节(通常用 char 填充),只起地址区分作用。
- 例外:作为基类时,空基类优化(EBO, Empty Base Optimization)允许编译器不为空基类分配存储。
c
struct Empty {};
struct Derived : Empty { int x; };
sizeof(Derived) // 可能 == 4(EBO 生效,Empty 不占空间)
注意:空类并非"真空"------它有身份(地址)、可能有的隐式成员见下文。
1.2.虚表
c
struct Empty { virtual void f(); };
sizeof(Empty) // == 8(64位:一个 vptr)
一旦声明虚函数,对象头部插入虚表指针 vptr,对象不再是空的。每个含虚函数的类(模板实例也各自)有一张虚表。
2.普通(单)继承
c
struct Base {
virtual void vf();
virtual void vf2();
int b;
};
struct Derived : Base {
void vf() override; // 覆写
virtual void df(); // 新增虚函数
int d;
};
2.1.内存布局
单继承是"线性叠加":
c
Derived 对象:
+----------------+ offset 0
| vptr | → 指向 Derived 的虚表
| int b | offset 8
| int d | offset 12
+----------------+
sizeof == 16
派生类对象就是一个基类子对象加上自己的成员(is-a 在内存上是真的)。基类子对象地址 == 派生类对象地址,this 指针不需要调整。
2.2.虚表
Derived 的虚表构建规则(编译期):
c
Base 虚表: Derived 虚表:
+----------+ +----------+
| &Base::vf | | &Derived::vf | ← 覆写,槽位被替换
| &Base::vf2| | &Base::vf2 | ← 未覆写,原样继承
+----------+ | &Derived::df | ← 新增虚函数追加在尾部
+----------+
关键机制:覆写不是"指向派生类新表",而是把基类虚表中的槽位(slot)替换为派生类函数地址。布局与基类兼容,使得"派生类虚表是基类虚表的超集"。
2.3.多态原理
c
Base* p = new Derived;
p->vf(); // 调用 Derived::vf
三步走:
- 从对象头取 vptr(p 指向的地址偏移 0 处);
- 在虚表中按编译期已知的槽位索引取函数地址(vf 是第 0 槽,编译器从 Base 声明里算出);
- 调用该地址。
代价与收益:一次间接寻址(缓存友好的话几乎无感),换来运行期绑定。注意:多态只对指针/引用生效,按值切片(Base obj = derived;)会切掉派生部分,vptr 变成 Base 的。
3.多重继承(Multiple Inheritance)
c
struct A { virtual void fa(); int a; };
struct B { virtual void fb(); int b; };
struct C : A, B {
void fa() override;
void fc();
int c;
};
3.1.内存布局:多个"完整子对象"首尾相接
c
C 对象(offset):
+------+ 0
| vptr | → C 的主虚表(A 的表 + C 的新增)
| int a| 8 ← A 子对象
+------+ 12
| vptr | 16 ← B 的虚表指针!B 子对象从这里开始
| int b| 24
+------+ 28
| int c| 32
+------+ 36 → sizeof(C) == 40(对齐)
核心事实:C 内部有两个完整基类子对象,各自带自己的 vptr。A 子对象在偏移 0,B 子对象不在 0。
3.2.虚表(主表 + 次表)
C 关联两张虚表(GCC 实际实现为一张主表 + 一个"附加表区",但概念上分开讲更清楚):
c
主表(与 A 共用区域):
slot0: &C::fa ← A::fa 被覆写
slot1: &B::fb ← 新增:A 表尾部追加的其实是...(见下)
次表(B 区域):
slot0: &C::fa 的"调整 thunk"入口(thunk 见下)
slot1: &B::fb
严格来说 GCC 的做法:C 的主虚表包含 A 的所有槽 + C 新增虚函数;B 有一张独立虚表(thunk 化)。
3.3.多态与 this 指针调整(多重继承的灵魂)
c
C c;
B* pb = &c; // pb == (char*)&c + 16!
pb->fa(); // 调 C::fa,但 this 必须指向 C 开头
Thunk(调节代码):通过 B 指针调用覆写的虚函数时,函数入口并不是 C::fa 本体,而是一段小代码:
c
thunk_for_fa:
this -= 16; // 把 B* 调整回 C*
jmp C::fa;
虚表中 B 区域的 fa 槽位存的是 thunk 地址。这就是为什么覆写函数在不同基类表里表现不同。
反过来:
c
C* pc = static_cast<C*>(pb); // 编译器知道要 -16,直接生成调整代码
void* pv = pb;
static_cast<C*>(pv); // 错误!void* 调整无从得知,需 dynamic_cast
3.4.菱形问题(为什么要虚拟继承)
c
struct A { int a; };
struct B : A {}; // B 内有一个 A
struct C : A {}; // C 内也有一个 A
struct D : B, C {}; // D 内有两个 A!二义性
D d; d.a = 1; // 错误:ambiguous
d.B::a = 1; // 必须显式限定
数据冗余 + 二义性,由此引出虚拟继承。
4.虚拟继承(Virtual Inheritance)
c
struct A { virtual void fa(); int a; }; // 虚基类
struct B : virtual A { virtual void fb(); int b; };
struct C : virtual A { virtual void fc(); int c; };
struct D : B, C { void fa() override; virtual void fd(); int d; };
4.1.内存布局:共享虚基类子对象 + 偏移量指针
c
实测布局(sizeof(D) = 48):
+0 vptr ← B 和 D 共用这一个槽位!("主 vptr")
+8 int b
+12 padding(4)
+16 vptr ← C 区域
+24 int c
+28 int d
+32 vptr ← 共享 A 子对象
+40 int a
两大变化:
- 共享:无论 D 继承路径多复杂,A 子对象只有一份,位于完整对象末尾。
- 指针间接:编译期无法确定 A 在对象内的偏移(B 单独存在时 A 在 B 后面;D 存在时 A 被推到最后),所以通过 B/C 指针访问 a 时,要用虚表/指针里存储的偏移量动态计算:
上述内存布局中填充字节的目的是保证,类型实例对应的地址区间内,每个字段所在地址可被此字段尺寸整除。尾部填充字节是保证此类型对象存储时,每个对象起始地址均可被8字节整除。
c
访问 b_ptr->a 的伪代码:
char* this = b_ptr;
ptrdiff_t off = *(this - vptr处); // 从虚表中取偏移量,或从 vbptr
return *(int*)(this + off); // 间接寻址到共享 A
MSVC 差异:MSVC 使用独立的 vbptr(虚基类指针表) 放在对象中,概念等价。
4.2. 虚表的变化
虚表中新增虚基类偏移量槽位(Itanium ABI 是 vtable 前的负偏移区)。B 单独存在时,"B 到 A 的偏移"记一个值;D 的 B 子对象中,同一槽位记录的是 B→D→A 的新偏移。这实现了"偏移量在构造时由最派生类写入"。
4.3.虚拟继承下的多态
c
D d;
A* pa = &d; // 通过偏移量找到共享 A,pa 指向对象末尾附近
pa->fa(); // 调 D::fa,this 从 A* 位置调整回 D 开头(thunk)
多态机制不变(vptr + 槽位),变化在于:(a) 不同继承路径来的指针指向不同的 vptr 区域;(b) 指针转换不再是编译期常量偏移,而可能依赖运行期/构造期写入的偏移量。
5.构造与析构的完整过程
这是 C++ 对象模型中最易出错的一环,结合前面所有机制:
5.1. 构造顺序规则
c
struct D : B, C { // 声明顺序决定顺序
D() : m1(), B(), C() {} // 实际执行顺序与初始化列表无关!
Member m1;
};
完整顺序(以 D 为例):
- 先分配原始内存(operator new / 栈帧)------此时内容是垃圾;
- 按声明顺序构造虚基类子对象(最派生类 D 负责构造共享的 A);
- 按声明顺序构造直接基类子对象(B,然后 C);
- 按声明顺序构造非静态成员(m1);
- 执行构造函数体。
析构严格倒序:函数体 → 成员析构(逆序)→ 基类析构(逆序)→ 虚基类析构(逆序,由 D 负责)。
5.2.vptr 的多次改写(关键!)
构造/析构期间,vptr 会被反复覆盖:
c
内存分配完成: [垃圾]
构造 A 子对象: [A 的 vptr] ... ← A 的构造函数把 vptr 写成 &A vtable
构造 B 子对象: [B 的 vptr] ... ← B 的构造函数改写成 &B vtable
构造 C 子对象: [C 的 vptr] ...
构造 D(进函数体前): [D 的 vptr] ... ← 终于变成最终形态
推论------构造/析构中调用虚函数不发生多态:
在构造A/B/C子对象时,A/B/C子对象中虚表指针指向的虚表类似A/B/C类型独立存在的虚表,A/B/C构造过程调虚函数,以A/B/C为终点产生多态行为。在构造D时,对象内部虚表指针全部指向以D为终点的虚表。析构过程同理。
c
struct A { A() { f(); } virtual void f() { cout << "A"; } };
struct B : A { void f() override { cout << "B"; } };
B b; // 输出 "A"!
// 调 A::A 时对象还是"A 形态",vptr 指向 A 的表
析构同理(vptr 回退),所以析构函数中调虚函数也不多态。这正是基类析构必须声明为 virtual 的根本原因:若 delete base_ptr 而 Base 析构非虚,行为未定义(通常只析构了基类部分,泄漏派生资源且 vptr 未回退)。
5.3.虚拟继承的特殊规则
- 最派生类责任:构造虚基类 A 是最终创建者的责任(谁 new D,谁构造 A;B/C 的构造函数里对 A 的构造请求被抑制------编译器在 B、C 的构造函数中加入"是否是最派生类"的运行期检查标志)。
- 这就解释了为什么虚基类必须由最终派生类初始化,以及为什么虚基类的默认构造函数调用会"穿透"中间层。
6.运行时类型识别(RTTI)
6.1.typeid
c
Base* p = new Derived;
typeid(*p); // p 指向含虚函数的对象 → 返回动态类型 Derived
typeid(*p).name(); // 运行期从对象 vptr → vtable → typeinfo 读取
实现:虚表中(负偏移区)存一个指向 std::type_info 对象的指针。typeid 对多态类型就是一次指针解引用;对非多态类型(无虚函数)则在编译期定死,与对象无关。
c
Empty e; // 无虚函数
typeid(e) == typeid(Empty); // 编译期常量折叠,永远 true
6.2.dynamic_cast
c
Base* p = get();
if (Derived* pd = dynamic_cast<Derived*>(p)) { ... } // 向下转型
- 向上转型(Derived→Base):编译期完成,直接偏移调整;
- 向下/交叉转型:dynamic_cast<void*>§ 返回完整对象地址(靠 typeinfo 找到对象起点),再验证目标类型与静态类型的继承关系(比较 typeinfo / 遍历继承图)。失败返回 nullptr(指针)或抛 std::bad_cast(引用)。
- dynamic_cast 依赖虚表中的 typeinfo,因此只对多态类型有效。
6.3.RTTI 的成本与关闭
- 每个多态类额外生成 type_info 对象(含类型名字符串);
- g++ -fno-rtti 可关闭(dynamic_cast 报 __dynamic_cast 未定义),typeid 仍可用于编译期可知的情形。
7.总结速查表
| 主题 | 单继承 | 多重继承 | 虚拟继承 |
|---|---|---|---|
| 基类子对象位置 | 偏移 0 | 每个基类各自偏移 | 共享,位于末尾 |
| 指针转换 | 零成本 | 编译期常量偏移(可能非 0) | 需读偏移量(构造期确定) |
| 虚表 | 1 张,槽位覆写 | 主表 + 次表,用 thunk | 表中带虚基类偏移槽 |
| this 调整 | 无 | thunk 内调整 | thunk + 偏移间接 |
| 构造特殊点 | vptr 逐层改写 | 同左 | 最派生类构造虚基类 |
贯穿一切的三个核心机制:
- vptr/vtable:多态、RTTI、虚基类偏移三合一,都藏在虚表结构里;
- 槽位覆写 + thunk:继承体系"替换"虚函数的实现手段;
- 构造期逐步确立对象身份:vptr 的多次改写解释了构造/析构中无多态、虚析构必要性等一切规则。