2638 字
13 分钟
C++ 虚函数与动态绑定:从调用原理到接口设计

本文是 C++ 系列 的第六篇。

上一篇:C++ 网络编程:先把 socket 当成资源对象管理

刚接触虚函数时,最容易卡在这类代码上:变量的静态类型明明是基类,为什么调用的却是派生类实现?

void execute(const Task& task) {
task.run();
}
NetworkTask task;
execute(task);

这段代码的关键不在于 execute 不知道传进来的是什么对象,而在于 Task::run 是否声明为 virtual。如果它是虚函数,调用目标会由对象的实际类型决定;如果不是,编译器只看 Task& 这个静态类型。

运行时多态不是“基类指针有魔法”。它依赖明确的接口设计、对象生命周期和所有权关系。这篇只讨论这些边界,不展开特定编译器的 ABI 或汇编细节。

1. 静态绑定:编译器只看表达式类型#

先看没有 virtual 的版本:

#include <iostream>
class Task {
public:
void run() const {
std::cout << "base task\n";
}
};
class NetworkTask : public Task {
public:
void run() const {
std::cout << "network task\n";
}
};
void execute(const Task& task) {
task.run();
}
int main() {
NetworkTask task;
execute(task);
}

这段代码输出 base taskNetworkTask::run 只是隐藏了同名函数,不构成运行时覆盖。execute 里的参数类型是 const Task&,编译器在编译期就确定调用 Task::run

这类调用叫静态绑定。它并不差:普通成员函数、函数重载、模板实例化都依赖编译期确定的调用关系。问题只是,当行为确实要随对象实际类型变化时,静态绑定不够用。

2. 动态绑定需要三个条件#

把基类函数声明为 virtual

#include <iostream>
class Task {
public:
virtual ~Task() = default;
virtual void run() const {
std::cout << "base task\n";
}
};
class NetworkTask final : public Task {
public:
void run() const override {
std::cout << "network task\n";
}
};
void execute(const Task& task) {
task.run();
}

这段代码中,execute(NetworkTask{}) 会调用 NetworkTask::run。动态绑定成立要同时满足三件事:

  1. 基类成员函数声明为 virtual
  2. 派生类使用相同签名覆盖它,最好写 override
  3. 调用通过基类指针或基类引用发生,并且实际对象是派生类对象

如果直接用 NetworkTask 对象调用 run(),当然也会进入派生类实现,但那不是动态绑定。动态绑定的价值在于:调用点只依赖 Task 接口,却仍能处理不同的具体任务。

3. vptrvtable 是常见模型,不是标准承诺#

主流编译器通常会用“对象里保存一个隐藏指针,类型对应一张函数表”的模型实现虚函数。常见术语是 vptrvtable

一次虚函数调用可以粗略理解为:

  1. 通过基类引用或指针拿到实际对象
  2. 从对象的隐藏信息找到该实际类型对应的虚函数表
  3. 从表中取出 run 对应的函数地址
  4. 调用派生类实现

这个模型能解释为什么相同的 Task& 会调用不同派生类行为,也能解释为什么带虚函数的对象常常比没有虚函数的对象多一些实现层面的开销。

但要保持边界:C++ 标准没有规定对象必须有 vptr,也没有规定 vtable 的布局、位置或函数顺序。 它们是常见实现策略,不应该被当成可移植的对象内存布局来依赖。

4. 抽象接口:让调用方依赖行为,不依赖具体类型#

工程里更常见的写法是把基类做成抽象接口:

#include <string_view>
class Task {
public:
virtual ~Task() = default;
virtual std::string_view name() const = 0;
virtual void run() = 0;
};

这段代码里,Task 不能直接构造,因为它有纯虚函数。它只描述任务必须具备的行为:能提供名称,也能执行。

具体任务各自实现接口:

#include <iostream>
class FlushLogTask final : public Task {
public:
std::string_view name() const override {
return "flush log";
}
void run() override {
std::cout << "flush log buffer\n";
}
};
class ReconnectTask final : public Task {
public:
std::string_view name() const override {
return "reconnect";
}
void run() override {
std::cout << "reconnect server\n";
}
};

这段代码让两种任务的内部细节完全不同,但调用方只需要认识 Taskfinal 表示这里不准备继续派生;它主要表达设计意图,不是为了追求某个微优化。

5. 异构对象放进容器时,把所有权写清楚#

std::vector<Task> 行不通:抽象类不能按值存储,而且按值存储基类还会带来对象切片。更合适的写法是让容器拥有多态对象:

#include <memory>
#include <vector>
class TaskExecutor {
public:
void add(std::unique_ptr<Task> task) {
tasks_.push_back(std::move(task));
}
void runAll() {
for (const auto& task : tasks_) {
task->run();
}
}
private:
std::vector<std::unique_ptr<Task>> tasks_;
};

这段代码里,TaskExecutor 独占任务对象。unique_ptr 不能拷贝,只能移动,所以 add 明确接收所有权,tasks_ 析构时会自动销毁所有任务。

调用方创建并移交任务:

