lambda、捕获与生命周期:回调不是免费午餐 / Lambdas, Captures, and Lifetime
lambda 最容易被介绍成“现场写一个小函数”。
这没错,但不够。真正重要的是:lambda 是对象,它可能保存状态,而保存状态就会带来生命周期问题。
1. 没有捕获的 lambda 像小函数
看代码:
auto add = [](int a, int b) {
return a + b;
};
int result = add(1, 2);这段代码定义了一个可调用对象。
可以先把它理解成:
add 是一个对象
这个对象可以像函数一样被调用没有捕获时,它不保存外部变量。
所以它比较接近普通函数。
2. 有捕获的 lambda 是闭包对象
看代码:
int base = 10;
auto add_base = [base](int x) {
return x + base;
};[base] 表示按值捕获 base。
可以近似想成编译器生成了一个类:
struct Closure {
int base;
int operator()(int x) const {
return x + base;
}
};真实实现不要求完全这样。
但这个模型能解释两件事:
lambda 是对象
捕获的值会成为对象内部状态图:
add_base closure object
+------------------+
| base copy = 10 |
+------------------+
| operator()(x) |
+------------------+3. 按值捕获:保存副本
int value = 1;
auto f = [value] {
return value + 1;
};
value = 100;
int result = f(); // 通常还是 2因为 lambda 保存的是捕获时的副本。
它不是每次调用都重新读取外部 value。
如果想修改这个副本,需要 mutable:
auto counter = [value = 0]() mutable {
return ++value;
};这里 value = 0 是初始化捕获。
lambda 对象内部有自己的 value。
每次调用修改的是这个内部副本。
4. 按引用捕获:不拥有,只借用
按引用捕获:
int value = 1;
auto f = [&value] {
return value + 1;
};lambda 内部保存的不是独立副本。
它引用外部对象。
所以外部对象改变,lambda 看到的也会改变。
value = 100;
int result = f(); // 101这很方便,也很危险。
危险来自生命周期:
#include <functional>
std::function<int()> make_counter() {
int value = 0;
return [&value] {
return ++value;
};
}make_counter() 返回后,局部变量 value 已经销毁。
返回出去的 lambda 还引用它。
make_counter frame
+------------------+
| value = 0 |
+------------------+
^
|
lambda stores reference
after return
frame gone
lambda still stores reference to dead object这就是悬垂引用。
5. 返回 lambda 时优先按值捕获需要的状态
安全写法:
#include <functional>
std::function<int()> make_counter() {
return [value = 0]() mutable {
return ++value;
};
}这里 value 是 lambda 对象自己的成员。
make_counter() 返回后,lambda 对象仍然拥有这份状态。
这就是“捕获方式”和“闭包对象生命周期”之间的关系。
6. this 捕获尤其要小心
看代码:
struct Widget {
int value{42};
auto callback() {
return [this] {
return value;
};
}
};[this] 捕获的是当前对象指针。
如果 Widget 对象先销毁,回调后执行:
lambda object
|
v
this pointer
|
v
destroyed Widget object这也是悬垂。
更安全的设计取决于需求。
如果只需要当前值:
auto callback() {
int copy = value;
return [copy] {
return copy;
};
}如果需要共享对象生命周期,可能要用 shared_ptr/weak_ptr 设计。
但不要默认把 this 捕获丢进异步回调里。
7. 捕获默认值不要滥用
两种默认捕获:
[=] { } // 默认按值捕获用到的外部变量
[&] { } // 默认按引用捕获用到的外部变量它们写起来短,但大型代码里可能不清楚到底捕获了什么。
尤其 [&] 放进延迟执行、异步执行、保存到成员变量的回调时,要非常谨慎。
更可读的方式常常是显式列出:
[count, name] {
// ...
}或者:
[&output] {
// 明确知道只借用 output
}8. 泛型 lambda 和模板
现代 C++ 支持:
auto add = [](auto a, auto b) {
return a + b;
};这个 lambda 的调用运算符本质上像模板。
也就是说:
add(1, 2)
实例化一份适合 int 的调用
add(std::string{}, std::string{})
实例化另一份适合 string 的调用如果某个类型不支持 a + b,错误会在调用时出现。
所以泛型 lambda 和模板一样,也要考虑约束和错误信息。
9. lambda 转成函数指针
没有捕获的 lambda 可以转换成函数指针:
int (*fp)(int, int) = [](int a, int b) {
return a + b;
};有捕获的 lambda 不行:
int base = 10;
// int (*fp)(int) = [base](int x) { return x + base; }; // 错原因是有捕获的 lambda 需要保存状态。
普通函数指针只能指向代码入口,不能保存对象状态。
10. 本篇总结
lambda 的核心模型:
lambda expression
|
v
closure object
|
+-- captured state
|
+-- operator()每次写 lambda 都问:
它捕获了什么?
按值还是按引用?
lambda 对象会活多久?
被引用对象会活多久?
是否捕获 this?
是否会被异步执行或保存起来?lambda 不是危险语法。
真正危险的是:回调活得比它引用的对象更久。