条款31:避免默认捕获模式

ZaynPei Lv6

lambda 表达式

Lambda 表达式(Lambda Expression)是一种在需要函数的地方,可以就地定义匿名函数对象的便捷方式。它本质上是一个可调用的代码单元,可以像函数一样使用,但无需为其命名(当然也可以为其命名)。它允许你编写简短、内联的函数,特别适用于作为算法或异步调用的参数,从而让代码更简洁、更具表现力。

为何需要 Lambda?

在 C++11 之前,如果你想向一个算法(如 std::sort 或 std::find_if)传递自定义逻辑,通常有两种方法:定义一个独立的函数或者定义一个函数对象(Functor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义函数, 缺点:函数定义与调用点分离,降低了代码的可读性;可能会污染命名空间。
bool isOdd(int n) {
return n % 2 != 0;
}
// ...
std::vector<int> numbers = {1, 2, 3, 4, 5};
auto it = std::find_if(numbers.begin(), numbers.end(), isOdd);

// 定义函数对象, 缺点:语法非常冗长,为了一个简单的操作就需要定义一个完整的类。
struct IsOddFunctor {
bool operator()(int n) const {
return n % 2 != 0;
}
};
// ...
std::vector<int> numbers = {1, 2, 3, 4, 5};
auto it = std::find_if(numbers.begin(), numbers.end(), IsOddFunctor());
Lambda 表达式正是为了解决这些问题而生的。 它允许你将执行逻辑直接写在调用的地方:
1
2
3
4
std::vector<int> numbers = {1, 2, 3, 4, 5};
auto it = std::find_if(numbers.begin(), numbers.end(), [](int n) {
return n % 2 != 0;
});
这样做代码紧凑、逻辑清晰,定义与使用紧密相连

语法解析

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 表达式中使用。

  1. ( ) 参数列表 (Parameter List) 与普通函数的参数列表完全相同。如果 Lambda 不需要参数,() 可以省略(但如果使用了 mutable 或 -> return_type,则不能省略)。

捕获和传参的区别在于:捕获是从外部作用域获取变量,而参数是调用 Lambda 时主动传入的值。

1
2
[]() { std::cout << "No params." << std::endl; }
[](int x, int y) { return x + y; }

  1. specifiers (可选) 说明符
  • mutable:默认情况下,对于按值捕获的变量,Lambda 体内不能修改它们(它们是 const 的副本)。使用 mutable 关键字可以取消这个限制,允许你修改这些副本。
    1
    2
    3
    4
    5
    6
    7
    8
    int 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+) 等:与普通函数类似,用于指定异常规范或编译期求值。
  1. -> 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; // 多个返回语句,但类型可统一推导
    }

  2. { } 函数体 (Function Body): Lambda 表达式的具体执行代码,与普通函数的函数体一样。

Lambda 的本质:闭包 (Closure)

理解 Lambda 的关键在于知道它在底层是如何工作的。每当你写下一个 Lambda 表达式,编译器都会自动生成一个唯一的、匿名的类类型,这个类被称为“闭包类型”(Closure Type)。

Lambda 表达式本身创建了一个该类型的对象,称为“闭包对象”。这个闭包类重载了 operator(),使得其对象可以像函数一样被调用。函数体就是 Lambda 的 {} 中的代码。

而所有被捕获的变量,都会成为这个闭包类的成员变量。

  • 按值捕获 [x] -> 成为成员变量 int x;
  • 按引用捕获 [&y] -> 成为成员变量 int& y;

例如, 以下lambda表达式会生成这样的闭包类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int x = 10;
int y = 20;
auto my_lambda = [x, &y](int z) { y = x + z; };


// 编译器生成的(概念上的)等价物
class __Lambda_XYZ_Compiler_Generated {
public:
// 构造函数捕获变量
__Lambda_XYZ_Compiler_Generated(int x_val, int& y_ref)
: x(x_val), y(y_ref) {}

// 重载 operator()
void operator()(int z) const {
y = x + z; // 在函数体内使用成员变量
}

private:
int x; // 按值捕获的成员
int& y; // 按引用捕获的成员
};

// 创建闭包对象
auto my_lambda = __Lambda_XYZ_Compiler_Generated(x, y);

