条款31:避免默认捕获模式
lambda 表达式
Lambda 表达式(Lambda Expression)是一种在需要函数的地方,可以就地定义匿名函数对象的便捷方式。它本质上是一个可调用的代码单元,可以像函数一样使用,但无需为其命名(当然也可以为其命名)。它允许你编写简短、内联的函数,特别适用于作为算法或异步调用的参数,从而让代码更简洁、更具表现力。
为何需要 Lambda?
在 C++11 之前,如果你想向一个算法(如 std::sort 或 std::find_if)传递自定义逻辑,通常有两种方法:定义一个独立的函数或者定义一个函数对象(Functor)
1 | // 定义函数, 缺点:函数定义与调用点分离,降低了代码的可读性;可能会污染命名空间。 |
1 | std::vector<int> numbers = {1, 2, 3, 4, 5}; |
语法解析
Lambda 表达式的完整语法结构如下: capture specifiers -> return_type { body }, 其中很多部分是可选的。我们来逐一分解:
| 捕获方式 | 说明 |
|---|---|
[] |
不捕获任何外部变量。 |
[=] |
按值捕获 (by copy)。所有外部变量都以只读副本的形式在函数体内可用。 |
[&] |
按引用捕获 (by reference)。所有外部变量都以引用的形式在函数体内可用,可以修改。 |
[this] |
捕获 this 指针。允许在 Lambda 体内访问当前对象的成员变量和函数。 |
[a, &b] |
显式捕获。只捕获变量 a(按值)和 b(按引用)。 |
[=, &b] |
混合捕获。默认按值捕获,但显式指定 b 按引用捕获。 |
[&, a] |
混合捕获。默认按引用捕获,但显式指定 a 按值捕获。 |
[val = expr] (C++14+) |
带初始化的捕获(或称广义捕获)。允许创建一个新的变量 val 并用 expr 初始化,该变量仅在 Lambda 体内可见。这对于移动捕获(move capture)尤其重要。 |
需要注意的是, [&] 和 [=] 被称为默认捕获模式 (Default Capture Modes)。当你在捕获列表中使用它们而不指名具体变量时,就为 Lambda 设定了一个“自动捕获”的规则, 所有外部的变量都会在使用时被自动捕获. 这个规则在提供便利的同时也可能导致一些问题, 这就是这一讲所提出的.
全局变量 (Global variables)和静态变量 (Static variables)不需要被捕获, 因为它们已经在全局作用域中定义, 可以直接在 Lambda 表达式中使用。
- ( ) 参数列表 (Parameter List) 与普通函数的参数列表完全相同。如果 Lambda 不需要参数,() 可以省略(但如果使用了 mutable 或 -> return_type,则不能省略)。
捕获和传参的区别在于:捕获是从外部作用域获取变量,而参数是调用 Lambda 时主动传入的值。
1 | []() { std::cout << "No params." << std::endl; } |
- specifiers (可选) 说明符
- mutable:默认情况下,对于按值捕获的变量,Lambda
体内不能修改它们(它们是 const 的副本)。使用 mutable
关键字可以取消这个限制,允许你修改这些副本。
1
2
3
4
5
6
7
8int count = 0;
auto counter = [count]() mutable {
count++; // OK with mutable
std::cout << count << std::endl;
};
counter(); // 输出 1
counter(); // 输出 2
std::cout << count << std::endl; // 仍然输出 0,因为修改的是副本 - noexcept, constexpr (C++17+) 等:与普通函数类似,用于指定异常规范或编译期求值。
-> return_type (可选) 返回类型 通常情况下,编译器可以根据函数体中的 return 语句自动推导出 Lambda 的返回类型。因此,这个部分是可选的。 只有在少数复杂情况下(例如期望的返回类型与推导出的不同且可以通过转换实现),才需要显式指定返回类型。
1
2
3
4
5
6
7
8// 自动推导返回类型为 double
[](double a) { return a * 1.5; }
// 显式指定返回类型为 double
[](int a) -> double {
if (a > 0) return a;
return 0.0; // 多个返回语句,但类型可统一推导
}{ } 函数体 (Function Body): Lambda 表达式的具体执行代码,与普通函数的函数体一样。
Lambda 的本质:闭包 (Closure)
理解 Lambda 的关键在于知道它在底层是如何工作的。每当你写下一个 Lambda 表达式,编译器都会自动生成一个唯一的、匿名的类类型,这个类被称为“闭包类型”(Closure Type)。
Lambda 表达式本身创建了一个该类型的对象,称为“闭包对象”。这个闭包类重载了 operator(),使得其对象可以像函数一样被调用。函数体就是 Lambda 的 {} 中的代码。
而所有被捕获的变量,都会成为这个闭包类的成员变量。
- 按值捕获 [x] -> 成为成员变量 int x;
- 按引用捕获 [&y] -> 成为成员变量 int& y;
例如, 以下lambda表达式会生成这样的闭包类:
1 | int x = 10; |
应用场景
配合 STL 算法(最常见的用途)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
std::vector<int> v = {5, -1, 42, 8, 7};
// 1. 自定义排序
int factor = 10;
std::sort(v.begin(), v.end(), [factor](int a, int b) {
// 按与 factor 的距离排序
return std::abs(a - factor) < std::abs(b - factor);
});
// 2. 查找第一个满足条件的元素
auto it = std::find_if(v.begin(), v.end(), [](int n) {
return n > 40;
});
if (it != v.end()) {
std::cout << "Found: " << *it << std::endl; // 输出 Found: 42
}
// 3. 遍历
std::for_each(v.begin(), v.end(), [](int n){
std::cout << n << " ";
});泛型 Lambda (C++14): 使用 auto 关键字作为参数类型,可以让 Lambda 成为一个模板
1
2
3
4
5
6
7
8auto generic_add = [](auto a, auto b) {
return a + b;
};
int sum_int = generic_add(5, 3); // 8
double sum_double = generic_add(1.5, 2.5); // 4.0
std::string s1 = "hello", s2 = " world";
std::string s3 = generic_add(s1, s2); // "hello world"带初始化的捕获 (C++14): 对于移动(move)一个只能移动的对象(如 std::unique_ptr)到 Lambda 内部非常有用
1
2
3
4
5
6
7
8
9
10
11
auto ptr = std::make_unique<int>(100);
// 将 ptr 的所有权移入 Lambda
auto my_lambda = [p = std::move(ptr)]() {
std::cout << "Value inside lambda: " << *p << std::endl;
};
my_lambda();
// 此处 ptr 已经是 nullptr,因为所有权已经移交
避免默认捕获模式
回到标题, 该条款的核心论点是,C++11 提供的两种默认捕获模式——按引用默认捕获 [&] 和 按值默认捕获 [=] 都存在风险,应当避免使用。取而代之的,是显式捕获(explicit capture),即明确列出 lambda 表达式所依赖的所有外部变量
按引用默认捕获 ([&]) 的风险:悬垂引用
这是最直接也最危险的问题。按引用捕获会导致闭包(lambda 表达式创建的运行时对象)包含指向局部变量或函数参数的引用。如果闭包的生命周期超过了这些局部变量或参数的生命周期,那么闭包内的引用就会悬垂 (dangle)。
假设我们有一个全局的过滤器(函数)容器,一个函数 addDivisorFilter 用于向其中添加一个过滤器。
1 | using FilterContainer = std::vector<std::function<bool(int)>>; // 函数包装器可以存储任何可调用对象 |
按值默认捕获 ([=]) 的风险
按值默认捕获 [=] 看起来似乎可以解决悬垂引用的问题,并让 lambda 变得“自洽”,但这种想法是具有误导性的,并隐藏着两个主要的陷阱。 ##### 陷阱一:悬垂指针(this 指针陷阱) 按值捕获一个指针,只是复制了指针本身,而不是它所指向的对象。如果指针所指向的对象被销毁,闭包中持有的指针副本同样会变成悬垂指针。
这个问题在类的成员函数中尤其隐蔽,因为 [=] 会隐式地捕获 this 指针。
1 | class Widget { |
而当doSomeWork 函数返回时,pw 所指向的 Widget 对象被销毁。但 filters 容器中的 lambda 闭包仍然存在,并且它内部持有一个指向已被销毁的 Widget 对象的 this 指针。这同样导致了悬垂指针和未定义行为。
陷阱二:“自洽”的假象
[=] 模式给人的感觉是,闭包复制了它所需的一切,因此是完全独立、自洽的。这是错误的。
[=] 不会捕获静态存储期的变量。如果 lambda 使用了全局变量、static 局部变量或 static 成员变量,它只是直接引用这些变量,而不会在闭包中创建其副本。
1 | void addDivisorFilter() { |
每次调用 addDivisorFilter,divisor 的值都会增加。这意味着每次添加到 filters 中的 lambda 的行为都会因 divisor 的改变而改变,这与 [=] 所暗示的“按值复制、行为固定”的直觉完全相反。
鉴于上述的默认捕获行为的危险性, 最安全的做法是避免使用默认捕获模式,转而显式捕获 lambda 所需的所有变量。这使得代码的依赖关系清晰可见,迫使开发者思考变量的生命周期,从而写出更健壮、更安全的代码。