constexpr Lambda 和 捕获 *this 的拷贝

1. C++17 的主要改进

Lambda 表达式是 C++11 引入的一种匿名函数(没有名字的函数)。它可以让我们在需要函数的地方快速定义一个小型函数,特别适合用于算法、回调函数等场景。

C++17 对 Lambda 表达式进行了改进,让我们用起来更方便!

2. constexpr Lambda

在 C++17 之前,Lambda 表达式默认是不能在编译期常量表达式(如 constexpr 函数或模板参数)中使用的。虽然编译器经常在内部做一些优化,但标准层面并不允许显式地将 Lambda 用于编译期计算。

C++17 规定,Lambda 表达式默认就是隐式的 constexpr(前提是它的函数体满足 constexpr 的要求)。这意味着我们可以在编译期调用 Lambda,比如在 constexpr 函数里使用 Lambda、把 Lambda 当作模板参数传递。这极大地增强了 C++ 的元编程能力。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <type_traits>
#include <array>

// C++17: constexpr 函数中可以使用 Lambda
constexpr auto getSquareLambda()
{
// 这个 Lambda 满足 constexpr 条件
return [](int n)
{
return n * n;
};
}

int main()
{
// 1. 在静态断言中使用 Lambda
constexpr auto is_even = [](int n) {
return n % 2 == 0;
};
static_assert(is_even(4), "4应该是偶数");
static_assert(!is_even(5), "5应该是奇数");
std::cout << "静态断言测试通过!" << std::endl;

// 2. 在模板参数中使用 Lambda
constexpr auto add = [](int a, int b) {
return a + b;
};
// 使用编译期计算的结果作为模板参数
std::array<int, add(10, 5)> arr; // 创建大小为15的数组
std::cout << "数组大小: " << arr.size() << std::endl;

// 3. 在 constexpr if 中使用
constexpr int value = 42;
if constexpr ([](int n) { return n > 0; }(value))
{
std::cout << "value是正数" << std::endl;
}

// 4. 在编译期计算结果
constexpr auto square = getSquareLambda();
constexpr int result = square(5);
std::cout << "5 的平方是 (编译期计算): " << result << std::endl;

// 5. 也可以用于运行期
int x = 10;
std::cout << "10 的平方是 (运行期计算): " << square(x) << std::endl;

return 0;
}

在 C++11/14 中,Lambda 默认是 const 的。也就是说,如果你没有在参数列表后写 mutable,你就不能修改按值捕获的变量。

但是,C++17 允许 Lambda 是 constexpr 的。这引出了一个有趣的问题:如果我给 Lambda 加上 constexpr 修饰符(虽然通常不需要显式加),它还能修改捕获的变量吗?

C++17 规定,如果 Lambda 满足 constexpr 的条件,它依然遵循 Lambda 的基本规则:默认是不可变

  • 如果尝试修改按值捕获的变量,必须在参数列表后加上 mutable 关键字。
  • 一旦加上了 mutable,这个 Lambda 就不再是 constexpr(因为它改变了状态)。

这个改进主要是理清了规则:Lambda 可以是编译期常量,但前提是它像 const 函数一样不修改内部状态。

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
26
27
28
#include <iostream>

int main()
{
int base = 10;

// 情况 1: 普通 Lambda,默认 const,不能修改 base
// [base](){ base++; } // 编译错误!不能修改按值捕获的变量

// 情况 2: 加上 mutable,可以修改 base,但不能用于 constexpr 上下文
auto mutableFunc = [base]() mutable {
base++;
return base;
};
std::cout << "mutable call: " << mutableFunc() << std::endl; // 输出 11

// 情况 3: constexpr Lambda (C++17)
constexpr int number = 9;
auto constexprFunc = [number](int n) {
return number + n;
};

// 编译期计算
constexpr int compileTimeVal = constexprFunc(5);
std::cout << "constexpr call: " << compileTimeVal << std::endl; // 输出 15 (10 + 5)

return 0;
}

自定义的lambda表达式如果捕获了外部变量,变量的属性会影响到该Lambda的属性,比如:

1
2
3
4
constexpr int number = 9;
auto constexprFunc = [number](int n) {
return number + n;
};
  • 如果捕获的是编译期常量,这个Lambdaconstexpr
  • 如果捕获的是运行时变量,并在函数体内使用,这个Lambda不是constexpr
  • 如果捕获的是运行时变量,但在函数体中没有使用它Lambda 仍可以是 constexpr
  • 如果捕获的是编译期常量,但是函数体包含非法操作(如 newthrow),也不是 constexpr

3. 捕获 *this 的拷贝

C++17 引入了 [*this] 捕获方式。它不是捕获指针,而是把当前对象(整个实体)复制一份传给 Lambda。这样做的好处是 Lambda 内部持有的是对象的副本,即使外部的原对象被销毁了,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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <string>
#include <thread>
#include <functional>

class Worker
{
public:
Worker(std::string name) : m_name(name) {}

// 模拟一个耗时任务,返回一个 Lambda
std::function<void()> getTaskOldStyle()
{
// C++11/14 风格:捕获 this 指针
// 危险!如果 Worker 对象在 task 执行前被销毁,this 就悬空了
return [this]() {
std::cout << "[Old Style] My name is: " << this->m_name << std::endl;
};
}

std::function<void()> getTaskNewStyle()
{
// C++17 风格:捕获 *this (对象副本)
// 安全!Lambda 内部持有对象的拷贝,与原对象解耦
return [*this]() {
std::cout << "[New Style] My name is: " << m_name << std::endl;
};
}

private:
std::string m_name;
};

int main()
{
{
auto task = Worker("Alice").getTaskNewStyle();
// 此时 Worker("Alice") 临时对象已经销毁了
// 但因为是值捕获副本,task 依然可以安全调用
task();
}

{
auto task1 = Worker("Bob").getTaskOldStyle();
// 此时 Worker("Bob") 临时对象已经销毁了
Worker worker2("Hello, world");
task1();
}

return 0;
}

程序运行的结果:

1
2
[New Style] My name is: Alice
[Old Style] My name is: Hello, world
  • getTaskNewStyle 中,我们使用了 [*this]。这意味着 Lambda 内部保存了一个 Worker 对象的副本。
  • 即使我们在 main 函数中创建的是一个临时对象 Worker("Alice") 并取出了 task,临时对象随即销毁,task 中的副本依然存在,调用时不会出错。

为什么这里看起来能”正常工作” ?

1
2
3
4
{
auto task1 = Worker("Bob").getTaskOldStyle();
task1();
}
  • Worker("Bob") 创建一个临时(匿名)对象
  • 调用 getTaskOldStyle() 方法,返回一个 Lambda,Lambda 捕获了 this 指针(指向临时对象)
  • 表达式结束后,临时对象立即被销毁,将返回的 Lambda 赋值给 task1
  • 调用task1(); 相当于访问了已销毁的对象,为什么程序可以正常执行有一些原因:
    • 临时对象是在栈上创建的,内存未被立即覆盖
    • 重新重新创建新的临时对象,会覆盖原来的栈内存,最后输出Hello, world