替代方案对比:函数指针与虚函数、std::function与虚函数、CRTP、std::variant与访问者模式
多态,说白了就是「同一接口,不同实现」。C++里实现多态的路子可不止虚函数一条。我在项目里见过不少团队,一提到多态就只想到虚函数,其实有些场景下换条路走,代码反而更清爽、性能更好。
今天咱们就把这几种方案摆在一起,掰开揉碎地对比一下。你想想看,面试时被问到「虚函数的替代方案」,能说出三四种,是不是比只背个虚函数表要强得多?
1. 函数指针 vs 虚函数
函数指针是最原始的多态手段。C语言时代没有虚函数,大家不也活得好好的?
// 函数指针版本
struct Animal {
void (*speak)(void* self);
void (*destroy)(void* self);
};
struct Dog {
Animal base;
const char* name;
};
void dog_speak(void* self) {
Dog* d = (Dog*)self;
printf("%s says: Woof!\n", d->name);
}
// 虚函数版本
struct AnimalBase {
virtual void speak() = 0;
virtual ~AnimalBase() = default;
};
struct DogV : AnimalBase {
std::string name;
void speak() override {
std::cout << name << " says: Woof!\n";
}
};
| 对比维度 | 函数指针 | 虚函数 |
|---|---|---|
| 调用开销 | 一次间接跳转 | 两次间接跳转(查vptr + 查vtable) |
| 内存占用 | 每个对象多一个指针 | 每个对象多一个vptr(通常8字节) |
| 类型安全 | 弱(void* 强转) | 强(编译器检查) |
| 可读性 | 差(手动管理) | 好(语法自然) |
我的建议:除非你在写嵌入式或内核代码,否则别用函数指针模拟多态。虚函数那点性能损失,在现代CPU上几乎可以忽略不计。我曾经在一个低功耗IoT项目里用函数指针替代虚函数,省了不到2KB的ROM,但维护成本翻了三倍——不值当。
2. std::function vs 虚函数
std::function 是C++11引入的「多态函数包装器」。它跟虚函数的区别,说白了就是一个包装行为,一个包装对象。
// std::function 版本
struct Processor {
std::function<int(int)> handler;
};
// 可以传入各种可调用对象
Processor p1{[](int x) { return x * 2; }};
Processor p2{std::bind(&some_func, _1)};
// 虚函数版本
struct ProcessorBase {
virtual int handle(int x) = 0;
};
| 对比维度 | std::function | 虚函数 |
|---|---|---|
| 灵活性 | 极高(lambda、函数指针、bind) | 中等(只能通过继承) |
| 调用开销 | 较高(小对象优化 + 虚函数 + 可能的内存分配) | 较低(两次间接跳转) |
| 类型擦除 | 完全擦除 | 部分擦除(基类指针) |
| 适用场景 | 回调、事件驱动 | 对象体系、接口设计 |
避坑指南:我曾经把一个高频回调函数从虚函数改成std::function,结果性能下降了30%。原因是std::function内部做了小对象优化检查,每次调用都有分支判断。如果你的回调函数在热路径上,建议用虚函数或裸函数指针。
3. CRTP(奇异递归模板模式)
CRTP 是模板元编程里的经典技巧。它用编译期多态替代运行期多态,说白了就是「让基类知道派生类的类型」。
template <typename Derived>
struct AnimalBase {
void speak() {
static_cast<Derived*>(this)->speak_impl();
}
};
struct Dog : AnimalBase<Dog> {
void speak_impl() {
std::cout << "Woof!\n";
}
};
struct Cat : AnimalBase<Cat> {
void speak_impl() {
std::cout << "Meow!\n";
}
};
| 对比维度 | CRTP | 虚函数 |
|---|---|---|
| 调用开销 | 零(编译期解析) | 有(运行期间接跳转) |
| 内存占用 | 无额外开销 | 每个对象多一个vptr |
| 类型擦除 | 不支持(必须知道具体类型) | 支持(基类指针) |
| 代码膨胀 | 有(模板实例化) | 无 |
我的经验:CRTP 最适合「模板方法模式」——就是基类定义算法骨架,派生类填充细节。我在一个高性能计算库中,用CRTP替代虚函数实现了向量运算的多态,性能提升了40%。但代价是:所有类型必须在编译期确定,没法做运行时多态容器。
4. std::variant 与访问者模式
std::variant 是C++17的「类型安全联合体」。配合访问者模式,可以实现一种「封闭式多态」——所有可能的类型都在编译期确定。
using Animal = std::variant<Dog, Cat>;
struct SpeakVisitor {
void operator()(const Dog& d) { d.speak(); }
void operator()(const Cat& c) { c.speak(); }
};
Animal a = Dog{};
std::visit(SpeakVisitor{}, a); // 输出 Woof!
| 对比维度 | std::variant + 访问者 | 虚函数 |
|---|---|---|
| 类型集合 | 封闭(编译期固定) | 开放(运行时扩展) |
| 调用开销 | 索引跳转(类似switch) | 两次间接跳转 |
| 内存布局 | 连续(栈上) | 分散(堆上) |
| 扩展性 | 差(新增类型需改所有访问者) | 好(新增派生类即可) |
注意:std::variant 的访问者模式有个经典问题——「表达式爆炸」。如果你有N种类型、M种操作,就得写N×M个重载。我曾经在一个协议解析模块里用了variant,结果新增一种消息类型,改了7个访问者。后来我改用虚函数,反而更省事。
5. 综合对比与选型建议
嗯,到这里四种方案都过了一遍。咱们用一张图来总结一下它们的关系:
选型其实没那么复杂,我个人的经验是:
- 需要运行时多态、类型可扩展 → 虚函数。这是最通用的方案,90%的场景都适用。
- 性能敏感、类型固定 → CRTP。比如游戏引擎的组件系统、数值计算库。
- 回调、事件驱动 → std::function。配合lambda用起来很舒服。
- 类型集合封闭、不想用继承 → std::variant + 访问者。比如解析固定协议、状态机。
- 嵌入式、内核开发 → 函数指针。但要做好封装,别裸写。
一个小技巧:如果你不确定选哪个,先上虚函数。等性能分析工具告诉你这里是瓶颈,再考虑优化。我见过太多人「过早优化」,用CRTP写了一大堆模板,结果需求一变,改得想哭。
好了,这几种方案的核心区别和适用场景就聊到这儿。记住一点:多态只是手段,不是目的。选方案的时候,多想想「这个代码三个月后还有人能看懂吗?」——嗯,这个问题往往比性能更重要。
公众号:蓝海资料掘金营,微信deep3321