虚函数调用真的很慢吗?先分清动态派发与对象布局的成本

时间:2026/05/04

性能分析中常看到这样的热循环:

1
2
3
for (const std::unique_ptr<Entity>& entity : entities) {
entity->update(dt);
}

第一反应往往是“虚函数太慢”。但这一行可能同时包含四件事:读取连续的智能指针、跳到分散的堆对象、读取对象动态类型信息、间接调用目标函数。真正昂贵的也许是缓存未命中和无法批处理,而不是间接调用本身。

虚函数解决的是运行时类型选择问题:调用方只知道稳定的基类接口,具体实现直到运行时才确定。是否值得替换,必须同时看接口需求、数据布局、调用频率和编译器能否去虚化。

本文先用完整示例建立正确生命周期,再解释主流实现中的 vptr/vtable、切片和构造期行为,最后比较 std::variant、模板与按类型分组等热路径方案。

1. 为什么普通重载解决不了运行时类型选择?

假设场景里既有圆又有矩形,调用方只拿到 Shape&

1
2
3
double measure(const Shape& shape) {
return shape.area();
}

静态类型是 Shape,动态类型可能是 CircleRectangle。把 area 声明为虚函数后,通过基类引用或指针调用时,C++ 根据对象动态类型选择最终覆盖函数(final overrider)。

1
2
3
4
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};

= 0 表示纯虚函数,使 Shape 成为抽象类,不能直接实例化。派生类用 override 明确覆盖:

1
2
3
struct Circle final : Shape {
double area() const override;
};

override 会让参数、cv 限定或引用限定不一致变成编译错误,而不是悄悄新增一个同名函数。final 在这里表示不允许继续派生,也可能给优化器更多动态类型信息,但不承诺一定内联。

2. 怎样写出生命周期正确的最小多态程序?

下面用 std::unique_ptr<Shape> 保存异构对象,计算总面积,并用计数器验证派生对象最终全部析构。代码需要 C++17。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <iomanip>
#include <iostream>
#include <memory>
#include <string_view>
#include <vector>

class Shape {
public:
virtual ~Shape() = default;
virtual double area() const noexcept = 0;
virtual std::string_view name() const noexcept = 0;
};

class Circle final : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {
++alive;
}

~Circle() override {
--alive;
}

double area() const noexcept override {
constexpr double pi = 3.14159265358979323846;
return pi * radius_ * radius_;
}

std::string_view name() const noexcept override {
return "circle";
}

static int alive;

private:
double radius_;
};

int Circle::alive = 0;

class Rectangle final : public Shape {
public:
Rectangle(double width, double height)
: width_(width), height_(height) {
++alive;
}

~Rectangle() override {
--alive;
}

double area() const noexcept override {
return width_ * height_;
}

std::string_view name() const noexcept override {
return "rectangle";
}

static int alive;

private:
double width_;
double height_;
};

int Rectangle::alive = 0;

int main() {
{
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));

double total = 0.0;
for (const std::unique_ptr<Shape>& shape : shapes) {
std::cout << shape->name() << '\n';
total += shape->area();
}

std::cout << std::fixed << std::setprecision(3)
<< "total area = " << total << '\n'
<< "alive inside = "
<< Circle::alive + Rectangle::alive << '\n';
}

std::cout << "alive after = "
<< Circle::alive + Rectangle::alive << '\n';
}

编译运行:

1
2
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic polymorphism.cpp -o polymorphism
./polymorphism

预期输出:

1
2
3
4
5
circle
rectangle
total area = 24.566
alive inside = 2
alive after = 0

程序同时验证两件事:area() 根据动态类型派发;通过基类智能指针销毁时,虚析构让派生析构函数得到调用。

3. 为什么多态基类通常需要虚析构?

若通过基类指针删除派生对象,而基类析构不是虚函数,行为不满足普通多态销毁要求:

1
2
3
4
5
6
struct Base {
~Base() = default; // 非虚
};

Base* pointer = new Derived;
delete pointer; // 错误设计:不能正确进行多态删除

std::unique_ptr<Base> 的默认删除器最终也会对 Base* 执行 delete,智能指针不会自动弥补非虚析构。

多态基类常见两种明确设计:

  • 允许通过基类删除:析构函数公开且虚;
  • 禁止通过基类删除:析构函数受保护且非虚,并由其他所有权机制销毁具体类型。

纯虚析构函数也必须有定义,因为派生对象析构最终仍会执行基类析构部分。

4. vptr 与 vtable 是标准保证吗?

C++ 标准规定动态派发的可观察语义,不规定对象必须怎样布局。主流 ABI 通常为多态对象保存隐藏的虚表指针(vptr),指向虚函数表(vtable):

1
2
3
4
5
6
Circle object
├── vptr ─────> virtual table
│ ├── destructor entry
│ ├── Circle::area
│ └── Circle::name
└── radius

通过 Shape* 调用 area() 时,可粗略理解为:加载 vptr、找到表中槽位、间接跳转到目标。多重继承、虚继承、协变返回和 this-adjustment 会让真实 ABI 更复杂,不能依赖这张图解析任意对象内存。

