虚函数、多态与动态派发

虚函数、多态与动态派发

时间:2026/05/04

关键词:虚函数、动态多态、vptrvtable、虚析构、对象切片、动态派发、去虚化、CRTP、std::variant
核心目标:理解虚函数的语义、底层直觉和性能代价,知道什么时候该用动态多态,什么时候该换成静态分发。


1. 虚函数解决什么问题

虚函数解决的是:

调用方只知道基类接口,但运行时对象可能是不同派生类。

例子:

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
#include <iostream>
#include <memory>
#include <vector>

struct Task {
virtual ~Task() = default;
virtual void run() = 0;
};

struct LoadTask : Task {
void run() override {
std::cout << "load\n";
}
};

struct SaveTask : Task {
void run() override {
std::cout << "save\n";
}
};

int main() {
std::vector<std::unique_ptr<Task>> tasks;
tasks.push_back(std::make_unique<LoadTask>());
tasks.push_back(std::make_unique<SaveTask>());

for (const auto& task : tasks) {
task->run();
}
}

这里调用方只面对:

1
Task

但实际执行哪个 run(),取决于对象的真实动态类型。


2. 普通函数和虚函数的区别

普通成员函数:

1
2
3
struct A {
void f();
};

调用时,编译器通常在编译期就知道目标函数:

1
2
A a;
a.f();

虚函数:

1
2
3
struct Base {
virtual void f();
};

通过基类指针或引用调用时:

1
2
3
void call(Base& b) {
b.f();
}

编译器不能只看 Base& 就确定具体执行哪个版本,因为 b 可能引用任何派生类对象。

这就是动态派发:

  • 编译期确定接口
  • 运行期确定实现

3. 纯虚函数与抽象类

纯虚函数写法:

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

含有纯虚函数的类是抽象类,不能直接实例化:

1
// Shape s; // 错

派生类需要实现这个接口:

1
2
3
4
5
6
7
8
9
struct Circle : Shape {
double r;

explicit Circle(double radius) : r(radius) {}

double area() const override {
return 3.1415926 * r * r;
}
};

这里的 override 很重要,它能让编译器检查你是否真的重写了基类虚函数。


4. overridefinal

重写虚函数时推荐总是写 override

1
2
3
4
5
6
7
struct Base {
virtual void update(int dt);
};

struct Derived : Base {
void update(int dt) override;
};

如果参数写错:

1
2
3
struct Bad : Base {
void update(double dt) override; // 编译报错
};

这能避免“以为重写了,实际新增了一个函数”的问题。

final 表示不允许继续重写:

1
2
3
struct Leaf final : Base {
void update(int dt) override;
};

或者只禁止某个虚函数继续被重写:

1
2
3
struct Mid : Base {
void update(int dt) final;
};

在某些情况下,final 也能帮助编译器做去虚化优化。


5. 虚析构为什么重要

如果一个类要被当作多态基类使用,析构函数通常必须是虚函数:

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() = default;
};

struct Derived : Base {
~Derived() {
// 释放 Derived 自己的资源
}
};

否则这样删除会出问题:

1
2
Base* p = new Derived;
delete p; // 基类析构非 virtual 时有未定义行为风险

现代 C++ 更常写成:

1
std::unique_ptr<Base> p = std::make_unique<Derived>();

但即使用智能指针,底层仍然会通过基类析构。
所以多态基类的析构函数依然要设计清楚。

经验规则:

  • 要通过基类指针删除对象:基类析构必须是 virtual
  • 不允许通过基类删除:可以把基类析构设为 protected 且非虚

6. 函数和对象在内存里大概在哪里

先建立一个底层直觉:

  • 函数代码通常位于程序的代码段
  • 普通对象里不会保存成员函数的机器码
  • 成员函数不是“每个对象各存一份”
  • 对象保存的是数据成员,以及实现多态所需的额外信息

例如:

1
2
3
4
struct Counter {
int value;
void inc() { ++value; }
};

每个 Counter 对象通常只需要保存:

1
value

inc() 的机器码在代码段里,所有 Counter 对象共享同一份函数代码。

调用成员函数时,可以粗略理解成编译器额外传了一个隐藏参数:

1
c.inc();

近似成:

1
Counter_inc(&c);

这个隐藏的对象指针就是常说的:

1
this

