C++ 对象内存模型:vtable 布局、多继承 vptr、RTTI 与 dynamic_cast 走查

一个带虚函数的类,sizeof 往往比「把所有成员变量大小加起来」还要多 8 字节,多出来的就是隐藏的虚表指针(vptr)。这篇用真实可跑的代码把对象内存布局扒开:vptr 放在哪、多继承为什么有多个 vptr、RTTI 的 type_info 藏在 vtable 的什么位置、dynamic_cast 跨基类转换时编译器到底在做什么走查。看完你能解释「为什么 dynamic_cast 能把 Base* 转回 Derived* 甚至转到兄弟类」。

1. 引子:sizeof 为什么比「成员之和」大

先看一个反直觉的现象。Base 只有一个 int 成员(4 字节),但 sizeof(Base) 却是 16;Derived 只有两个 int,sizeof 是 16 而不是 8。多出来的 8 字节就是 vptr:它让对象「知道」自己是哪个类型、该调哪个虚函数。

cpp 复制代码
#include <iostream>

struct Base {
    virtual ~Base() = default;              // 有虚函数 → 有 vptr
    virtual void speak() const { std::cout << "Base\n"; }
    int base_data{100};
};

struct Derived : Base {
    void speak() const override { std::cout << "Derived\n"; }
    int derived_data{200};
};

int main() {
    std::cout << "sizeof(Base)    = " << sizeof(Base) << '\n';
    std::cout << "sizeof(Derived) = " << sizeof(Derived) << '\n';
    std::cout << "sizeof(int)     = " << sizeof(int) << '\n';
}
text 复制代码
sizeof(Base)    = 16
sizeof(Derived) = 16
sizeof(int)     = 4

官方文档:对象模型与虚函数表(vtable) 只讲语言语义;内存布局本身不是标准,而是各 ABI 的实现细节,这篇按 GCC/Clang 使用的 Itanium C++ ABI 讲解。

2. 单继承:vptr 在头部

Itanium ABI 规定,vptr 放在对象头部 (偏移 0),指向一个静态的 vtable。单继承下只有一个 vptr,成员变量按声明顺序排在它后面。下面这段代码直接读对象头 8 个字节,把 vptr 的地址抓出来,并算出 base_data 相对对象起始的偏移,验证「vptr 占 8 字节、成员排在后面」。

cpp 复制代码
#include <cstdint>
#include <iostream>
#include <typeinfo>

struct Base {
    virtual ~Base() = default;
    virtual void speak() const {}
    int base_data{100};
};

struct Derived : Base {
    void speak() const override {}
    int derived_data{200};
};

int main() {
    Derived d;
    // 讲内存布局需要直接看内存:对象头 8 字节就是 vptr(生产代码由编译器管理,不这么写)
    std::uintptr_t obj_addr = reinterpret_cast<std::uintptr_t>(&d);
    std::uintptr_t base_addr = reinterpret_cast<std::uintptr_t>(&d.base_data);
    void* vptr = *reinterpret_cast<void**>(&d);

    std::cout << "vptr(对象头部)       = " << vptr << '\n';
    std::cout << "base_data 相对对象偏移 = " << (base_addr - obj_addr) << '\n';
    std::cout << "typeid(d).name()       = " << typeid(d).name() << '\n';
}
text 复制代码
vptr(对象头部)       = ~~
base_data 相对对象偏移 = 8
typeid(d).name()       = 7Derived

对象在内存里的样子:

text 复制代码
Derived 对象内存布局(Itanium ABI,64 位,指针 8 字节)
+------------------+  <-- &d(对象起始)
| vptr -> vtable   |  偏移 0:8 字节,指向 Derived 的 vtable
+------------------+
| base_data = 100  |  偏移 8
+------------------+
| derived_data=200 |  偏移 12
+------------------+  <-- sizeof(Derived) = 16(含 4 字节 padding 对齐)

vptr 指向的 vtable 长这样(注意虚函数指针之前还有两个隐藏槽,供 RTTI 用):

text 复制代码
Derived 的 vtable(vptr 指向第一个虚函数槽)
+---------------------+
| offset-to-top (0)   |  vptr - 16:到对象顶部的偏移(多继承/虚继承时非 0)
+---------------------+
| type_info* (RTTI)   |  vptr - 8:指向 Derived 的 std::type_info
+---------------------+
| ~Derived()          |  vptr + 0:析构
+---------------------+
| speak()::Derived    |  vptr + 8:覆盖后的虚函数
+---------------------+

type_info* 就挂在 vptr 前面 8 字节的负偏移槽里------这就是第 5 节 RTTI 的物理位置。

3. 多继承:多个 vptr 与指针自动调整

