18. std::function 类型擦除:统一存储可调用对象,运行时多态

好,咱们今天聊一个非常实用的东西——std::function

说实话,我刚开始用C++那会儿,对这东西有点抵触。总觉得“不就是个函数指针的升级版吗?有必要搞这么复杂?”后来在项目中吃了不少亏,才明白它真正的价值在哪。

std::function 的核心能力,说白了就四个字:类型擦除

什么意思?就是你不用管一个可调用对象到底是什么类型——是函数指针、lambda、还是函数对象——你只需要知道它“能调用”,并且有统一的签名就行。剩下的脏活累活,std::function 帮你扛了。

18.1 什么是类型擦除?

类型擦除,就是把具体类型的信息隐藏起来,只暴露一个统一的接口。

你想想看,lambda 表达式每个都是不同的类型。哪怕两个 lambda 长得一模一样,编译器也会给它们生成不同的类型。这就麻烦了——你想把它们存到一个容器里?不行。你想把它们作为参数传给一个函数?得用模板。你想在运行时动态决定调用哪个?更没门。

std::function 就是来解决这个问题的。它把各种可调用对象“包”起来,抹掉它们的具体类型,只保留一个统一的调用接口。

核心思想: 编译期类型 → 运行时多态。把类型信息从编译期“擦除”,在运行时通过虚函数机制来调用。

18.2 基本用法

先看个最简单的例子:

#include <functional>
#include <iostream>

void hello() {
    std::cout << "Hello from function!\n";
}

int main() {
    // 存储一个普通函数
    std::function<void()> f1 = hello;
    f1();  // 输出: Hello from function!

    // 存储一个 lambda
    std::function<int(int, int)> f2 = [](int a, int b) {
        return a + b;
    };
    std::cout << f2(3, 4) << '\n';  // 输出: 7

    // 存储一个函数对象
    struct Adder {
        int operator()(int a, int b) const {
            return a + b;
        }
    };
    std::function<int(int, int)> f3 = Adder{};
    std::cout << f3(10, 20) << '\n';  // 输出: 30

    return 0;
}

看到了吗?f1f2f3 的类型都是 std::function<void()>std::function<int(int, int)>。不管背后是什么,调用方式完全一样。

18.3 类型擦除的内部机制

嗯,这里要稍微深入一点。我当年面试的时候被问过这个问题,答得不太好,后来专门去翻了源码。

std::function 内部大致是这样工作的:

  1. 它有一个 小对象优化(Small Object Optimization, SOO)的缓冲区。如果可调用对象比较小(比如一个无捕获的 lambda),就直接存在栈上,避免堆分配。
  2. 如果对象太大,就放到堆上。
  3. 它内部维护一个 虚函数表 风格的指针,指向一组操作:拷贝、移动、销毁、调用。
  4. 当你调用 f(args...) 时,它通过这个虚函数表找到真正的调用函数,把参数转发过去。

说白了,std::function 自己就是一个函数对象,它内部用虚函数实现了运行时多态。

小技巧: 如果你存的是无捕获的 lambda(可以隐式转换为函数指针),std::function 的开销非常小。我一般优先用这种形式。

18.4 什么时候用 std::function?

我个人习惯,在以下几种场景会毫不犹豫地用 std::function

  • 回调注册:比如事件系统、观察者模式。你需要存一堆回调,运行时决定调用哪个。
  • 策略模式:运行时切换算法。比如排序时传入不同的比较器。
  • 延迟执行:把任务存起来,稍后执行。
  • 跨 API 边界:库函数需要接受用户自定义的可调用对象,但又不想用模板(模板会增加编译时间、暴露实现)。

举个例子,我在一个网络库中用过:

class EventLoop {
public:
    using Task = std::function<void()>;

    void postTask(Task task) {
        tasks_.push_back(std::move(task));
    }

    void run() {
        for (auto& task : tasks_) {
            task();  // 统一调用
        }
    }

private:
    std::vector<Task> tasks_;
};

// 使用
EventLoop loop;
loop.postTask([]() { std::cout << "Task 1\n"; });
loop.postTask([]{ std::cout << "Task 2\n"; });
loop.run();

你看,Task 的类型是统一的,但实际执行的任务可以完全不同。这就是类型擦除的威力。

18.5 性能考量

别以为 std::function 是万能的。它是有代价的。

对比项 std::function 模板 + 函数指针 直接调用 lambda
调用开销 虚函数调用(间接) 直接调用 直接调用(可内联)
内存开销 至少 32-64 字节(含 SOO) 8 字节(函数指针) 取决于捕获
类型灵活性 极高
编译时间 高(模板实例化)
注意: 在性能敏感的循环中,尽量避免使用 std::function。我曾经在一个高频交易系统中发现,把 std::function 换成模板 + 函数指针,性能提升了 30%。虚函数调用 + 无法内联,在热点路径上是很痛的。

18.6 避坑指南

我曾经踩过一个坑:把 lambda 捕获了 this 指针,然后存到 std::function 里,结果对象销毁后调用这个 function,直接崩溃。

class Widget {
public:
    void registerCallback() {
        // 危险!捕获了 this,但 this 可能被销毁
        callback_ = [this]() {
            doSomething();  // 如果 Widget 已销毁,这里就 UB 了
        };
    }

    void doSomething() { /* ... */ }

private:
    std::function<void()> callback_;
};

正确的做法是:要么确保生命周期,要么用 std::weak_ptr 或传值捕获。

// 安全做法:用 shared_from_this()
class Widget : public std::enable_shared_from_this<Widget> {
public:
    void registerCallback() {
        auto self = shared_from_this();
        callback_ = [self]() {
            self->doSomething();  // 安全
        };
    }
};

18.7 与 std::bind 的关系

很多人喜欢用 std::bind 配合 std::function。我个人不太推荐 std::bind,因为 lambda 更清晰、更现代。

// 不推荐
std::function<int(int)> f = std::bind(std::plus<int>(), 10, std::placeholders::_1);

// 推荐
std::function<int(int)> f = [](int x) { return x + 10; };

lambda 的可读性明显更好。而且 lambda 的捕获机制更灵活,不容易出错。

18.8 知识体系图

下面这张图帮你理清 std::function 在整个可调用对象生态中的位置:

std::function 类型擦除知识体系 std::function 可调用对象(输入) 函数指针 Lambda 表达式 函数对象 std::bind 结果 应用场景(输出) 回调注册 策略模式 延迟执行 跨 API 边界 内部机制:小对象优化 + 虚函数表 + 类型擦除 编译期抹去具体类型 → 运行时通过虚函数调用

18.9 总结

std::function 是 C++ 中实现类型擦除的利器。它让你可以统一存储和调用各种可调用对象,在运行时实现多态。

但记住:它是有代价的。虚函数调用、可能的堆分配、无法内联——这些在性能敏感的场景下要慎重。

我个人建议:

  • 在回调、事件、策略等场景,放心用 std::function
  • 在热点路径、高频调用处,考虑用模板或函数指针替代。
  • 永远注意生命周期问题,别让 std::function 持有悬空指针。

好了,这一章就到这里。记住一句话:类型擦除让你在运行时忘记类型,但别忘了性能和安全


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