7. vptrvtable 的近似模型

对含虚函数的对象,主流实现通常会在对象中放一个隐藏指针:

1
vptr

它指向一张虚函数表:

1
vtable

虚函数表里保存函数地址。
可以粗略理解成:

1
2
3
4
5
对象
├── vptr -----> vtable
│ ├── &Derived::run
│ └── &Derived::~Derived
└── 数据成员

示例:

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() = default;
virtual void run() = 0;
};

struct Derived : Base {
int x = 0;
void run() override {}
};

一个 Derived 对象里通常会有:

  • 一个隐藏的 vptr
  • 数据成员 x

vtable 通常位于只读数据区附近,由编译器生成。
这些位置属于实现细节,不同编译器、ABI、多重继承场景都会有差异。

这部分只需要记住性能直觉:

虚函数不是把函数代码塞进对象,而是对象多带了一个“查表入口”。


8. 虚调用大致发生了什么

通过基类引用调用虚函数:

1
2
3
void call(Base& b) {
b.run();
}

底层可以粗略想成:

1
2
3
4
1. 从对象地址找到 vptr
2. 通过 vptr 找到 vtable
3. 从 vtable 中取出 run 对应的函数地址
4. 间接 call 到这个函数地址

也就是:

1
对象 -> vptr -> vtable -> 函数地址 -> 间接调用

相比普通直接调用,虚调用多了:

  • 一次或多次内存读取
  • 一次间接跳转
  • 编译器更难内联
  • CPU 分支预测更难判断目标

所以虚函数的成本通常不是“虚函数本身很慢”这么简单,而是:

  • 它阻碍了内联
  • 它让调用目标到运行期才确定
  • 它常常伴随指针间接访问和对象分散存储

9. 动态派发的真实性能成本

单次虚调用的额外开销通常不大。
真正容易放大的场景是:

1
2
3
for (auto& p : objects) {
p->update();
}

如果 objects 是一堆指向不同堆对象的指针,成本可能来自:

  • 指针追踪导致缓存 miss
  • 对象分散在堆上
  • 每次虚调用目标不同,间接分支预测困难
  • 无法内联 update()
  • 编译器难以做循环优化和向量化

在高性能热循环里,问题往往不是一个 virtual 关键字,而是这一整套数据布局:

1
2
3
4
vector<unique_ptr<Base>>
-> 堆对象
-> vptr
-> 间接函数地址

这比:

1
std::vector<Particle>

更难被 CPU 和编译器优化。


10. 对象切片

对象切片是多态初学者常见坑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Base {
virtual ~Base() = default;
virtual void print() const {
std::cout << "Base\n";
}
};

struct Derived : Base {
int value = 42;

void print() const override {
std::cout << "Derived\n";
}
};

int main() {
Derived d;
Base b = d; // 切片:只拷贝 Base 子对象
b.print(); // 调用 Base::print
}

Base b = d 会创建一个真正的 Base 对象,派生类部分被切掉。
如果要保留动态类型,应使用:

  • Base&
  • Base*
  • std::unique_ptr<Base>
  • std::shared_ptr<Base>

11. 构造和析构中的虚调用

构造函数和析构函数里调用虚函数要非常小心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct Base {
Base() {
init();
}

virtual ~Base() = default;

virtual void init() {
std::cout << "Base init\n";
}
};

struct Derived : Base {
int x = 1;

void init() override {
std::cout << "Derived init: " << x << '\n';
}
};

构造 Derived 时,先构造 Base 子对象。
Base 构造期间,派生类部分还没构造好,所以虚调用不会派发到 Derived::init()

析构也类似:

  • 派生类部分先析构
  • 基类析构期间对象已经不再是完整的派生类

经验规则:

不要依赖构造函数或析构函数里的虚调用来触发派生类行为。


12. 去虚化:编译器什么时候能优化掉虚调用

去虚化指的是:

编译器证明某次虚调用的目标只有一个,于是把它变成直接调用,甚至内联。

常见条件:

  • 对象的动态类型在当前作用域里明确
  • 类或函数被标记为 final
  • 链接期优化能看到完整继承关系
  • 编译器根据上下文推断没有其他派生实现

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
virtual ~Base() = default;
virtual int value() const = 0;
};

struct Derived final : Base {
int value() const override {
return 1;
}
};