一个类同时继承两个带虚函数的基类时,需要两个 vptr (每个基类子对象一个)。关键是:当你把 Both* 转成 Right* 时,编译器会自动调整指针值 ------Right 子对象不在对象头部,而是在偏移 16 的位置。下面这段代码把两个 vptr 的地址和指针调整量都打出来。

cpp 复制代码
#include <cstdint>
#include <iostream>

struct Left {
    virtual ~Left() = default;
    virtual void l() const {}
    int lv{1};
};
struct Right {
    virtual ~Right() = default;
    virtual void r() const {}
    int rv{2};
};
struct Both : Left, Right {
    void l() const override {}
    void r() const override {}
    int bv{3};
};

int main() {
    Both b;
    std::uintptr_t both_addr = reinterpret_cast<std::uintptr_t>(&b);
    Left* pl = &b;                          // 指向对象头部
    Right* pr = &b;                         // 自动调整到 Right 子对象

    std::cout << "sizeof(Both) = " << sizeof(Both) << '\n';
    std::cout << "Left*  == &b  + " << (reinterpret_cast<std::uintptr_t>(pl) - both_addr) << '\n';
    std::cout << "Right* == &b  + " << (reinterpret_cast<std::uintptr_t>(pr) - both_addr) << '\n';

    void* vptr_left = *reinterpret_cast<void**>(pl);
    void* vptr_right = *reinterpret_cast<void**>(pr);
    std::cout << "vptr(Left 子对象)  = " << vptr_left << '\n';
    std::cout << "vptr(Right 子对象) = " << vptr_right << '\n';
    std::cout << "两个 vptr 是否不同 = " << (vptr_left != vptr_right ? "是" : "否") << '\n';
}
text 复制代码
sizeof(Both) = 32
Left*  == &b  + 0
Right* == &b  + 16
vptr(Left 子对象)  = ~~
vptr(Right 子对象) = ~~
两个 vptr 是否不同 = 是

多继承的布局图:

text 复制代码
Both 对象内存布局(Both : Left, Right)
+------------------+  <-- &b == Left 子对象起始
| vptr_Left        |  偏移 0  -> 指向 Both 的 Left 部分 vtable
+------------------+
| lv = 1           |  偏移 8
+------------------+
| vptr_Right       |  偏移 16 -> 指向 Both 的 Right 部分 vtable
+------------------+  <-- Right 子对象起始(&r == &b + 16)
| rv = 2           |  偏移 24
+------------------+
| bv = 3           |  偏移 28
+------------------+  <-- sizeof(Both)

官方文档:多重继承(multiple inheritance) 说明了基类子对象的排列;指针调整量由编译器在编译期算出,所以 Right* pr = &b; 零开销,但 dynamic_cast 到兄弟类则要查 vtable(见第 6 节)。

4. 虚继承:vbptr 与共享基类

菱形继承里,如果不用虚继承,最底层的类会有两份 祖父类 Top;用 virtual 继承则只有一份,代价是多一个虚基类表指针(vbptr)。用 sizeof 和偏移把差异量化出来。

cpp 复制代码
#include <cstdint>
#include <iostream>

struct Top { int top{0}; };

struct MidA : virtual Top { int a{1}; };   // 虚继承
struct MidB : virtual Top { int b{2}; };   // 虚继承
struct Diamond : MidA, MidB { int d{3}; };

int main() {
    Diamond obj;
    std::cout << "sizeof(Top)     = " << sizeof(Top) << '\n';
    std::cout << "sizeof(Diamond) = " << sizeof(Diamond) << '\n';
    // 共享的 Top 只有一份:通过两条路径拿到的地址一致
    Top* via_a = static_cast<MidA*>(&obj);
    Top* via_b = static_cast<MidB*>(&obj);
    std::cout << "via_a == via_b  = " << (via_a == via_b ? "是(共享一份 Top)" : "否") << '\n';
}
text 复制代码
sizeof(Top)     = 4
sizeof(Diamond) = 40
via_a == via_b  = 是(共享一份 Top)
text 复制代码
Diamond 对象布局(虚继承,Top 只有一份,放对象末尾)
+------------------+
| vptr_MidA        |  (MidA 的虚基类表指针)
+------------------+
| a                |
+------------------+
| vptr_MidB        |
+------------------+
| b                |
+------------------+
| d                |
+------------------+
| top(共享 Top)  |  <-- via_a == via_b 都指向这里
+------------------+

230→

231→三种继承方式对对象布局的影响,一张表对照:

继承方式 vptr 数量 基类子对象份数 指针转基类时
单继承 1 个 每个基类 1 份(链式) 无需调整,基类指针 == 对象起始
多继承 每个直接基类 1 个 每个基类 1 份 转非首个基类要加偏移(编译期算好)
虚继承 每个虚基类 1 个 vbptr 虚基类只有 1 份 查 vbptr 指向的虚基类表,运行时求偏移