函数机器码通常位于代码区,不会复制进每个对象。对象常增加一个或多个隐藏指针和相应对齐开销;准确大小只能在目标编译器、ABI 和类层次上用 sizeof 等方式观察。

5. 一次虚调用的成本究竟来自哪里?

与可内联直接调用相比,动态派发可能增加:

  1. 从对象取得派发信息;
  2. 间接分支;
  3. 目标变化时更难预测;
  4. 调用点难以内联,后续常量传播和向量化机会减少。

但在 vector<unique_ptr<Base>> 中,往往还有更大的数据成本:

1
2
3
4
连续指针数组
├──> heap object A
├────────> heap object B
└────> heap object C

每个对象可能在不同缓存行甚至页面。若 update() 本身做文件 I/O、复杂搜索或大量计算,虚调用成本可以忽略;若它只加两个浮点数且执行上亿次,派发与布局才可能显著。

所以“虚函数慢不慢”没有脱离工作负载的答案。要测的是整个调用路径,不是关键字。

6. 编译器什么时候能去虚化?

去虚化(devirtualization)是编译器证明动态目标唯一后,把虚调用改成直接调用,甚至继续内联。

1
2
3
4
5
int evaluate() {
Circle circle(2.0);
Shape& shape = circle;
return static_cast<int>(shape.area());
}

这里动态类型在局部作用域明确,优化器可能去虚化。常见有利条件包括:

  • 具体对象在调用点可见;
  • 类或覆盖函数是 final
  • 链接时优化(LTO)看到更完整的类层次;
  • profile-guided optimization 获得目标分布信息。

这些都是实现优化,不是语言承诺。共享库可见性、插件加载、编译单元边界和编译器选项都会改变结果。用优化报告或汇编确认,而不是看到 final 就宣布成本消失。

7. 对象切片为什么会让动态类型消失?

按值把派生对象复制到基类对象,只保留基类子对象:

1
2
3
Derived derived;
Base base = derived; // 对象切片
base.run(); // base 本身就是 Base 对象

这不是虚派发失败,而是新对象的动态类型确实为 Base。同理,std::vector<Base> 不能按值保存不同派生对象。

要保留动态类型,使用基类引用、指针或拥有型智能指针。若确实需要值语义的多态对象,可以设计 clone()、使用专门的 polymorphic-value 封装,或在类型集合封闭时使用 std::variant

引用和裸指针不表达所有权,必须确保对象生命周期覆盖使用期。不要为了避免切片而把所有接口都换成 shared_ptr

8. 为什么构造函数里的虚调用不会到最派生类?

构造 Derived 时,Base 子对象先构造。基类构造期间,派生部分尚未开始其生命周期,因此虚调用按当前构造层次处理,不会派发到尚未构造的 Derived 覆盖函数:

1
2
3
4
5
6
7
8
9
struct Base {
Base() { initialize(); }
virtual void initialize();
};

struct Derived : Base {
std::string state;
void initialize() override; // Base 构造时不会调用这个版本
};

析构反向发生:进入基类析构时,派生部分已经销毁,虚调用也不会重新进入派生实现。

不要设计依赖构造/析构中多态回调的初始化流程。可使用工厂函数:先完整构造对象,再调用明确的虚拟初始化步骤,并处理失败状态;更简单时直接让每层构造自己的成员。

9. 按类型分组为什么可能比替换派发机制更重要?

假设更新百万个粒子,类型集合固定为玩家、敌人和子弹。异构指针容器将不同对象交错:

1
std::vector<std::unique_ptr<Entity>> entities;

按类型分组则为:

1
2
3
std::vector<Player> players;
std::vector<Enemy> enemies;
std::vector<Bullet> bullets;

后者让对象连续、调用目标固定,更容易预取、内联和向量化。即使仍在每个系统外层使用虚接口,内层热循环也可以处理同质数组:

1
2
动态多态:选择本帧运行哪个系统
静态循环:系统内部批量更新连续数据

代价是对象集合管理更复杂,跨类型保持全局顺序也需要额外索引。数据布局优化必须服务真实访问模式,不能为了消灭虚函数重写整个架构。

10. std::variant 什么时候更合适?

类型集合在编译期封闭时,可将对象按值存入 variant:

1
2
3
4
5
6
7
8
using ShapeValue = std::variant<Circle, Rectangle>;
std::vector<ShapeValue> shapes;

for (ShapeValue& shape : shapes) {
std::visit([](auto& concrete) {
concrete.update();
}, shape);
}

variant 自身存放足以容纳最大候选类型的内联存储和活动类型信息,通常避免每个元素单独堆分配。它的权衡包括:

  • 容器元素大小由最大候选与对齐决定;
  • visitation 仍需要根据活动类型选择路径;
  • 新增类型要修改封闭类型列表及可能的 visitor;
  • 大小差异悬殊时会浪费空间。

它适合编译期已知的少量类型,不适合运行时第三方随意增加实现的开放集合。

11. 模板和 CRTP 能否替代虚函数?

模板参数在编译期确定时,调用目标通常可见:

