C++ 中的虚函数不只是语法特性,它直接决定了对象在运行时调用哪一个方法。设计模式反复强调面向接口编程、把变化隔离到可替换的组件中,虚函数恰好提供了这种稳定契约:基类定义行为签名,派生类给出具体实现,客户端只看到基类引用或指针。理解虚函数在设计模式中的应用,相当于理解了多态如何从语言机制转化为结构设计手段。

一、虚函数与多态:设计模式的基础
当类中包含虚函数时,编译器会为这个类生成一张虚函数表,通常称为 vtable。每个包含虚函数的对象内部都有一个指向这张表的指针,称为 vptr。当程序通过基类指针或引用调用虚函数时,并不会直接在编译期绑定到某个具体函数地址,而是根据对象的实际类型查找虚函数表,再跳转到对应的函数实现。这个间接调用过程就是动态绑定,也是 C++ 运行时多态的核心。
纯虚函数进一步把基类变成抽象接口。声明为 = 0 的虚函数不提供实现,派生类必须覆写才能实例化。C++11 引入的 override 关键字可以让编译器检查函数签名是否真的匹配基类声明,避免因为参数类型或 const 修饰符写错而意外创建了一个新的虚函数。另一个关键字 final 可以阻止派生类继续覆写某个虚函数,或者阻止某个类被继续继承。这些语言特性让抽象接口更安全,也为设计模式提供了明确的约束。
设计模式强调高层模块依赖抽象而不是具体类,所谓依赖倒置原则。虚函数让这种依赖可以落地:业务代码只持有抽象基类的指针或引用,具体实现可以在运行时注入。策略模式、模板方法模式、观察者模式、工厂方法模式等都很看重这种可替换性。可以说,虚函数是 C++ 语言层面对接口编程的天然支持。
二、策略模式:将算法抽象为虚函数
策略模式适合处理一组可以互相替换的算法。例如电商系统中根据用户等级计算折扣,或者图形程序里根据文件格式选择不同导出方式。如果把这些逻辑写成连续的 if else,每新增一种算法都要修改既有分支,代码会越来越难维护。策略模式把每一种算法封装成独立类,它们都实现同一个抽象策略接口,客户端只依赖这个接口,不关心当前使用的是哪一个具体算法。
下面的代码展示了策略模式的基本结构。Strategy 作为抽象基类,唯一的虚函数 calc 定义算法契约。AddStrategy 和 MultiplyStrategy 分别实现加法运算和乘法运算。客户端 Calculator 持有一个指向 Strategy 的智能指针,运行时可以随时替换。
#include <iostream>
#include <memory>
class Strategy {
public:
virtual double calc(double a, double b) const = 0;
virtual ~Strategy() = default;
};
class AddStrategy : public Strategy {
public:
double calc(double a, double b) const override {
return a + b;
}
};
class MultiplyStrategy : public Strategy {
public:
double calc(double a, double b) const override {
return a * b;
}
};
class Calculator {
std::unique_ptr<Strategy> strategy_;
public:
void setStrategy(std::unique_ptr<Strategy> s) {
strategy_ = std::move(s);
}
double run(double x, double y) const {
return strategy_->calc(x, y);
}
};
int main() {
Calculator calc;
calc.setStrategy(std::make_unique<AddStrategy>());
std::cout << calc.run(3, 5) << std::endl;
calc.setStrategy(std::make_unique<MultiplyStrategy>());
std::cout << calc.run(3, 5) << std::endl;
return 0;
}
这个例子里,Calculator::run 内部调用的是基类 Strategy::calc,但真正执行的代码取决于运行时注入的对象类型。新增一种算法只需要再写一个派生类,不需要修改 Calculator 的代码,这符合开闭原则。策略对象通常由智能指针管理,因此基类的虚析构函数不能省略。否则通过基类指针删除派生类对象时,派生类资源可能不会被正确释放。
策略模式使用组合而不是继承来扩展行为,虚函数在这里只是定义策略接口。这种设计让算法变化被控制在独立类内部,调用方和算法实现之间保持松耦合。实际项目中,如果策略对象本身没有状态,也可以考虑使用函数对象或 lambda,但虚函数接口在需要复杂策略族时仍然更清晰。
三、模板方法模式:虚函数固定流程骨架
模板方法模式解决的是另一种问题:整个流程结构固定,但其中某些步骤可以由子类定制。典型场景如数据报表导出,无论导出 CSV、JSON 还是 Excel,流程都可以概括为打开文件、写表头、写每一行数据、写尾部、关闭文件。模板方法模式把这个固定流程放在基类的非虚函数中,把可变步骤设计成受保护的虚函数,留给派生类实现。
下面代码中,ReportExporter::exportReport 是模板方法,它不是虚函数,因此子类不能改变整体流程。步骤函数 openFile、writeHeader、writeRow、writeFooter、closeFile 都是纯虚函数,派生类必须实现。客户端只调用 exportReport,不需要知道具体导出格式。
#include <iostream>
#include <string>
#include <vector>
class ReportExporter {
public:
virtual ~ReportExporter() = default;
void exportReport() {
openFile();
writeHeader();
for (const auto& row : rows_) {
writeRow(row);
}
writeFooter();
closeFile();
}
protected:
virtual void openFile() = 0;
virtual void writeHeader() = 0;
virtual void writeRow(const std::string& row) = 0;
virtual void writeFooter() = 0;
virtual void closeFile() = 0;
std::vector<std::string> rows_{"apple", "banana", "cherry"};
};
class CsvExporter : public ReportExporter {
protected:
void openFile() override {
std::cout << "open csv\n";
}
void writeHeader() override {
std::cout << "id,name\n";
}
void writeRow(const std::string& row) override {
std::cout << row << "\n";
}
void writeFooter() override {}
void closeFile() override {
std::cout << "close csv\n";
}
};
int main() {
CsvExporter exporter;
exporter.exportReport();
return 0;
}
模板方法模式与策略模式有明显区别。策略模式将整个算法交给另一个对象,客户端可以自由替换策略;模板方法模式则把流程骨架固定在基类中,派生类只能填充局部步骤。虚函数在模板方法中的角色不是对外开放的接口,而是基类骨架回调派生类的定制点。把步骤函数设为 protected,可以防止外部直接调用这些孤立的步骤,保证它们只在完整流程中被使用。
实际编码时,可以把模板方法声明为 final,进一步阻止派生类覆写整个流程。虚函数的访问级别和 final 关键字共同维护了流程的不可变性。只要派生类按照约定实现步骤,新增一种导出格式就是新增一个类,不会影响既有导出逻辑。
四、观察者模式:虚函数充当事件回调接口
观察者模式用于对象之间的一对多通知,例如按钮点击后更新界面、写日志、发送统计。被观察对象称为 Subject,它维护一组观察者。当状态变化时,Subject 调用每个观察者的更新方法,但不需要知道观察者具体是什么类型。观察者接口通常只包含一个虚函数 update,所有具体观察者继承并实现这个函数。
下面是一个最小实现。Observer 是抽象接口,ScreenDisplay 和 LogWriter 是具体观察者。Subject 用 std::vector 保存观察者指针,notify 方法遍历指针并调用虚函数 update。这种写法让 Subject 与具体观察者完全解耦。
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
class Observer {
public:
virtual ~Observer() = default;
virtual void update(const std::string& message) = 0;
};
class Subject {
std::vector<Observer*> observers_;
public:
void addObserver(Observer* obs) {
observers_.push_back(obs);
}
void removeObserver(Observer* obs) {
observers_.erase(
std::remove(observers_.begin(), observers_.end(), obs),
observers_.end()
);
}
void notify(const std::string& message) {
for (Observer* obs : observers_) {
obs->update(message);
}
}
};
class ScreenDisplay : public Observer {
public:
void update(const std::string& message) override {
std::cout << "screen: " << message << std::endl;
}
};
class LogWriter : public Observer {
public:
void update(const std::string& message) override {
std::cout << "log: " << message << std::endl;
}
};
int main() {
Subject subject;
ScreenDisplay screen;
LogWriter log;
subject.addObserver(&screen);
subject.addObserver(&log);
subject.notify("button clicked");
return 0;
}
可以看到,Subject::notify 完全依赖 Observer 的虚函数接口。增加新的观察者不需要修改 Subject,只要新类继承 Observer 并注册到 Subject 即可。虚函数在这里承担了事件回调的功能,它让回调可以面向对象地组织起来,而不只是传递一个函数指针。
使用观察者模式时要特别注意对象生命周期。如果观察者对象已经被销毁,但还留在 Subject 的观察者列表中,通知时就会访问悬空指针。通常需要在观察者析构前调用 removeObserver,或者使用弱引用机制。也正因为观察者可能通过基类指针删除,Observer 的虚析构函数必不可少。
五、工程实践:虚函数的代价与常见坑
虚函数调用需要通过 vtable 间接寻址,编译器通常不能对通过基类指针发生的虚调用做内联优化。与普通成员函数相比,每次调用多了一次或两次指针间接跳转。这个代价在纳秒级别,对绝大多数业务逻辑来说可以忽略。虚函数最重要的价值是消除大量条件分支、让结构更清晰。除非性能分析报告明确指出虚调用成为热点,否则不应该为了微小的性能提升而牺牲多态带来的可维护性。
更需要注意的其实是对象切片问题。将派生类对象按值赋值给基类对象时,派生类额外数据会被切掉,只剩下基类部分,之后调用虚函数不会进入派生类实现。例如把 CsvExporter 对象按值传给 ReportExporter 参数,就会产生切片,多态失效。正确做法是传递引用或指针,不要按值复制具有多态行为的对象。
如果基类需要支持多态删除,必须声明虚析构函数,否则通过基类指针删除派生类对象会导致未定义行为。对于明确不需要被继承的类,可以使用 final 阻止继承,某些情况下编译器还能进行去虚化优化。理解虚函数表和动态绑定机制,有助于在设计模式中合理地选择抽象层级,既不过度设计,也不牺牲扩展性。