Lambda 表达式凭借简洁优雅的语法,成了现代 C++ 最受欢迎的特性之一。
但好用不代表安全。有时候,一个运行了好几天的进程突然 coredump,即使 gdb 调试也看不出问题在哪。问题很可能就藏在你不经意的捕获方式里。
快速回顾
先快速回顾一下 Lambda 的几种捕获方式,方便大家进入下面的内容:
int x = 42;
std::string name = "hello";
auto by_value = [x, name]() {};
auto by_ref = [&x, &name]() {};
auto all_val = [=]() {};
auto all_ref = [&]() {};
简单总结如下:
| 捕获 |
含义 |
[x] |
拷贝 x |
[&x] |
引用 x |
[=] |
拷贝所有用到的变量 |
[&] |
引用所有用到的变量 |
带着这个直觉,我们开始下面的踩坑之旅。
坑一:成员函数内使用 [=]
示例如下:
class Server {
std::string host_ = "localhost";
int port_ = 8080;
public:
auto handler() {
return [=]() {
std::cout << host_ << ":" << port_;
};
}
};
很多人以为捕获的是 host_ 和 port_ 的副本。实际上,此处捕获的是 this 指针。
看下面这个用法:
Server server;
auto handler = server.get_handler();
// server 对象销毁
handler(); // 指针悬空,未定义行为
我们可以用下面这种方法避免:
auto get_handler() {
return [host = host_, port = port_]() {
std::cout << host << ":" << port << "\n";
};
}
或者用这个(C++17 起支持 [*this]):
auto get_handler() {
return [*this]() {
std::cout << host_ << ":" << port_ << "\n";
};
}
坑二:引用捕获的悬空陷阱
常见用法如下:
std::function<int()> make_counter() {
int count = 0;
return [&count]() {
return ++count;
};
}
auto counter = make_counter();
counter();
当我们调用 make_counter() 后,其栈帧销毁:Lambda 持有 &count → 已释放的栈内存。结果就是未定义行为。
项目中更隐蔽的场景:
void setup() {
auto config = load_config();
register_callback([&]() {
use(config); // 稍后执行 → config 已死
});
}
也就是说,只要涉及回调、线程、协程、容器、返回值等场景,就应该禁用引用捕获,改用按值捕获:
register_callback([config]() { use(config); });
坑三:Move 捕获与 mutable
auto ptr = std::make_unique<Widget>();
auto lambda = [p = std::move(ptr)]() {
p->do_thing();
};
上面代码一切正常,但如果再加上其他操作:
auto lambda = [p = std::make_unique<Widget>()]() {
p->do_thing(); // OK,只能调用 const 成员函数
p = nullptr; // 编译失败
p.reset(); // 编译失败
transfer(std::move(p)); // 编译失败
};
上述代码编译失败,这是因为 Lambda 的 operator() 默认是:
void operator()() const;
即捕获变量 = const 成员。可以这样解决:
auto f = [p = std::make_unique<Foo>()]() mutable {
p.reset();
};
需要注意的是,使用 mutable 会改变其内部状态:
auto counter = [n = 0]() mutable { return ++n; };
std::cout << counter(); // 1
std::cout << counter(); // 2
std::cout << counter(); // 3
坑四:复制 mutable Lambda 的状态独立
auto counter = [n = 0]() mutable { return ++n; };
auto copy = counter;
counter(); // 1
counter(); // 2
copy(); // 1 不是 3
这是因为每个 Lambda 都有自己的状态副本。
项目中常见的一种用法:
std::function<int()> fn = counter; // 内部复制
我们可能以为 fn 和 counter 共享状态,其实并不是,它们是完全独立的对象。
如果需要共享状态,可以这样:
auto n = std::make_shared<int>(0);
auto counter = [n]() { return ++*n; };
这样通过智能指针实现了真正的状态共享。在云栈社区的技术交流中,经常有开发者在这里踩坑,尤其是涉及多线程回调时更需注意。
坑五:结构化绑定的捕获(C++17 vs C++20)
auto [x, y] = std::make_pair(1, 2);
// C++17: can't capture structured bindings
// C++20: allowed
auto lambda = [x, y]() { return x + y; };
C++20 终于允许在 Lambda 中捕获结构化绑定,但它的行为取决于绑定本身是不是引用,这点非常容易误解。
也就是说:
std::pair<int, int> some_pair{1, 2};
auto [a, b] = some_pair;
auto lambda = [a, b]() {
return a + b;
};
a 和 b 是普通变量,Lambda 捕获的是它们的副本。上述代码等价于:
auto lambda = [a_copy = a, b_copy = b]() {
return a_copy + b_copy;
};
而对于引用绑定:
std::pair<int, int> some_pair{1, 2};
auto& [a, b] = some_pair; // 注意这里的 &
auto lambda = [a, b]() {
return a + b;
};
a 和 b 本身就是引用,Lambda 捕获的是引用的副本。那么如果 some_pair 先销毁:
auto make_lambda() {
std::pair<int, int> p{1, 2};
auto& [a, b] = p;
return [a, b]() { return a + b; }; // ← UB
}
这就会导致悬空引用。
坑六:捕获参数包(C++20 的 Pack Capture)
C++20 之前的版本中,不支持直接捕获参数包,必须用其他方式封装:
template <typename... Args>
auto wrap(Args&&... args) {
auto lambda =
[tup = std::make_tuple(std::forward<Args>(args)...)]() mutable
{
std::apply(
[](auto&... xs) {
(process(std::move(xs)), ...);
},
tup
);
};
return lambda;
}
自 C++20 起,引入 pack capture,上面代码可以简化为:
template <typename... Args>
auto wrap(Args... args) {
auto lambda = [...args = std::move(args)]() {
(process(args), ...);
};
return lambda;
}
坑七:默认捕获与显式捕获混合
来看一个合法示例:
int a = 1, b = 2, c = 3;
// 除 a 外全部引用捕获
auto lambda = [&, a]() {
// a → 值捕获
// b → 引用捕获
// c → 引用捕获
};
// 除 b 外全部值捕获
auto lambda2 = [=, &b]() {
// a → 值捕获
// b → 引用捕获
// c → 值捕获
};
如果需要捕获的变量很多,比如几十个:
auto lambda = [&, config, timeout]() {
send_request(
socket,
host,
port,
buffer,
parser,
handler,
config,
timeout);
};
这时,你不得不仔细想清楚:哪个是引用捕获?哪个是值捕获?
针对这种情况,建议使用显式捕获:
auto lambda =
[&socket,
&buffer,
config,
timeout]()
{
socket.send(buffer, config, timeout);
};
结语
Lambda 捕获是那种「语法简单、坑极深」的特性。[=] 看着安全,[&] 看着方便,却常常在生产环境、高并发下出现意想不到的问题。
解决方案其实很朴素:显式捕获。
当然,最最最重要的是记住:成员函数中的 [=] 捕获的是 this,而不是成员变量。
以上。
如果对本文有疑问,欢迎到云栈社区 C/C++ 板块一起讨论交流,那里有更多关于智能指针、移动语义、模板元编程等深度话题。