应用场景

  1. 配合 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
    #include <vector>
    #include <algorithm>
    #include <iostream>

    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 << " ";
    });

  2. 泛型 Lambda (C++14): 使用 auto 关键字作为参数类型,可以让 Lambda 成为一个模板

    1
    2
    3
    4
    5
    6
    7
    8
    auto 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"

  3. 带初始化的捕获 (C++14): 对于移动(move)一个只能移动的对象(如 std::unique_ptr)到 Lambda 内部非常有用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <memory>

    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
2
3
4
5
6
7
8
9
10
11
using FilterContainer = std::vector<std::function<bool(int)>>; // 函数包装器可以存储任何可调用对象
FilterContainer filters;

void addDivisorFilter() {
auto divisor = computeDivisor(); // divisor 是一个局部变量

// 危险![&] 捕获了对局部变量 divisor 的引用
filters.emplace_back(
[&](int value) { return value % divisor == 0; }
);
} // divisor 在这里被销毁
当 addDivisorFilter 函数返回时,其局部变量 divisor 会被销毁。然而,被添加到全局容器 filters 中的 lambda 闭包仍然存在,并且它内部包含一个指向已被销毁的 divisor 内存地址的引用。任何后续对这个过滤器的调用都将导致未定义行为。 在此时, 显式写出 [&divisor] 比 [&] 更安全。因为它清晰地表明了这个 lambda 的生存依赖于 divisor 的生命周期,迫使开发者去思考和确认这个依赖是安全的。而 [&] 则会隐藏这种依赖关系。

按值默认捕获 ([=]) 的风险

按值默认捕获 [=] 看起来似乎可以解决悬垂引用的问题,并让 lambda 变得“自洽”,但这种想法是具有误导性的,并隐藏着两个主要的陷阱。 ##### 陷阱一:悬垂指针(this 指针陷阱) 按值捕获一个指针,只是复制了指针本身,而不是它所指向的对象。如果指针所指向的对象被销毁,闭包中持有的指针副本同样会变成悬垂指针。

这个问题在类的成员函数中尤其隐蔽,因为 [=]隐式地捕获 this 指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {
public:
void addFilter() const;
private:
int divisor;
};

void Widget::addFilter() const {
// [=] 看起来是按值捕获,但实际上是按值捕获了 this 指针
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}

void doSomeWork() {
auto pw = std::make_unique<Widget>();
pw->addFilter(); // 添加了一个依赖 *pw 的过滤器
} // pw 在这里被销毁, 但这个指针的值 (即指向 Widget 对象的指针) 已经被复制到了 lambda 闭包中
在 addFilter 中,divisor 是一个成员变量,它无法被直接捕获。为了在 lambda 内部访问 divisor(实际上是 this->divisor),[=] 捕获的是this 指针的副本

而当doSomeWork 函数返回时,pw 所指向的 Widget 对象被销毁。但 filters 容器中的 lambda 闭包仍然存在,并且它内部持有一个指向已被销毁的 Widget 对象的 this 指针。这同样导致了悬垂指针和未定义行为。

陷阱二:“自洽”的假象

[=] 模式给人的感觉是,闭包复制了它所需的一切,因此是完全独立、自洽的。这是错误的。

[=] 不会捕获静态存储期的变量。如果 lambda 使用了全局变量static 局部变量或 static 成员变量,它只是直接引用这些变量,而不会在闭包中创建其副本。

1
2
3
4
5
6
7
8
9
void addDivisorFilter() {
static auto divisor = computeDivisor(); // divisor 是静态局部变量

filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);

++divisor; // 修改静态变量
}
这里的 [=] 实际上没有捕获任何东西,因为 divisor 是 static 的。lambda 内部的 divisor 直接引用了那个唯一的静态变量。

每次调用 addDivisorFilter,divisor 的值都会增加。这意味着每次添加到 filters 中的 lambda 的行为都会因 divisor 的改变而改变,这与 [=] 所暗示的“按值复制、行为固定”的直觉完全相反。

鉴于上述的默认捕获行为的危险性, 最安全的做法是避免使用默认捕获模式,转而显式捕获 lambda 所需的所有变量。这使得代码的依赖关系清晰可见,迫使开发者思考变量的生命周期,从而写出更健壮、更安全的代码。