从虚函数到继承布局,系统理解 C++ 对象模型
C++ 对象模型里有一块内容,几乎每次学到继承、多态、虚函数时都会反复碰到,那就是:
- 虚函数到底怎么实现
- vtable、vptr 分别是什么
- 为什么含虚函数的类
sizeof会变大 - 单继承、多继承、虚继承下对象内存到底怎么排
这些知识点如果一个个零碎地记,很容易越记越乱。更好的方式是把它们放到同一张图里去理解:虚函数解决的是多态问题,而 vtable/vptr、对象布局、继承层次,都是编译器为实现这件事付出的具体代价。
这篇文章就把这部分内容系统整理一遍。
一、虚函数的底层实现思路
C++ 为了支持运行时多态,主流编译器通常会采用一套非常经典的方案:
- 为类生成一张虚函数表(vtable)
- 为对象保存一个隐藏指针(vptr)
- 调用虚函数时通过 vptr 找到 vtable,再找到最终函数地址
注意:C++ 标准并没有强制要求虚函数必须通过 vtable/vptr 实现。下面讲的是 GCC、Clang、MSVC 等主流编译器的常见实现方式。
1. vtable 是什么
vtable 可以理解成一张“虚函数地址表”,里面存放这个类对应的虚函数入口地址。
例如:
1 | |
可以近似理解为编译器会生成:
1 | |
2. vptr 是什么
每个多态对象内部通常都会有一个隐藏成员,叫 vptr,它指向所属类型的 vtable。
例如:
1 | |
对象内存通常可以近似理解为:
1 | |
所以可以先记一句最重要的话:
vtable 属于类,vptr 属于对象。
二、vptr 在对象中的位置
vptr 的位置标准没有强制规定,但主流实现里通常把它放在对象起始地址处,也就是对象内存最前面。
为什么通常放在最前面
这样做的好处很直接:
- 编译器更容易生成统一访问逻辑
- 运行时取 vptr 的偏移固定
- 多态调用路径更简单
大小是多少
vptr 本质上只是一个普通指针:
- 32 位系统下一般是 4 字节
- 64 位系统下一般是 8 字节
所以只要类支持多态,对象里通常就要多出一个指针大小的隐藏开销。
三、虚函数表存在哪里
vtable 一般存放在只读数据段,比如 .rodata 这类区域。
原因是:
- vtable 是类级别的数据,不属于某个具体对象
- 它通常在程序运行期间不会变化
- 放在只读区更安全
这也意味着:
vtable 不算进对象本身的
sizeof,但 vptr 会算进去。
四、虚函数表什么时候生成
可以按三个阶段理解。
1. 编译期
编译器扫描类定义,只要发现类里有虚函数,就会为这个类准备对应的 vtable 信息。
例如:
1 | |
可以近似理解为:
1 | |
2. 链接期
链接器会把这些符号和真实函数地址拼接起来,最终放进可执行文件或动态库。
3. 运行期
对象构造时,编译器生成的代码会把对象里的 vptr 设置成当前动态类型对应的 vtable 地址。
例如:
1 | |
这里还有一个很容易忽略的点:
在派生类构造过程中,基类子对象先构造,此时它对应部分的 vptr 先指向基类 vtable;等派生类构造完成后,再更新成派生类版本。这也是为什么“构造函数/析构函数里调用虚函数”经常会出现和预期不同的行为。
五、虚函数调用时发生了什么
普通成员函数调用,编译期通常就能确定目标地址。虚函数调用不一样,它要依赖动态分派。
例如:
1 | |
编译器通常会生成近似逻辑:
- 取出
p对象中的 vptr - 找到 vtable 中
f对应的槽位 - 跳转到该地址执行
可以近似理解为:
1 | |
最终调用到的是 Derived::f()。
这就是运行时多态真正落地的地方。
六、一个类有多个虚函数,会有几个 vptr
通常只有一个 vptr。
例如:
1 | |
虽然 A 有多个虚函数,但对象里一般仍然只需要一个 vptr:
1 | |
这个 vptr 指向整张 vtable,表里再存多个虚函数地址。
所以:
虚函数多,不代表 vptr 也多。
七、子类重写虚函数时发生了什么
如果子类重写了父类的虚函数,那么子类 vtable 中对应槽位会被替换。
例如:
1 | |
则可以近似理解为:
1 | |
结论
- Base 对象不受影响
- Derived 对象的 vptr 指向 Derived::vtable
- 动态分派时会调到重写后的版本
八、含虚函数的类为什么 sizeof 会变大
只要类支持多态,对象里通常就需要放一个 vptr。因此对象大小一般包含:
- 所有非静态成员变量
- 对齐填充(padding)
- vptr
例如:
1 | |
在 64 位系统下,常见布局近似为:
1 | |
所以记住:
vptr 算进
sizeof,vtable 不算。
九、父类没有虚函数,子类加了虚函数会怎样
父类完全不受影响,只有子类开始支持多态。
例如:
1 | |
Base 对象
1 | |
Derived 对象
1 | |
也就是说:
- Base 没有 vptr
- Derived 新增 vptr
- Base 大小不变,Derived 大小增大
十、单继承下的对象布局
来看这组类:
1 | |
1. 单继承下通常只有一个 vptr
对象整体可以近似理解为“基类子对象 + 派生类新增成员”。
A 对象
1 | |
B 对象
1 | |
C 对象
1 | |
2. B、C 没新增成员或虚函数时会怎样
例如:
1 | |
这时通常有:
1 | |
因为对象里仍然只是:
- 一个 vptr
- 一个成员
a - 加上必要对齐
不过要注意:
“大小相同”不等于“类型完全没有差别”。
B 和 C 仍然可能有各自的动态类型信息和各自的 vtable,只是对象大小没有继续增加。
十一、多继承下的对象布局
多继承时,最重要的结论是:
每个需要多态支持的基类子对象,通常都有自己独立的 vptr。
例如:
1 | |
C 对象通常近似这样:
1 | |
为什么对象会变大
因为多继承会带来:
- 多个基类子对象
- 多个 vptr
- 更多对齐填充
所以多继承对象通常比单继承对象更大、更复杂。
十二、虚继承是什么,解决什么问题
虚继承主要用来解决菱形继承中的公共基类重复问题。
经典例子:
1 | |
如果没有虚继承:
D中会有两份A- 访问
a时会出现二义性
使用虚继承后:
- B 和 C 不再各自持有独立 A
- 最终 D 中只保留一份共享的 A 子对象
虚继承解决的问题
- 避免菱形继承中基类重复
- 避免成员访问二义性
- 保证公共虚基类只有一份实体
十三、虚继承会带来什么开销
虚继承不是没有代价的,它通常会带来:
- 更复杂的对象布局
- 额外的偏移量信息
- 访问虚基类时可能需要额外间接寻址
- 构造和析构顺序更复杂
所以它本质上是:
为了解决继承结构问题,接受一定实现复杂度和运行开销。
十四、虚函数调用为什么比普通函数慢
虚函数调用通常比普通函数慢,主要不是因为“函数体本身执行更慢”,而是因为它需要多一步动态分派。
普通函数调用:
- 编译期就能确定目标地址
- 更容易内联
虚函数调用:
- 运行时先取 vptr
- 再查 vtable
- 再跳到真实函数地址
这带来的影响通常有:
- 一次额外间接寻址
- 更难被内联优化
- 某些场景下不利于分支预测
不过也要客观看:
在大多数普通业务代码里,这点开销未必是真正瓶颈。只有在高频、小函数、极致性能敏感路径里,虚调用成本才更值得关注。
十五、如何减少虚函数调用开销
常见思路有:
- 能静态绑定时就不要强行做动态多态
- 对热点路径减少极短小虚函数的高频调用
- 使用
final帮助编译器做去虚拟化优化 - 在性能极敏感场景下考虑模板、CRTP 等静态多态方案
不过前提永远是:
先确认瓶颈真的在这里,再谈优化。
十六、虚函数一定通过 vtable 实现吗
不一定。
C++ 标准只规定虚函数要支持动态绑定语义,但没有规定编译器必须使用 vtable/vptr。
现实里主流编译器几乎都采用这套方案,因为它工程上成熟、效率高、实现稳定。
所以学习对象模型时,默认按 vtable/vptr 理解是完全合理的。
十七、最后做一个总总结
如果把这篇文章压缩成几句话,最关键的结论就是:
关于虚函数
- 虚函数用于支持运行时多态
- 主流实现依赖 vtable + vptr
关于 vptr
- vptr 通常位于对象起始位置
- 它属于对象
- 单继承通常一个对象一个 vptr
- 多继承通常一个多态基类子对象对应一个 vptr
关于 vtable
- vtable 属于类级别数据
- 通常放在只读数据段
- 不计入
sizeof
关于继承
- 单继承布局相对简单
- 多继承会让对象更大、更复杂
- 虚继承主要用来解决菱形继承中的重复基类问题
对象模型这部分的关键,不是死背每一种布局图,而是抓住一条主线:
编译器为了支持多态,需要把“类型信息”和“调用入口”嵌进对象模型里;而 vtable、vptr、继承布局变化,本质上都是这件事的实现结果。
理解了这条主线,再回头看虚函数、继承、多继承、虚继承,就不会再是一些零碎结论了。