捕获列表是整个 lambda 里唯一「没有默认值可选」的部分:你必须 明确写出 []、[x]、[&x] 还是 [=]。它决定了三件事 ------ 闭包能不能活过被捕获变量的生命周期、闭包对象占多少字节、以及调用时看到的是「定义那一刻的快照」还是「变量的实时值」。这三件事全是踩坑高发区,本文用真跑的输出把它们一个个钉死。
1. 引子:闭包活过了变量的生命周期
最典型的现场是这样的:一个函数把 lambda 返回出去,lambda 里用引用捕获了函数内的局部变量 ------ 编译通过、调用不报错、结果是垃圾值。因为引用捕获只记地址,不接管生命周期 ;函数一返回,那块栈内存就作废了。这就是悬垂引用(dangling reference),也是 [&] 被称为「最危险的捕获方式」的原因。
要理解它,得先把捕获这一整套语义理顺。
官方文档:Lambda expressions --- cppreference(捕获列表部分)
2. 全部捕获形式:一张表看完
标准里一共这几种写法,前 8 行是 C++11 就有的,最后一行是 C++14 加的:
| 写法 | 捕获了什么 | 闭包大小(x86-64) | 调用时看到的值 | 主要风险 |
|---|---|---|---|---|
[] |
什么都不捕获 | 1 字节(空类) | 只能访问全局 / constexpr |
无,最安全 |
[x] |
把 x 拷贝成成员 |
sizeof(x),含 padding |
定义时的快照 | 拷贝成本;原变量后续改动看不到 |
[&x] |
只存 &x(就是个指针) |
8 字节 | 变量的实时值 | 悬垂;跨线程数据竞争 |
[=] |
函数体里用到的所有变量按值 | 用到的变量之和 | 快照 | 在成员函数里会隐式捕获 this(C++20 起直接禁止) |
[&] |
函数体里用到的所有变量按引用 | 8 字节 × 个数 | 实时值 | 最容易出悬垂;光看捕获列表不知道它依赖谁 |
[this] |
this 指针 |
8 字节 | 成员变量的实时值 | 对象先销毁则悬垂 |
[*this](C++17) |
整个对象的副本 | sizeof(对象) |
快照 | 拷贝成本;对象很大时很贵 |
[x, &y] |
混合,逐个指定 | 两者之和 | 逐个决定 | 需要逐个核对生命周期 |
[z = expr](C++14) |
用 expr 初始化一个新成员 z |
sizeof(z) |
快照 | 闭包可能变得不可拷贝 ,进不了 std::function |
三条选择原则,先记住能用一辈子:
- 优先显式逐个捕获,别用
[=]/[&]。理由不是「风格洁癖」,而是[&]让读代码的人无法从捕获列表看出这个闭包依赖谁 ------ 他必须读完函数体才知道。生命周期审查的难度就是这么上来的。 - 闭包要活过当前作用域,就一律用值捕获(或者初始化捕获)。
- 引用捕获只在「闭包的生命周期严格短于被捕获变量」时才安全 ------ 典型是就地传给
std::sort、std::for_each这种同步调用。
官方文档:C++ Core Guidelines --- F.53 与其捕获引用,不如捕获值(Lambdas 一节)
3. 捕获时机是「定义时」,不是「调用时」
这是理解值 / 引用差异的钥匙:捕获发生在 lambda 表达式被求值的那一刻,也就是闭包对象被构造出来的时候。之后无论原变量怎么改,值捕获的那份副本都不再变:
cpp
#include <cstdio>
int main() {
int x = 10;
const auto by_val = [x] { return x; }; // 定义时就把 x 拷了一份
const auto by_ref = [&x] { return x; }; // 只记住地址,不拷贝
x = 99; // 之后修改原变量
std::printf("值捕获看到 = %d\n", by_val());
std::printf("引用捕获看到 = %d\n", by_ref());
return 0;
}
text
值捕获看到 = 10
引用捕获看到 = 99
by_val() 永远返回 10 ------ 它在 [x] 求值时就锁定了。by_ref() 返回 99,因为它每次调用都去 x 现在住的地方取值。
推论 :如果想「先捕获、后面再更新」,值捕获做不到,只能引用捕获(或者用 mutable 改那份副本,见《泛型 lambda 与 mutable》)。工程上更稳的做法是把要带的状态显式塞进闭包,而不是依赖外部变量的后续变化。
4. 引用捕获的共享语义与闭包大小
引用捕获还有个常被忽略的性质:多个闭包捕获同一个引用时,它们共享同一份状态。这在做「回调 + 查询」这类组合时很好用,但也意味着状态的生命周期完全由调用方负责:
cpp
#include <cstdio>
int main() {
int counter = 0;
// 两个闭包通过同一个引用共享 counter
const auto inc = [&counter] { ++counter; };
const auto get = [&counter] { return counter; };
inc();
inc();
inc();
std::printf("共享计数 = %d\n", get());
// 值捕获:每个闭包自己存一份副本
const auto own = [counter] { return counter; };
std::printf("值捕获快照 = %d\n", own());
std::printf("引用捕获大小 = %zu 字节(就是一个指针)\n", sizeof(inc));
std::printf("值捕获大小 = %zu 字节(就是一个 int)\n", sizeof(own));
return 0;
}
text
共享计数 = 3
值捕获快照 = 3
引用捕获大小 = 8 字节(就是一个指针)
值捕获大小 = 4 字节(就是一个 int)
从内存角度解释「哪种更优」 :引用捕获的闭包恒为 8 字节(一个指针),值捕获则是被捕获对象大小之和。看起来引用捕获更省,但这是拿生命周期安全性换的:
| 角度 | 值捕获 [x] |
引用捕获 [&x] |
|---|---|---|
| 闭包大小 | sizeof(x)(大对象会明显变大,还可能有 padding) |
固定 8 字节(一个指针) |
| 拷贝次数 | 捕获时 1 次(大对象这是实打实的成本) | 0 次 |
| 缓存局部性 | 副本就在闭包对象里,访问时命中的是同一块内存 | 每次调用都要解引用一个指针去访问别处的内存,多一次访存 |
| 生命周期 | 与外部变量解耦,安全 | 强绑定,外部先死就是悬垂 |
| 能放进容器/跨线程传递 | 可以(只要被捕获类型可拷贝) | 危险,几乎一定会出问题 |
结论很直白:小对象(int / double / 指针)直接用值捕获,省心且不多花什么;大对象(std::string / 大数组)用值捕获时心里要清楚这是一次深拷贝,或者用 std::shared_ptr 捕获 。至于 sizeof(std::string) 是 32 字节这种「捕获一个字符串要拷 32 字节 + 一次堆分配」的成本,只有真拿它去对比才会记住。
5. 危险点一:引用捕获 + 逃出作用域 = 悬垂引用
把引用捕获的闭包返回出去,就是开头说的那个现场。写成代码是这么回事:
cpp
#include <functional>
// 片段:悬垂引用 ------ 千万不要这么写(反例,违反了「捕获引用不如捕获值」)
std::function<int()> make_bad() {
int local = 42;
return [&local] { return local; }; // 闭包只存了 &local
} // ← local 在这里析构,指针立刻失效
// 正确写法:值捕获,副本跟着闭包一起走
std::function<int()> make_good() {
int local = 42;
return [local] { return local; }; // 闭包里有自己的 int
}
栈上到底发生了什么:
text
make_bad() 执行中 make_bad() 返回后
┌──────────────────────────┐
│ 栈帧 A │ 栈帧 A 已被回收,那块内存
│ local = 42 (4 字节) │ 现在住着 main 的其它临时数据
│ ↑ │
└───┼──────────────────────┘
│
│ 闭包对象(在堆上,被 std::function 持有)
│ ┌────────────────────────────┐
└──┤ p = 0x7ffd6a3c (8 字节) ↓│ ← 指向已销毁的栈帧
└────────────────────────────┘
调用 bad() 时读 *p → 垃圾值(甚至直接崩)
make_good() 的闭包 ┌────────────────────────────┐
│ local = 42 (自己的副本) │ ← 副本在,闭包就安全
└────────────────────────────┘
这是个真能跑通的错误代码 :语法合法、能编译、调用时也不报错。我在 gcc 13.2 + -O2 下跑过一次原始版本(带 main() 的完整程序),bad() 打印出的是 30486 这种垃圾数字,good() 稳稳是 42。换成别的编译选项、别的机器,这个数字会变,也可能直接段错误 ------ 这就是未定义行为(undefined behavior)的样子:它不保证报错,只保证你不可依赖结果。
官方文档:std::function --- cppreference:注意它持有的是闭包的副本,所以「闭包生命周期 > 变量生命周期」的情况必须靠值捕获解决。
工程上怎么防:
- 一旦发现闭包要被存起来 (进容器、进
std::function、作为成员、丢给线程),捕获列表就只允许值捕获 /[*this]/ 初始化捕获。 - 就地同步调用(
std::sort的比较器、std::for_each的回调)用[&]是安全且高效的,这也是唯一推荐用[&]的场景。 - 用
-Wshadow、-Wdangling-*这类警告只是辅助;生命周期要靠捕获列表自己写对,编译器不一定救你(上面这段 gcc 13 并没有给出任何警告)。
6. 危险点二:[=] 在成员函数里会隐式捕获 this
[=] 的官方语义是「按值捕获用到的变量」,但它有一个历史遗留的例外:在成员函数里,this 指针是按值捕获的(指针按值拷贝,自然就指向原对象)。也就是说:
cpp
// 片段:C++17 里合法、但语义容易误判
class Widget {
int data_ = 0;
public:
auto make_bad() const {
return [=] { return data_ + 1; }; // 实际捕获的是 this,不是 data_ 的副本!
}
};
看起来像「拷了一份 data_」,实际是「拷了一个 this,闭包随时能读到对象的最新值」------ 也就意味着对象一销毁,这个闭包就是悬垂的 。C++20 把这个隐患堵上了:[=] 不再隐式捕获 this,要捕获就必须写出来([=, this] 或 [this]),或者干脆用 C++17 就有的 [*this] 拷贝整个对象。
下面这段需要 C++20 ([=, this] 的写法是 C++20 才合法的),代码块第一行标了 // verify: std=c++20:
cpp
// verify: std=c++20
#include <cstdio>
class Counter {
public:
explicit Counter(int start) : base_{start} {}
// [*this](C++17):把整个对象拷进闭包,闭包与原对象生命周期无关
auto snapshot() const { return [*this] { return base_ + 1; }; }
// [=, this](C++20):显式捕获 this 指针,闭包看到的是原对象的实时状态
auto live() const { return [=, this] { return base_ + 1; }; }
void add(int d) { base_ += d; }
private:
int base_;
};
int main() {
Counter c{100};
const auto snap = c.snapshot();
const auto live = c.live();
c.add(5); // 原对象 +5
std::printf("[*this] 快照 = %d\n", snap());
std::printf("[=,this] 实时 = %d\n", live());
return 0;
}
text
[*this] 快照 = 101
[=,this] 实时 = 106
snap() 定格在 100 + 1,live() 跟着对象涨到了 105 + 1。这就是「拷贝对象」和「持有指针」的区别,和值捕获 / 引用捕获是同构的两件事。
| 写法 | 标准 | 捕获内容 | 闭包大小 | 对象销毁后调用 |
|---|---|---|---|---|
[=](成员函数内) |
C++17 及以前 | 隐式包含 this |
8 字节起 | 悬垂,UB |
[=, this] / [this] |
C++20 起 [=,this] 合法 |
显式 this 指针 |
8 字节起 | 悬垂,UB |
[*this] |
C++17 起可用 | 整个对象副本 | sizeof(对象) |
安全(副本仍在) |
这就是「迁移到 C++20」时最容易撞上的一类编译错误 :原来写着 [=] 的成员函数 lambda,升到 C++20 后要么补上 this,要么改成 [*this] ------ 选哪个取决于你到底想要「实时视图」还是「快照」。
7. 初始化捕获:唯一能把不可拷贝对象移进 lambda 的办法
C++14 的初始化捕获(init-capture,也叫广义捕获 generalized capture)语法是 [名字 = 表达式]:左边是一个新引入的成员名 (可以和外面不同名),右边是任意表达式。它的关键价值是 ------ 可以 move:
cpp
#include <cstdio>
#include <memory>
#include <utility>
int main() {
auto ptr = std::make_unique<int>(7);
// 初始化捕获:把 unique_ptr 移进闭包 ------ 值捕获做不到(它不可拷贝)
const auto take = [p = std::move(ptr)] { return *p; };
std::printf("闭包里的值 = %d\n", take());
std::printf("外层 ptr 还在吗?%s\n", ptr ? "在" : "不在(已被移走)");
// 初始化捕获的右边可以是任意表达式,不限于现有变量
const int n = 3;
const auto square = [m = n * n] { return m; };
std::printf("m = n*n = %d\n", square());
return 0;
}
text
闭包里的值 = 7
外层 ptr 还在吗?不在(已被移走)
m = n*n = 9
三个实用点:
[p = std::move(ptr)]是把std::unique_ptr(以及任何 move-only 类型)搬进闭包的唯一途径 。写[ptr]会直接编译失败,因为unique_ptr不可拷贝。- 右边是普通表达式 ,所以可以做计算、可以改名(
[m = n * n]把n*n的结果命名为m)、可以在捕获时就地构造一个对象。 - 代价是闭包可能变得不可拷贝 。这一点和
std::function相冲 ------ 后者要求目标可拷贝:
cpp
#include <functional>
#include <memory>
// 片段(故意编译失败,不要这么写):持有 unique_ptr 的闭包不可拷贝
std::function<int()> f = [p = std::make_unique<int>(1)] { return *p; };
真实报错(gcc 13.2,来自 libstdc++ 的静态断言,信息非常直白):
text
.../bits/std_function.h:439:69: error: static assertion failed:
std::function target must be copy-constructible
439 | static_assert(is_copy_constructible<__decay_t<_Functor>>::value,
想同时要「move 语义」和「类型擦除」,只有三条路:改用 std::shared_ptr(可拷贝,代价是引用计数)、把它包进一个自己的 move-only 包装类型、或者干脆用模板形参 / auto 接住闭包类型而不做类型擦除。
8. 完整示例:把命令收集起来稍后执行
这个例子把「捕获时机」和「闭包必须自持状态」两件事合在一起:命令在局部作用域里创建,执行时那些局部变量早就销毁了,所以只能用值捕获或初始化捕获。
cpp
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <vector>
// 把「命令」先收集起来、稍后统一执行:执行时局部变量已经销毁,
// 所以命令必须用值捕获 / 初始化捕获把数据拷进(或移进)闭包
int main() {
std::vector<std::function<std::string()>> commands;
{
std::string tag = "report";
const int version = 3;
auto payload = std::make_shared<std::string>("payload-data");
// 值捕获:拷一份 tag 和 version
commands.push_back([tag, version] {
return tag + "/v" + std::to_string(version);
});
// 初始化捕获:把 shared_ptr 移进闭包(可拷贝,所以能进 std::function)
commands.push_back([p = std::move(payload)] { return "shared:" + *p; });
} // ← tag / version / payload 在这里全部销毁
for (const auto& cmd : commands) {
std::printf("%s\n", cmd().c_str());
}
std::printf("命令数 = %zu\n", commands.size());
return 0;
}
text
report/v3
shared:payload-data
命令数 = 2
编译命令:g++ -std=c++17 -O2 -Wall capture_demo.cpp -o capture_demo。
这段代码值得注意的地方:
- 两个闭包都在内层作用域构造 ,但调用发生在作用域之外。如果任何一个写成
[&tag]或[&version],输出就不再是固定值 ------ 这里用值捕获是正确性的硬要求,不是风格问题。 - 第二个命令必须用初始化捕获:
payload是shared_ptr,写成[payload]也能编译(它是可拷贝的),但会多做一次引用计数;用std::move把它移进去,引用计数净增 0 次,代价更低。 - 换成
unique_ptr会更有意思:[p = std::move(payload)]依然能构造闭包,但放不进std::vector<std::function<...>>(见第 7 节的报错),得换成std::vector<std::unique_ptr<...>>加自定义包装。 std::function在这里是必要的:容器要求所有元素同类型,而每个闭包类型都独一无二,只能靠类型擦除统一。换来的是每次调用多一次间接跳转(详见《std::function 为什么比 lambda 慢》)。- 全程只用标准库、没有裸
new/delete、没有std::endl,符合知识库的基调。
9. 延伸阅读
- Lambda expressions --- cppreference:捕获列表的完整规则和 C++20 的变更说明,第 2 节的表就是它的浓缩版
- std::function --- cppreference:为什么要求可拷贝、小对象优化怎么工作
- std::unique_ptr --- cppreference:move-only 类型与初始化捕获搭配的标准姿势
- C++ Core Guidelines --- Lambdas / F.53:官方对「别捕获引用」的表述,和本文第 5 节呼应
- Compiler Explorer:想看
[x]和[&x]生成的闭包到底差在哪几条指令,这是最快的办法
10. 一句话总结
捕获列表的三件事 ------ 捕获时机是 lambda 定义时 (值捕获是快照、引用捕获看实时值)、闭包大小等于被捕获对象之和 (引用捕获固定 8 字节但每次都多一次解引用访存)、生命周期必须由自己保证 ([&] 捕获让闭包逃出作用域就是悬垂引用,成员函数里的 [=] 会隐式捕获 this,C++20 起必须显式写 [=, this] 或改用 [*this]);要把不可拷贝对象带进闭包,唯一办法是 C++14 的初始化捕获 [p = std::move(ptr)],而它的代价是闭包不再可拷贝、进不了 std::function。