13. Lambda 与回调函数:替代函数指针和 std::function,简化异步编程

回调函数,说白了就是「你告诉我怎么做,到时候我自己调用」。在 C++ 里,这东西历史悠久——从 C 时代的函数指针,到 C++11 的 std::function,再到如今的 lambda。我个人觉得,lambda 的出现让回调这件事变得前所未有的舒服。

你想想看,以前写回调,你得先定义一个全局函数,或者搞个仿函数类,代码散得到处都是。现在呢?一个 lambda 就地解决,干净利落。

13.1 回调的演变:从函数指针到 lambda

先看个最原始的例子。假设我们要给 std::sort 传一个比较器:

// 函数指针版本
bool compare(int a, int b) {
    return a > b;
}
std::sort(vec.begin(), vec.end(), compare);

这有什么问题?逻辑上没问题,但代码不内聚。比较逻辑和排序调用是分开的,阅读时要跳来跳去。

后来有了 std::function,稍微灵活了点:

std::function<bool(int, int)> comp = compare;
std::sort(vec.begin(), vec.end(), comp);

std::function 有运行时开销——它用到了类型擦除,内部有虚函数调用或小对象优化。我在项目中测过,频繁调用时性能差距很明显。

再后来,lambda 来了:

std::sort(vec.begin(), vec.end(), [](int a, int b) { return a > b; });

嗯,这才叫「原地解决」。编译器甚至可以直接内联这个 lambda,零开销。

13.2 Lambda 作为回调的天然优势

为什么 lambda 适合做回调?我总结了三点:

  • 捕获上下文:函数指针做不到捕获局部变量,lambda 可以。你想想看,异步回调里经常需要访问当前对象或局部状态,lambda 的捕获列表就是为此而生。
  • 就地定义:逻辑写在调用处,阅读代码时不用跳转。我习惯把回调逻辑直接写在 API 调用旁边,一目了然。
  • 类型安全:lambda 是编译器生成的匿名类型,模板实例化时完全内联,没有 std::function 的类型擦除开销。

核心结论:能用 lambda 的地方,优先用 lambda。函数指针和 std::function 只在需要类型擦除或二进制接口时考虑。

13.3 异步编程中的 lambda 回调

异步编程是 lambda 回调的主战场。比如用 std::async

std::future<int> result = std::async(std::launch::async, [this]() {
    // 模拟耗时操作
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return computeSomething();
});

// 继续做其他事情...

int value = result.get();  // 阻塞等待

这里 lambda 捕获了 this,可以访问成员变量和函数。如果是函数指针,你得额外传一个 void* 上下文参数,麻烦且容易出错。

再看一个更实际的例子——网络库的回调:

void startAsyncRead(Connection& conn) {
    conn.async_read([this](const std::vector<char>& data, ErrorCode ec) {
        if (ec) {
            handleError(ec);
            return;
        }
        processData(data);
        startAsyncRead(conn);  // 继续读取
    });
}

我曾经在项目里用这种模式写了一个高并发网络服务。lambda 捕获 thisconn 的引用,逻辑非常紧凑。如果用函数指针,你得额外维护一个回调上下文结构体,代码量翻倍。

13.4 避坑指南:lambda 回调的陷阱

lambda 虽好,但用不好也会翻车。我踩过几个坑,分享给你:

陷阱一:悬空引用

如果 lambda 捕获了局部变量的引用,而 lambda 在变量销毁后才执行,那就出事了。

// 错误示例
void scheduleTask() {
    int data = 42;
    timer.schedule([&data]() {
        std::cout << data;  // data 可能已经销毁!
    }, 1000);
}

解决方案:按值捕获,或者用 std::shared_ptr 管理生命周期。

陷阱二:this 指针失效

捕获 this 时,如果对象先于 lambda 被销毁,回调里访问成员就是未定义行为。

// 错误示例
class Worker {
    void start() {
        thread_pool.submit([this]() {
            doWork();  // 如果 Worker 对象被销毁,this 就悬空了
        });
    }
};

我建议用 std::enable_shared_from_this 配合 weak_ptr 来安全捕获:

class Worker : public std::enable_shared_from_this<Worker> {
    void start() {
        auto weak_self = weak_from_this();
        thread_pool.submit([weak_self]() {
            if (auto self = weak_self.lock()) {
                self->doWork();
            }
        });
    }
};

13.5 何时仍用 std::function?

lambda 虽好,但 std::function 也有不可替代的场景:

场景 推荐方案 原因
回调需要存储/传递 std::function lambda 类型不固定,无法直接存入容器
回调需要多次调用 lambda 或 std::function lambda 可复制,但 std::function 更灵活
性能敏感的热路径 lambda 模板 避免 std::function 的虚函数开销
C 语言接口回调 函数指针 ABI 兼容性要求

我个人习惯是:默认用 lambda,需要类型擦除时用 std::function,只有 C 接口才用函数指针

13.6 知识体系图

下面这张图总结了 lambda 回调的核心逻辑和替代关系:

Lambda 回调核心逻辑 函数指针 C 风格,无捕获 std::function 类型擦除,灵活但慢 Lambda 捕获上下文,零开销 演变 优化 应用场景 C 接口回调 函数指针唯一选择 异步任务回调 lambda 捕获上下文 事件驱动编程 std::function 存储 最佳实践 默认用 lambda → 需要类型擦除用 std::function → C 接口用函数指针 注意生命周期管理,避免悬空引用和 this 失效

13.7 实战建议

最后,给你几个我在项目中总结的实战建议:

  • 小回调用 lambda,大回调用命名函数:如果回调逻辑超过 10 行,我建议提取成命名函数或函数对象,保持 lambda 简洁。
  • 注意捕获开销:按值捕获大对象时会有拷贝开销。可以用 std::shared_ptrstd::ref 来避免。
  • 善用泛型 lambda:C++14 的泛型 lambda 可以接受任意类型参数,非常适合写通用回调。

小技巧:如果你不确定该用哪种方式,先写 lambda。编译器会帮你优化。只有遇到性能瓶颈或需要类型擦除时,再考虑换成 std::function

嗯,lambda 回调这块其实不难,关键是理解它的生命周期和捕获机制。多写几次,你就能找到感觉了。