c++对象模型--内存布局,多态,运行时类型识别

内存布局和多态不同编译器支持方式不同,以下基于 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 的多次改写解释了构造/析构中无多态、虚析构必要性等一切规则。
相关推荐
充值内卷1 小时前
[特殊字符] 自己动手做一个类 tldraw 的桌面白板:StepDraw 开源啦(C++ + WebView2 + Canvas)
开发语言·c++
镜子AI1 小时前
镜子AI工程实践:用熵权法 + TOPSIS 做多方案选型排序(零依赖 Python)
开发语言·人工智能·python
估值探索者1 小时前
【Python量化组合管理实战 02】钱该怎么分?等权、波动率倒数与动态评分三种配权实测
java·开发语言·python·接口·股票数据api接口
西索斯coding1 小时前
Kimi K2.6 与 Qwen3.7 Max:跨文件修复评测与成本核算方法(2026)
开发语言·python·ai
从小白到软工1 小时前
5-2 从查表到控件:造按钮
c语言·开发语言·数据结构·经验分享·ai编程
cu1432 小时前
细谈IT6653的具体功能与其应用
c语言·c++·单片机·嵌入式硬件
迪康Defender2 小时前
PHP 高危漏洞应急分析:从风险原理到端点侧纵深防护
运维·开发语言·安全·自动化·php·运维开发·远程工作
2601_966949652 小时前
为什么不应该把 QuantDash API Key 直接写进公开代码仓库?从 Git 提交历史理解凭证泄露风险
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
智_永无止境2 小时前
又找到一个纯Java的OCR项目,识别的格式还挺多
java·开发语言·ocr