一个带虚函数的类,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. 延伸阅读
- Itanium C++ ABI:对象布局与 vtable ------ 这篇所有布局细节的一手出处,vptr 位置、vtable 槽位、RTTI 都在这里定义。
- cppreference:对象模型 ------ 语言层面的对象、子对象、多态对象概念。
- cppreference:
dynamic_cast------ 交叉转换、失败语义、对多态类型的要求。 - Compiler Explorer(godbolt) ------ 自己写个多继承类,开
-O0看 vtable 生成和指针调整的汇编。
本知识库内的相关篇目:
- 《std::launder、std::bit_cast 与 placement new:合法绕过别名》 ------ 想把一个 int 的位模式当 float 读
- 《异常的实现与代价:零成本异常、栈展开与 -fno-exceptions 什么时候用》 ------ C++ 异常常被说「零成本」,但它的代价藏在抛出路径上。
- 《C++ ABI 与跨库兼容:为什么换编译器要重编、符号可见性》 ------ C++ ABI 与跨库兼容,同体系里最该一起看的另一篇。
10. 一句话总结
多态对象的 vptr 在头部、多继承有多个 vptr、RTTI 的 type_info 挂在 vtable 虚函数指针之前的负偏移槽里;dynamic_cast 跨基类转换靠查这份 type_info 做运行时走查,所以关闭 RTTI(-fno-rtti)会同时废掉 dynamic_cast。