1
2
3
4
5
6
7
8
template <class Integrator>
void step(
std::vector<Particle>& particles,
const Integrator& integrator) {
for (Particle& particle : particles) {
integrator(particle);
}
}

CRTP(curiously recurring template pattern)也能表达静态多态:

1
2
3
4
5
6
template <class Derived>
struct Updatable {
void update() {
static_cast<Derived&>(*this).update_impl();
}
};

它们让内联和专门化更容易,却会把实现暴露在头文件、增加模板实例与编译成本,也不能自然地通过一个稳定基类指针保存运行时开放类型。静态多态不是“更高级的虚函数”,而是解决不同约束的问题。

12. 函数对象与类型擦除又处在什么位置?

若只需要一个行为而非完整继承层次,可以保存可调用对象:

1
std::function<void(double)> update;

std::function 是类型擦除(type erasure)工具,可能包含间接调用,并可能为较大捕获分配内存。它改善接口组合,不保证比虚函数快。

自定义 type erasure 可以控制内联存储、复制语义和接口集合,但实现复杂且容易触及生命周期、对齐和异常安全问题。项目已有虚接口已经清楚时,不应仅为追求新模式重写。

13. C++ 虚接口能否作为跨模块稳定 ABI?

在同一编译器、兼容版本、相同标准库和统一构建选项的受控系统中,虚接口常用于模块边界。但 C++ 标准不规定 vtable 布局、名字修饰、异常 ABI 或标准库二进制布局。

跨编译器、跨运行库或需要长期独立升级的插件,通常使用版本化 C ABI 工厂和函数表更可控,再在模块内部包装成 C++ 对象。即使使用同一 ABI,也要考虑:

  • 谁分配、谁释放对象;
  • 基类布局能否改变;
  • 异常能否跨边界;
  • RTTI 与编译选项是否兼容;
  • 接口版本和能力协商。

因此,“虚函数提供稳定接口”是源码设计意义,不自动等于二进制兼容承诺。

14. 怎样做一个可信的动态派发基准?

不要只比较一个空虚函数与空普通函数,编译器可能把工作完全删除,结果也无法代表真实系统。一个有意义的实验应分别控制:

  1. 相同对象数据与相同业务计算;
  2. 同质动态类型与随机混合类型;
  3. 连续对象、指针对象和按类型分组布局;
  4. 是否启用 LTO、PGO 与去虚化;
  5. 结果被消费,避免死代码消除;
  6. Release 构建、预热和多轮统计。

同时查看 CPU cycles、分支预测失败、缓存未命中和生成汇编。若替换虚函数后耗时下降,仍要区分收益来自直接调用、内联,还是从堆对象改成连续存储。

15. 常见误区如何纠正?

误区一:每个多态对象都保存一份虚函数代码

不对。主流实现通常只在对象中保存派发指针,函数代码由对象共享。

误区二:虚函数每次都会发生缓存未命中

不对。vtable 和代码可能处于缓存中,间接分支也可能预测准确。成本依目标分布和数据布局变化。

误区三:final 一定消除虚调用

不对。它提供证明信息,是否去虚化仍由调用上下文和优化器决定。

误区四:智能指针让非虚基类析构变安全

不对。unique_ptr<Base> 默认删除仍依赖基类正确支持多态销毁。

误区五:用基类值保存派生对象仍能多态

不对。对象切片后新对象动态类型就是基类。

误区六:variant、CRTP 或 std::function 一定更快

不对。它们改变类型集合、存储和编译模型,各有分支、尺寸、分配或代码膨胀成本,必须按约束选择和测量。

16. 什么时候应该使用动态多态?

虚函数适合:

  • 具体类型运行时才知道;
  • 类型集合开放,需要第三方实现接口;
  • 调用位于粗粒度边界,不是极细热循环;
  • 接口解耦和可测试替身比微小派发成本重要;
  • 对象本来就需要独立生命周期。

以下场景值得比较其他方案:

  • 数百万同类对象执行极小操作;
  • 类型集合固定且很少;
  • profiler 显示间接分支、指针追踪或无法向量化是热点;
  • 对象可以自然按类型分组、连续处理;
  • 跨 ABI 边界需要比 C++ 类布局更严格的兼容策略。

17. 总结

回到开头,entity->update() 的成本不能只归咎于虚函数。动态派发可能阻止内联并增加间接分支,但异构堆对象造成的指针追踪和缓存局部性通常同样重要,甚至更重要。

最重要的结论是:

  1. 虚函数在运行时根据动态类型选择实现,适合开放类型集合与清晰接口边界。
  2. 多态删除需要正确析构策略,override、切片和构造期派发属于首先要保证的正确性。
  3. vptr/vtable 是主流 ABI 的实现模型,不是标准规定的可移植对象布局。
  4. 去虚化可能消除部分成本,但必须通过优化报告或汇编确认。
  5. 热路径优化应同时比较派发、对象存储、类型分组和批处理机会。

实践中先用 profiler 找到真正热点,再做一个“保持布局不变、只改变派发”的实验,以及一个“保持行为不变、改为连续分组”的实验。只有把两个变量拆开,才能知道该优化接口还是数据。