虚函数、多态与动态派发
虚函数、多态与动态派发
时间:2026/05/04
关键词:虚函数、动态多态、
vptr、vtable、虚析构、对象切片、动态派发、去虚化、CRTP、std::variant
核心目标:理解虚函数的语义、底层直觉和性能代价,知道什么时候该用动态多态,什么时候该换成静态分发。
1. 虚函数解决什么问题
虚函数解决的是:
调用方只知道基类接口,但运行时对象可能是不同派生类。
例子:
1 |
|
这里调用方只面对:
1 | Task |
但实际执行哪个 run(),取决于对象的真实动态类型。
2. 普通函数和虚函数的区别
普通成员函数:
1 | struct A { |
调用时,编译器通常在编译期就知道目标函数:
1 | A a; |
虚函数:
1 | struct Base { |
通过基类指针或引用调用时:
1 | void call(Base& b) { |
编译器不能只看 Base& 就确定具体执行哪个版本,因为 b 可能引用任何派生类对象。
这就是动态派发:
- 编译期确定接口
- 运行期确定实现
3. 纯虚函数与抽象类
纯虚函数写法:
1 | struct Shape { |
含有纯虚函数的类是抽象类,不能直接实例化:
1 | // Shape s; // 错 |
派生类需要实现这个接口:
1 | struct Circle : Shape { |
这里的 override 很重要,它能让编译器检查你是否真的重写了基类虚函数。
4. override 和 final
重写虚函数时推荐总是写 override:
1 | struct Base { |
如果参数写错:
1 | struct Bad : Base { |
这能避免“以为重写了,实际新增了一个函数”的问题。
final 表示不允许继续重写:
1 | struct Leaf final : Base { |
或者只禁止某个虚函数继续被重写:
1 | struct Mid : Base { |
在某些情况下,final 也能帮助编译器做去虚化优化。
5. 虚析构为什么重要
如果一个类要被当作多态基类使用,析构函数通常必须是虚函数:
1 | struct Base { |
否则这样删除会出问题:
1 | Base* p = new Derived; |
现代 C++ 更常写成:
1 | std::unique_ptr<Base> p = std::make_unique<Derived>(); |
但即使用智能指针,底层仍然会通过基类析构。
所以多态基类的析构函数依然要设计清楚。
经验规则:
- 要通过基类指针删除对象:基类析构必须是
virtual - 不允许通过基类删除:可以把基类析构设为
protected且非虚
6. 函数和对象在内存里大概在哪里
先建立一个底层直觉:
- 函数代码通常位于程序的代码段
- 普通对象里不会保存成员函数的机器码
- 成员函数不是“每个对象各存一份”
- 对象保存的是数据成员,以及实现多态所需的额外信息
例如:
1 | struct Counter { |
每个 Counter 对象通常只需要保存:
1 | value |
inc() 的机器码在代码段里,所有 Counter 对象共享同一份函数代码。
调用成员函数时,可以粗略理解成编译器额外传了一个隐藏参数:
1 | c.inc(); |
近似成:
1 | Counter_inc(&c); |
这个隐藏的对象指针就是常说的:
1 | this |
7. vptr 和 vtable 的近似模型
对含虚函数的对象,主流实现通常会在对象中放一个隐藏指针:
1 | vptr |
它指向一张虚函数表:
1 | vtable |
虚函数表里保存函数地址。
可以粗略理解成:
1 | 对象 |
示例:
1 | struct Base { |
一个 Derived 对象里通常会有:
- 一个隐藏的
vptr - 数据成员
x
vtable 通常位于只读数据区附近,由编译器生成。
这些位置属于实现细节,不同编译器、ABI、多重继承场景都会有差异。
这部分只需要记住性能直觉:
虚函数不是把函数代码塞进对象,而是对象多带了一个“查表入口”。
8. 虚调用大致发生了什么
通过基类引用调用虚函数:
1 | void call(Base& b) { |
底层可以粗略想成:
1 | 1. 从对象地址找到 vptr |
也就是:
1 | 对象 -> vptr -> vtable -> 函数地址 -> 间接调用 |
相比普通直接调用,虚调用多了:
- 一次或多次内存读取
- 一次间接跳转
- 编译器更难内联
- CPU 分支预测更难判断目标
所以虚函数的成本通常不是“虚函数本身很慢”这么简单,而是:
- 它阻碍了内联
- 它让调用目标到运行期才确定
- 它常常伴随指针间接访问和对象分散存储
9. 动态派发的真实性能成本
单次虚调用的额外开销通常不大。
真正容易放大的场景是:
1 | for (auto& p : objects) { |
如果 objects 是一堆指向不同堆对象的指针,成本可能来自:
- 指针追踪导致缓存 miss
- 对象分散在堆上
- 每次虚调用目标不同,间接分支预测困难
- 无法内联
update() - 编译器难以做循环优化和向量化
在高性能热循环里,问题往往不是一个 virtual 关键字,而是这一整套数据布局:
1 | vector<unique_ptr<Base>> |
这比:
1 | std::vector<Particle> |
更难被 CPU 和编译器优化。
10. 对象切片
对象切片是多态初学者常见坑。
1 |
|
Base b = d 会创建一个真正的 Base 对象,派生类部分被切掉。
如果要保留动态类型,应使用:
Base&Base*std::unique_ptr<Base>std::shared_ptr<Base>
11. 构造和析构中的虚调用
构造函数和析构函数里调用虚函数要非常小心:
1 |
|
构造 Derived 时,先构造 Base 子对象。
在 Base 构造期间,派生类部分还没构造好,所以虚调用不会派发到 Derived::init()。
析构也类似:
- 派生类部分先析构
- 基类析构期间对象已经不再是完整的派生类
经验规则:
不要依赖构造函数或析构函数里的虚调用来触发派生类行为。
12. 去虚化:编译器什么时候能优化掉虚调用
去虚化指的是:
编译器证明某次虚调用的目标只有一个,于是把它变成直接调用,甚至内联。
常见条件:
- 对象的动态类型在当前作用域里明确
- 类或函数被标记为
final - 链接期优化能看到完整继承关系
- 编译器根据上下文推断没有其他派生实现
例子:
1 | struct Base { |
这里编译器很可能知道 b 实际引用 Derived,从而把调用优化掉。
但这不是语言层保证,而是优化器能力。
13. 什么时候适合用虚函数
适合:
- 运行期才知道具体类型
- 需要稳定插件接口
- 类型集合经常扩展
- 调用频率不在最热路径
- 接口边界比极致性能更重要
典型例子:
- 渲染后端接口
- 文件系统抽象
- 任务系统里的任务基类
- 插件注册和对象工厂
这类场景中,虚函数提供的解耦价值往往大于一点动态派发成本。
14. 热循环里的替代方案
如果某段代码是极热路径,并且类型集合在编译期基本固定,可以考虑其他方式。
14.1 模板和策略类
1 | template <class Integrator> |
如果 Integrator 类型在编译期确定,编译器更容易内联。
14.2 CRTP
1 | template <class Derived> |
CRTP 是静态多态:
- 没有虚表
- 调用目标编译期确定
- 适合类型集合比较固定的高性能代码
代价是:
- 不能像
Base*那样自然存放异构对象 - 编译期耦合更强
14.3 std::variant
1 |
|
variant 适合:
- 类型集合有限
- 希望对象直接存进连续容器
- 不想为每个对象单独堆分配
代价是:
- 新增类型时要改
variant类型列表 - 访问逻辑可能变复杂
15. 数据布局通常比虚调用本身更重要
假设有很多对象要更新:
1 | std::vector<std::unique_ptr<Entity>> entities; |
这给 CPU 的信息是:
- 先遍历一段连续指针
- 再跳到分散的堆对象
- 再通过
vptr找函数地址
如果改成按类型分组:
1 | std::vector<Player> players; |
可能带来的收益:
- 对象连续
- 循环更简单
- 调用目标更稳定
- 更容易内联和向量化
所以高性能代码里常见做法是:
- 外层系统边界用虚函数表达抽象
- 内层热循环用连续数组和静态分发
16. 常见坑
16.1 基类没有虚析构
只要可能通过基类指针删除派生对象,就要认真处理析构函数。
16.2 忘记写 override
这会让拼写错误、参数不一致、const 不一致的问题隐藏很久。
16.3 把对象按值放进基类容器
1 | std::vector<Base> xs; |
这会切片,不能保存派生类动态类型。
16.4 在构造函数里期待派生类虚函数被调用
构造和析构期间的动态类型规则和普通成员函数调用不同。
16.5 在热循环里混合大量不同动态类型
这可能让缓存、分支预测、内联和向量化一起变差。
17. 一页总结
虚函数最值得记住的是:
- 虚函数提供运行期多态,让调用方依赖稳定接口
- 函数代码通常在代码段,对象不保存函数本体
- 多态对象通常通过
vptr指向vtable - 虚调用大致是“对象 -> vptr -> vtable -> 函数地址 -> 间接调用”
- 真正的性能问题常常来自无法内联、对象分散和缓存 miss
- 热路径可以考虑模板、CRTP、
std::variant或按类型分组的数据布局
如果只记一句:
虚函数是很好的接口工具,但在最热的数据循环里,要同时审视动态派发和对象布局。
18. 参考资料
cppreference: virtual functions
https://en.cppreference.com/w/cpp/language/virtualcppreference: override specifier
https://en.cppreference.com/w/cpp/language/overridecppreference: final specifier
https://en.cppreference.com/w/cpp/language/finalcppreference: variant
https://en.cppreference.com/w/cpp/utility/variant