官方文档:虚继承(virtual inheritance) 解释「虚基类只有一份」;vbptr 指向的虚基类表记录了 Top 子对象相对各派生类的偏移。

5. RTTI:type_info 挂在 vtable 头部

运行时类型信息(Run-Time Type Information,RTTI)不是凭空存在的:typeid(x) 拿到的 std::type_info 对象指针,就存在 vtable 里虚函数指针之前 的槽位(第 2 节图里的 vptr - 8)。所以「有虚函数的类才有多态 RTTI」。没有 vptr,就没地方挂 type_info。下面这段代码直接从 vtable 里把 type_info 指针挖出来,和 typeid 的地址比对。

cpp 复制代码
#include <iostream>
#include <typeinfo>

struct Animal {
    virtual ~Animal() = default;
    virtual void speak() const {}
};
struct Cat : Animal {
    void speak() const override {}
};

int main() {
    Cat c;
    const std::type_info& ti = typeid(c);          // 正常途径拿 type_info
    // 直接从 vtable 头部挖 type_info 指针(讲 RTTI 物理位置,生产代码走 typeid)
    void** vtable = *reinterpret_cast<void***>(&c);
    const std::type_info* ti_from_vtable = reinterpret_cast<const std::type_info*>(vtable[-1]);

    std::cout << "typeid(c).name()        = " << ti.name() << '\n';
    std::cout << "vtable 里的 type_info    = " << ti_from_vtable->name() << '\n';
    std::cout << "两者是否同一个对象       = " << (ti_from_vtable == &ti ? "是" : "否") << '\n';

    // 没有虚函数的类型没有 vptr,也就没有「多态 RTTI」:typeid 只看静态类型
    struct Plain { int x; };
    Plain p;
    const std::type_info& ti_p = typeid(p);
    std::cout << "非多态类型也能拿 type_info = " << ti_p.name() << "(编译期确定)\n";
}
text 复制代码
typeid(c).name()        = 3Cat
vtable 里的 type_info    = 3Cat
两者是否同一个对象       = 是
非多态类型也能拿 type_info = Z4mainE5Plain(编译期确定)

再换个角度看:type_info 和 vtable 在符号表里是独立符号 ,type_info 就挂在 vtable 前面。用 nm -C 看真实符号(V 是弱符号,链接时跨编译单元去重):

text 复制代码
$ nm -C demo | grep -E 'vtable|typeinfo'
0000000000402020 V typeinfo for Base
0000000000402040 V typeinfo for Derived
0000000000402019 V typeinfo name for Base
0000000000402030 V typeinfo name for Derived
0000000000402058 V vtable for Derived

每个多态类型都有独立的 vtable for X 和 typeinfo for X;typeinfo name for X 存的是 typeid(X).name() 那个字符串。

官方文档:std::type_info 与 typeid:当表达式的类型是多态类型时,typeid 在运行时求值(查 vtable),否则在编译期确定。

6. dynamic_cast:跨基类转换怎么走查

static_cast 的指针调整是编译期 算好的偏移;而 dynamic_cast<Right*>(pl) 这种「从一个基类转到另一个基类」的转换,编译器不知道运行时对象到底是什么,必须靠 vtable 里的 type_info 做运行时走查 :先拿到对象的动态类型,再在类型图里找目标类型,找到就返回调整后的指针,找不到返回 nullptr(指针)或抛 std::bad_cast(引用)。

cpp 复制代码
#include <iostream>
#include <typeinfo>

struct A { virtual ~A() = default; };
struct B { virtual ~B() = default; };
struct C : A, B {};

// 通过参数传入 A*,编译器不知道它实际指向什么,必须运行时查 vtable
B* try_cast(A* pa) { return dynamic_cast<B*>(pa); }

int main() {
    C c;
    A* pa = &c;                              // pa 实际指向的是 C
    B* pb = try_cast(pa);                    // 跨基类转换:A -> B,运行时走查

    std::cout << "dynamic_cast<B*>(pa) 成功 = " << (pb ? "是" : "否") << '\n';
    if (pb) {
        std::cout << "pb 动态类型 = " << typeid(*pb).name() << '\n';
    }

    A real_a;                                 // 真实指向 A(不是 C)
    B* fail = try_cast(&real_a);              // 转 A 为 B,运行时走查失败
    std::cout << "转真实 A 为 B 失败(nullptr)= " << (fail == nullptr ? "是" : "否") << '\n';
}
text 复制代码
dynamic_cast<B*>(pa) 成功 = 是
pb 动态类型 = 1C
转真实 A 为 B 失败(nullptr)= 是
text 复制代码
dynamic_cast<B*>(pa) 的走查过程(pa 静态类型 A*,运行时指向 C)
  1. 读 pa 指向对象的 vptr
  2. 从 vtable 头部取出 type_info,得知动态类型是 C
  3. 在类型图中找「从 C 到 B」的路径(C 直接继承 B)
  4. 找到 -> 返回调整后的 B*(指向 C 里的 B 子对象)
     找不到 -> 返回 nullptr(引用版本抛 std::bad_cast)