int main() {
TaskExecutor executor;
executor.add(std::make_unique<FlushLogTask>());
executor.add(std::make_unique<ReconnectTask>());
executor.runAll();
}

这段代码没有裸 new,也没有手写 delete。每个对象的生命周期由 unique_ptr 管理,runAll 通过 Task 接口触发动态绑定。

这里的关键不是“必须使用智能指针”,而是对象所有权不能模糊。谁创建任务,谁拥有任务,谁决定销毁任务,都应该能从类型里看出来。

6. overridefinal 和虚析构函数都是接口约束#

override 应该作为覆盖虚函数时的默认写法:

class Task {
public:
virtual void run() = 0;
};
class NetworkTask final : public Task {
public:
void run() override {}
};

这段代码里,override 要求 NetworkTask::run 必须真的覆盖基类虚函数。比如不小心写成 void run() const,编译器会直接报错,而不是悄悄创建另一个同名函数。

只要对象可能通过基类指针被销毁,基类析构函数就必须是虚函数:

class Task {
public:
virtual ~Task() = default;
virtual void run() = 0;
};

这段代码保证 std::unique_ptr<Task> 销毁派生对象时,会先调用派生类析构函数,再调用基类析构函数。没有虚析构函数,通过基类指针删除派生对象会产生未定义行为。

final 可以放在派生类或虚函数上,表达“不允许继续覆盖或派生”。它适合用于设计已经收敛的类型,不应该机械地给所有类都加上。

7. 三个常见风险#

7.1 对象切片#

按值把派生对象传给基类参数,会丢掉派生部分:

#include <iostream>
class Message {
public:
virtual void print() const {
std::cout << "base message\n";
}
};
class TextMessage final : public Message {
public:
void print() const override {
std::cout << "text message\n";
}
};
void printMessage(Message message) {
message.print();
}

这段代码里,printMessage(TextMessage{}) 会输出 base messageTextMessage 被复制成一个独立的 Message 对象,派生部分已经被切掉。

多态接口通常应该接收 const Message&Message&std::unique_ptr<Message>,而不是 Message 值。

7.2 在构造或析构中调用虚函数#

class Task {
public:
Task() {
configure();
}
virtual void configure() {}
};
class NetworkTask final : public Task {
public:
void configure() override {
// 此时派生类成员尚未完成构造
}
};

这段代码在构造 Task 阶段调用 configure() 时,不会动态派发到 NetworkTask::configure()。构造和析构期间,对象并不处在完整派生类状态;虚调用会按当前构造或析构层级处理。

更稳的做法是让构造函数只建立对象不变量,把需要多态行为的初始化放到对象构造完成之后的普通成员函数里。

7.3 为了复用而过度继承#

继承适合表达稳定的“is-a”接口关系。比如 FlushLogTask 是一种 Task,这个关系比较清楚。

但重试、日志、限流、缓存这类能力通常不是“某种任务”,而是附加行为。更适合用组合:让一个对象持有策略对象或包装另一个对象,而不是不断加深继承层级。

8. 运行时多态、模板和组合怎么选#

三种方式解决的问题不同:

方式适合的场景主要代价
运行时多态类型在运行时才确定,需要异构对象容器间接调用、接口设计和堆分配可能更复杂
模板多态类型在编译期已知,追求静态检查和内联机会编译时间、错误信息和代码膨胀
组合行为可以由成员对象拼装,不需要 is-a 关系需要先拆清楚职责

TaskExecutor 需要在运行时存储不同任务,因此运行时多态合理。一个只在编译期选择算法的数值计算模块,模板往往更直接。一个任务要附加重试或日志能力,优先考虑组合,而不是再造一层继承。

不要把虚函数当成默认设计。先问一句:调用方是否真的只依赖抽象接口,而且具体实现是否会在运行时变化?答案是肯定的,再引入运行时多态。

9. 面试里可以准确回答什么#

如果被问到“为什么基类指针能调用派生类函数”,可以把回答压缩成几句:

  1. 基类函数必须是 virtual
  2. 调用要通过基类指针或引用发生
  3. 实际对象必须是派生类对象
  4. 主流实现通常通过 vptr/vtable 找到覆盖函数,但这不是 C++ 标准规定的内存布局

继续往工程侧补充:基类析构函数要是虚函数;异构对象通常由 std::unique_ptr<Base> 管理;避免对象切片;构造和析构阶段不要依赖派生类虚行为。

10. 小结#

这一篇可以先记住:

  1. 静态绑定看表达式类型,动态绑定看对象实际类型
  2. virtual、基类指针或引用、派生实际对象共同构成动态绑定条件
  3. vptr/vtable 是常见实现模型,不是标准布局承诺
  4. 抽象接口描述行为,unique_ptr<Base> 把多态对象所有权写清楚
  5. overridefinal 和虚析构函数让接口约束更明确
  6. 对象切片、构造析构期间虚调用、过度继承是最常见的设计风险
  7. 运行时多态只在运行时需要抽象替换时使用,其他场景优先考虑模板或组合

虚函数真正解决的不是“怎么把函数调到派生类”,而是让调用方依赖稳定接口,同时把具体实现和对象所有权留在合适的边界里。