C++ lambda 捕获列表详解:值捕获、引用捕获、初始化捕获与悬垂引用

捕获列表是整个 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

三条选择原则,先记住能用一辈子:

  1. 优先显式逐个捕获,别用 [=] / [&] 。理由不是「风格洁癖」,而是 [&] 让读代码的人无法从捕获列表看出这个闭包依赖谁 ------ 他必须读完函数体才知道。生命周期审查的难度就是这么上来的。
  2. 闭包要活过当前作用域,就一律用值捕获(或者初始化捕获)。
  3. 引用捕获只在「闭包的生命周期严格短于被捕获变量」时才安全 ------ 典型是就地传给 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 接住闭包类型而不做类型擦除。

官方文档:std::unique_ptr、std::shared_ptr

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. 延伸阅读

10. 一句话总结

捕获列表的三件事 ------ 捕获时机是 lambda 定义时 (值捕获是快照、引用捕获看实时值)、闭包大小等于被捕获对象之和 (引用捕获固定 8 字节但每次都多一次解引用访存)、生命周期必须由自己保证 ([&] 捕获让闭包逃出作用域就是悬垂引用,成员函数里的 [=] 会隐式捕获 this,C++20 起必须显式写 [=, this] 或改用 [*this]);要把不可拷贝对象带进闭包,唯一办法是 C++14 的初始化捕获 [p = std::move(ptr)],而它的代价是闭包不再可拷贝、进不了 std::function。

相关推荐
lengxuenong2 小时前
枚举例题分析
c++·算法·青少年编程
繁华的地方不一定留下你的脚印2 小时前
C++线程如何安全停止与唤醒:std::jthread、stop_token与condition_variable
开发语言·c++
沫璃染墨3 小时前
《从零入门Linux系统篇(五十一):线程篇·四——pthread线程库详解:从线程创建到终止与分离》
linux·运维·服务器·开发语言·c++·系统架构·线程
shada3 小时前
CMake指定 MSVC 工具集与 Windows SDK
c++·windows
垆边人似月.3 小时前
华为机考题:矩阵中的最长递增路径
c语言·c++
A7bert7774 小时前
【SAM3部署至AGX Orin】环境配置→模型部署→问题记录
c++·人工智能·深度学习·ubuntu
longlongzihan4 小时前
LeetCode 56合并区间:排序与贪心
c++·算法·leetcode
程序员老陆4 小时前
C++ 继承方式:公有、保护、私有,到底在控制什么
开发语言·c++
坤坤子吖4 小时前
C++11——Lambda、function、bind 与可调用对象
c++·笔记·学习