在 C++ 中,lambda 表达式的捕获机制决定了它如何使用外部变量。这一机制看似简单,但在实际工程中却直接影响:

  • 变量是否被修改
  • 生命周期是否安全
  • 是否发生拷贝或所有权转移

本文将按照由浅入深的顺序,逐一为大家讲解其中的细节。

一、值捕获:lambda 的“快照机制” 📦

值捕获是最基础也是最安全的一种方式。它的核心行为是:在 lambda 创建时拷贝变量的当前值,之后 lambda 内部使用的是这个副本,而不是原变量。

这意味着,外部变量的变化不会影响 lambda 内部的值。

先看一个最简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

int main() {
int x = 10;

auto func = [x]() {
cout << "lambda x = " << x << endl;
};

x = 20;

func(); // 输出 10
}

这里可以观察到一个关键现象:

  • lambda 捕获的是 x = 10 时的值
  • 后续 x 被修改为 20,但 lambda 内部完全不受影响

可以把它理解为:lambda 在创建时对变量做了一次“拍照”

不过需要注意一个细节:默认情况下,这个“副本”是不可修改的。但是,有时候我们又需要对这个“副本”进行一些修改,所以下面就需要介绍一下 mutable 关键字了。

二、mutable:修改“值捕获副本”的能力 ⚠️

当使用值捕获时,lambda 内部的变量默认是只读的。这是为了避免无意中修改副本导致语义混乱。

例如:

1
2
3
4
5
int x = 10;

auto func = [x]() {
x++; // ❌ 编译错误
};

如果确实需要修改副本,可以使用 mutable

1
2
3
4
5
6
7
8
9
int x = 10;

auto func = [x]() mutable {
x++;
cout << x << endl;
};

func(); // 11
cout << x << endl; // 10

这里需要明确两点:

  • 修改的是 lambda 内部的副本
  • 外部变量完全不受影响

因此,mutable 并不会改变“值捕获”的本质,它只是放宽了副本的只读限制

三、引用捕获:直接操作原变量 🔗

与值捕获不同,引用捕获并不会拷贝变量,而是直接绑定到原变量

这意味着 lambda 内部的操作会直接影响外部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

int main() {
int x = 10;

auto func = [&x]() {
x += 5;
};

func();

cout << x << endl; // 输出 15
}

这里可以看出明显区别:

  • 值捕获 → 操作副本
  • 引用捕获 → 操作原变量

2.1 与值捕获的直接对比

为了更直观理解,两者可以放在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x = 10;

auto byValue = [x]() {
cout << "byValue x = " << x << endl;
};

auto byRef = [&x]() {
cout << "byRef x = " << x << endl;
};

x = 100;

byValue(); // 10
byRef(); // 100

这里体现出本质差异:

  • 值捕获:固定在捕获时刻
  • 引用捕获:始终跟随变量变化

2.2 一个必须警惕的问题:生命周期

引用捕获虽然高效,但存在一个非常严重的风险——悬空引用

1
2
3
4
5
6
auto getFunc() {
int x = 10;
return [&x]() {
return x;
};
}

这个代码的问题在于:

  • x 是局部变量
  • 函数返回后 x 被销毁
  • lambda 内部持有的是一个失效引用

这类问题在异步编程中尤为常见,因此需要格外谨慎。

四、初始化捕获:从“拷贝”到“构造” 🚀

前面的捕获方式,本质上都是“直接使用已有变量”。而初始化捕获(C++14)提供了一种更通用的能力:

在捕获时执行表达式,并用结果初始化 lambda 内部变量

基本形式如下:

1
2
3
auto func = [x = 10]() {
cout << x << endl;
};

这看似简单,但它的真正价值在于支持更复杂的场景,例如:move 语义

4.1 move 捕获:所有权转移,而不是引用

来看一个典型例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <memory>
using namespace std;

int main() {
unique_ptr<int> p = make_unique<int>(10);

auto func = [p = move(p)]() {
cout << *p << endl;
};

if (!p) {
cout << "p 已经为空" << endl;
}

func();
}

运行结果:

1
2
p 已经为空
10

这里体现了一个关键点:

  • p 被移动进 lambda
  • 外部 p 已失效
  • lambda 拥有资源的所有权

4.2 与前面几种方式的关系

在这一阶段,可以统一理解三种机制:

  • 值捕获:拷贝一份数据
  • 引用捕获:共享同一份数据
  • move 捕获:转移数据的所有权

特别强调一点:

move 捕获并不是引用捕获的优化,而是值捕获的一种扩展形式

它的本质仍然是“按值持有”,只不过这个值是通过移动构造得到的。

五、结语:如何选择捕获方式 🧠

当理解了这些机制之后,选择就变得清晰了:

  • 如果希望安全、隔离 → 使用值捕获
  • 如果需要修改外部变量 → 使用引用捕获(确保生命周期安全)
  • 如果涉及资源管理 → 使用初始化捕获 + move

归根结底,lambda 捕获机制解决的是一个核心问题:

变量是被复制、被共享,还是被转移

一旦这个问题想清楚,大部分 lambda 使用场景都会变得非常直观。