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 捕获 this 和 conn 的引用,逻辑非常紧凑。如果用函数指针,你得额外维护一个回调上下文结构体,代码量翻倍。
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 回调的核心逻辑和替代关系:
13.7 实战建议
最后,给你几个我在项目中总结的实战建议:
- 小回调用 lambda,大回调用命名函数:如果回调逻辑超过 10 行,我建议提取成命名函数或函数对象,保持 lambda 简洁。
- 注意捕获开销:按值捕获大对象时会有拷贝开销。可以用
std::shared_ptr或std::ref来避免。 - 善用泛型 lambda:C++14 的泛型 lambda 可以接受任意类型参数,非常适合写通用回调。
小技巧:如果你不确定该用哪种方式,先写 lambda。编译器会帮你优化。只有遇到性能瓶颈或需要类型擦除时,再考虑换成 std::function。
嗯,lambda 回调这块其实不难,关键是理解它的生命周期和捕获机制。多写几次,你就能找到感觉了。