官方文档:dynamic_cast 说明指针失败返回空指针、引用失败抛 std::bad_cast;它要求源类型是多态类型(有 vtable 可查)。

7. -fno-rtti 的代价

-fno-rtti 关掉 RTTI 能省掉 vtable 里的 type_info 和类型图,减小体积,常用于嵌入式/内核/游戏引擎等场景。代价是:多态的 typeid 和 dynamic_cast 直接编译失败。下面这段代码在正常编译下能跑;一旦加 -fno-rtti,dynamic_cast 那一行就会报 error: 'dynamic_cast' not permitted with -fno-rtti(实测报错见下方 text 块)。

cpp 复制代码
#include <iostream>

struct Base { virtual ~Base() = default; };
struct Derived : Base {};

int main() {
    Derived d;
    Base* pb = &d;
    Derived* pd = dynamic_cast<Derived*>(pb);
    std::cout << (pd ? "dynamic_cast ok" : "null") << '\n';
}
text 复制代码
dynamic_cast ok

用 godbolt 加 -fno-rtti 编译同一段代码,编译器直接拒绝(真实报错):

text 复制代码
/app/main.cpp:9:19: error: 'dynamic_cast' not permitted with '-fno-rtti'
    9 |     Derived* pd = dynamic_cast<Derived*>(pb);
      |                   ^~~~~~~~~~~~

官方文档:GCC C++ 方言选项 -fno-rtti 说明关闭 RTTI 后 typeid/dynamic_cast 的约束。

8. 完整示例:菱形 + 交叉转换一把梭

把前面所有要点串起来:单继承 vptr、多继承双 vptr、RTTI 物理位置、dynamic_cast 交叉转换,一次跑完。

cpp 复制代码
#include <cstdint>
#include <iostream>
#include <typeinfo>

struct Animal { virtual ~Animal() = default; virtual void speak() const = 0; };
struct Runnable { virtual ~Runnable() = default; virtual void run() const = 0; };

struct RobotDog : Animal, Runnable {
    void speak() const override { std::cout << "woof\n"; }
    void run() const override { std::cout << "clank\n"; }
    int battery{99};
};

int main() {
    RobotDog r;
    Animal* pa = &r;                       // 指向头部(Animal 子对象)
    Runnable* pr = dynamic_cast<Runnable*>(pa);   // 交叉转换:Animal -> Runnable

    std::cout << "sizeof(RobotDog)  = " << sizeof(RobotDog) << '\n';
    std::cout << "交叉转换成功      = " << (pr ? "是" : "否") << '\n';
    std::cout << "pr 的动态类型     = " << (pr ? typeid(*pr).name() : "null") << '\n';

    void** vtable = *reinterpret_cast<void***>(&r);
    const std::type_info* ti = reinterpret_cast<const std::type_info*>(vtable[-1]);
    std::cout << "vtable 里的 RTTI  = " << ti->name() << '\n';

    pa->speak();
    pr->run();
}
text 复制代码
sizeof(RobotDog)  = 24
交叉转换成功      = 是
pr 的动态类型     = 8RobotDog
vtable 里的 RTTI  = 8RobotDog
woof
clank

9. 延伸阅读

本知识库内的相关篇目:

10. 一句话总结

多态对象的 vptr 在头部、多继承有多个 vptr、RTTI 的 type_info 挂在 vtable 虚函数指针之前的负偏移槽里;dynamic_cast 跨基类转换靠查这份 type_info 做运行时走查,所以关闭 RTTI(-fno-rtti)会同时废掉 dynamic_cast。

相关推荐
wyhwust1 小时前
No1-绪论
c++
All for pursuit.1 小时前
【动态规划-8】152.乘积最大子数组
数据结构·c++·算法·leetcode·动态规划
INGNIGHT2 小时前
1861 · 老鼠跳跃(坐标型dp)
数据结构·c++
Lazionr2 小时前
手撕 unordered_set 和 unordered_map,剖析哈希表底层实现
c++·算法
xie0510_2 小时前
Any类简要实现
开发语言·c++·算法
道尔柯南2 小时前
C++ 入门基础详解:从第一个程序到核心语法特性
开发语言·c++
bkspiderx2 小时前
在 MFC 中添加自定义消息:从定义到使用的完整指南
c++·mfc·postmessage·sendmessage·mfc 中添加自定义消息·wm_app·on_message
纪念 2293 小时前
C++ string(三)
android·c++
纪念 2293 小时前
c++ string(最终篇)
android·c++