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

lambda 捕获列表为空 、函数体无运行时操作时,满足 C++17 隐式 constexpr 的条件,如果自定义的lambda表达式捕获了外部变量,这样会影响到该Lambda的属性,比如:

  • 如果捕获的是编译期常量,并在函数体内使用,这个Lambda不是constexpr
  • 如果捕获的是编译期常量,但在函数体中没有使用它Lambda 仍是 constexpr
  • 如果捕获的是运行时变量,并在函数体内使用,这个Lambda不是constexpr
  • 如果捕获的是运行时变量,但在函数体中没有使用它Lambda 仍是 constexpr
  • 如果没有捕获外部变量,但是函数体包含非法操作(如 IO 操作static 局部变量try/catchnew等 ),也不是 constexpr

在上述描述中,对于在Lambda捕获列表中捕获了外部变量/constexpr 常量但是没有使用的这种情况的说明:主流编译器(GCC、Clang 部分版本)会放宽检查,将捕获的未访问成员消除,这样就等价于没有捕获外部变量,优化后变成无捕获 lambda,这样就能满足隐式 constexpr的要求。

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
52
53
#include <iostream>

int main()
{
// 如果捕获的是运行时变量,但在函数体中没有使用它,Lambda 仍是 constexpr
auto add1 = [base](int a)
{
return a;
};
if constexpr (add1(4))
{
cout << "add1 ok..." << endl;
}

// 如果捕获的是运行时变量,并在函数体内使用,这个Lambda不是constexpr
auto add2 = [base](int a)
{
return a + base;
};
if constexpr (add2(4)) // error
{
cout << "add2 ok..." << endl;
}

// 如果捕获的是编译期常量,但是没有使用, 这个Lambda是constexpr
constexpr int count = 9;
auto add3 = [count](int a)
{
return a ;
};
if constexpr (add3(9))
{
cout << "ok..." << endl;
}

// 如果捕获的是编译期常量,并且在函数体内使用, 这个Lambda不是constexpr
auto add4 = [count](int a)
{
return a + count ;
};
if constexpr (add4(9)) // error
{
cout << "ok..." << endl;
}

auto bad1 = [](int a) {
static int x = 0; // constexpr禁止static局部变量
return a + x++;
};
static_assert(bad1(12)); // error

return 0;
}

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