替代方案对比:函数指针与虚函数、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. 综合对比与选型建议

嗯,到这里四种方案都过了一遍。咱们用一张图来总结一下它们的关系:

多态实现方案对比 函数指针 C风格,手动管理 虚函数 运行期多态 CRTP 编译期多态 std::variant 封闭式多态 性能(从高到低) CRTP 函数指针 虚函数 std::function 灵活性(从低到高) CRTP std::variant 虚函数 std::function 没有银弹,根据场景选择最合适的方案

选型其实没那么复杂,我个人的经验是:

  • 需要运行时多态、类型可扩展 → 虚函数。这是最通用的方案,90%的场景都适用。
  • 性能敏感、类型固定 → CRTP。比如游戏引擎的组件系统、数值计算库。
  • 回调、事件驱动 → std::function。配合lambda用起来很舒服。
  • 类型集合封闭、不想用继承 → std::variant + 访问者。比如解析固定协议、状态机。
  • 嵌入式、内核开发 → 函数指针。但要做好封装,别裸写。

一个小技巧:如果你不确定选哪个,先上虚函数。等性能分析工具告诉你这里是瓶颈,再考虑优化。我见过太多人「过早优化」,用CRTP写了一大堆模板,结果需求一变,改得想哭。

好了,这几种方案的核心区别和适用场景就聊到这儿。记住一点:多态只是手段,不是目的。选方案的时候,多想想「这个代码三个月后还有人能看懂吗?」——嗯,这个问题往往比性能更重要。


公众号:蓝海资料掘金营,微信deep3321