int f() {
Derived d;
Base& b = d;
return b.value();
}

这里编译器很可能知道 b 实际引用 Derived,从而把调用优化掉。
但这不是语言层保证,而是优化器能力。


13. 什么时候适合用虚函数

适合:

  • 运行期才知道具体类型
  • 需要稳定插件接口
  • 类型集合经常扩展
  • 调用频率不在最热路径
  • 接口边界比极致性能更重要

典型例子:

  • 渲染后端接口
  • 文件系统抽象
  • 任务系统里的任务基类
  • 插件注册和对象工厂

这类场景中,虚函数提供的解耦价值往往大于一点动态派发成本。


14. 热循环里的替代方案

如果某段代码是极热路径,并且类型集合在编译期基本固定,可以考虑其他方式。

14.1 模板和策略类

1
2
3
4
5
6
template <class Integrator>
void step(Particle* ps, std::size_t n, const Integrator& integrator) {
for (std::size_t i = 0; i < n; ++i) {
integrator(ps[i]);
}
}

如果 Integrator 类型在编译期确定,编译器更容易内联。

14.2 CRTP

1
2
3
4
5
6
7
8
9
10
11
12
template <class Derived>
struct Updatable {
void update() {
static_cast<Derived*>(this)->update_impl();
}
};

struct Player : Updatable<Player> {
void update_impl() {
// ...
}
};

CRTP 是静态多态:

  • 没有虚表
  • 调用目标编译期确定
  • 适合类型集合比较固定的高性能代码

代价是:

  • 不能像 Base* 那样自然存放异构对象
  • 编译期耦合更强

14.3 std::variant

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <variant>
#include <vector>

struct A {
void update() {}
};

struct B {
void update() {}
};

using Object = std::variant<A, B>;

void update_all(std::vector<Object>& objects) {
for (auto& obj : objects) {
std::visit([](auto& x) {
x.update();
}, obj);
}
}

variant 适合:

  • 类型集合有限
  • 希望对象直接存进连续容器
  • 不想为每个对象单独堆分配

代价是:

  • 新增类型时要改 variant 类型列表
  • 访问逻辑可能变复杂

15. 数据布局通常比虚调用本身更重要

假设有很多对象要更新:

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

这给 CPU 的信息是:

  • 先遍历一段连续指针
  • 再跳到分散的堆对象
  • 再通过 vptr 找函数地址

如果改成按类型分组:

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

可能带来的收益:

  • 对象连续
  • 循环更简单
  • 调用目标更稳定
  • 更容易内联和向量化

所以高性能代码里常见做法是:

  • 外层系统边界用虚函数表达抽象
  • 内层热循环用连续数组和静态分发

16. 常见坑

16.1 基类没有虚析构

只要可能通过基类指针删除派生对象,就要认真处理析构函数。

16.2 忘记写 override

这会让拼写错误、参数不一致、const 不一致的问题隐藏很久。

16.3 把对象按值放进基类容器

1
std::vector<Base> xs;

这会切片,不能保存派生类动态类型。

16.4 在构造函数里期待派生类虚函数被调用

构造和析构期间的动态类型规则和普通成员函数调用不同。

16.5 在热循环里混合大量不同动态类型

这可能让缓存、分支预测、内联和向量化一起变差。


17. 一页总结

虚函数最值得记住的是:

  1. 虚函数提供运行期多态,让调用方依赖稳定接口
  2. 函数代码通常在代码段,对象不保存函数本体
  3. 多态对象通常通过 vptr 指向 vtable
  4. 虚调用大致是“对象 -> vptr -> vtable -> 函数地址 -> 间接调用”
  5. 真正的性能问题常常来自无法内联、对象分散和缓存 miss
  6. 热路径可以考虑模板、CRTP、std::variant 或按类型分组的数据布局

如果只记一句:

虚函数是很好的接口工具,但在最热的数据循环里,要同时审视动态派发和对象布局。


18. 参考资料

  1. cppreference: virtual functions
    https://en.cppreference.com/w/cpp/language/virtual

  2. cppreference: override specifier
    https://en.cppreference.com/w/cpp/language/override

  3. cppreference: final specifier
    https://en.cppreference.com/w/cpp/language/final

  4. cppreference: variant
    https://en.cppreference.com/w/cpp/utility/variant