C++ Lambda 本质 & 变量捕获

C++ 11 引入 lambda 之后,可以很方便地在 C++ 中使用匿名函数,这篇文章主要聊聊其背后的实现原理以及有反直觉的变量捕获机制。在阅读本文之前,需要读者对 C++ lambda 有一个简单的了解。

C++ Lambda 的函数结构

1
[capture_list](parameter_list) -> return_type {function_body}

其中,capture_list 表示捕获列表,parameter_list 表示函数参数列表,return_type 表示函数返回类型,function_body 表示函数体。下面是一个简单的 Lambda 函数示例,这里定义一个计算面积的名为 area 的 lambda。

1
2
3
4
5
6
7
8
#include <iostream>
int main() {
double pi = 3.14;
auto area = [=](double radius) -> double {
return pi * radius * radius;
};
std::cout << "area of circle with radius 2.0 : " << area(2.0) << std::endl;
}

这里选择了 by-copy (=) 的方法来捕获 pi 这个变量,也就是会复制一份 pi 进到 area lambda 里,那么这个值 copy 到了哪里呢?

Lambda 在编译期的实现

我们使用 C++ insights 来看一下内部可能的实现:

01.png

实际编译器会为每一个 lambda 生成唯一的类(functor),有以下的特点:

  1. line 6, 生成的类名唯一,不可读,不同编译器生成的名字可能不一样,我们在运行时是无法拿到具体类名的
  2. line 9, 因为有 operator() 所以是可以直接当成函数调用的,函数参数和返回值和 lambda 中声明的完全一致。
  3. line 15, 捕获的变量在这里,会被转化为类该类的属性,并在构造的传入捕获的参数 (line 15 & line 24)

ps: 其实也可见 C++ 中 lambda 的实现和 Java 的 lambda 转换为匿名内部类的实现,以及 Objective-C 的 block 的实现原理和变量捕获机制都非常的相似。

关于 const

如果我们将上例中的 area lambda 改成下面会如何?

1
2
3
4
auto area = [=](double radius) -> double {
pi *= 2;
return pi * radius * radius;
};

实际上编译会失败,clang 会报以下错误:

1
2
3
4
lambda.cpp:6:8: error: cannot assign to a variable captured by copy in a non-mutable lambda
pi *= 2;
~~ ^
1 error generated.

这里最主要的原因是编译器生成的匿名类的 operator() 都是 const 的,const 在这里修饰 this 指针 (__lambda_5_15 对象的指针),表示 this 不可变,因此不可以修改属性 pi 的值。这一点稍微有点违反直觉,需要注意。

也即是说编译器意欲生成的代码是这样的,但发现不合法:

1
2
3
4
5
6
7
8
public:
inline /*constexpr */ double operator()(double radius) const
{
pi *= 2;
return (pi * radius) * radius;
}
private:
double pi;

那如何把 const 去掉,使得 lambda 内可以修改捕获的值呢?
答案就是 mutable 关键字,增加 mutable 之后:

1
2
3
4
auto area = [=](double radius) mutable -> double {
pi *= 2;
return pi * radius * radius;
};

再来看看生成后的 operator(), 没有了 const,也可以正常修改 this 的属性 pi

1
2
3
4
5
6
7
8
9
public:
inline /*constexpr */ double operator()(double radius)
{
pi = pi * 2;
return (pi * radius) * radius;
}

private:
double pi;

变量捕获方式 & 如何捕获 this 指针

捕获方法分为两种 = 和 &,分别对应 capture by-copycapture by-reference, 基本的部分这里我们不多做介绍。需要注意的是对 this 的捕获,通过 [&][=] 对 this 的隐式捕获,以及 [this] 显式捕获都是 by-reference 的,其实捕获的都是 this 指针。

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

class Math {
public:
Math(double value): value_(value) {}
auto square() {
return [&]() -> double {
return value_ * value_;
};
}
private:
double value_;
};

int main() {
Math math(10);
std::cout << math.square()() << std::endl;
}

return [&]() -> double ... 这里换成 [=] 或者 [this] 生成的代码都是完全一致的,如下:

01.png

捕获 this 指针 by-refernce 的好处是减少内存的 copy,但处理不当的话,比如 this 指针的生命周期如果没有 lambda 长,那么就会访问的野指针,导致 crash。这种 case 下,可以考虑通过 [*this] 的方式,copy this 对象到 lambda 中。 ps: [*this] 是 C++ 17 引入的。

方框的位置是和上面 by-reference 不同之处,会调用 Math 的 copy 构造创建一个 copy 保存到 lambda 对象中。
01.png

需要注意的是,即便是 copy 一份,因为生成的 operation () 还是 const 的,所以并不能修改 Math 的属性,如果需要修改,需要加上 mutable 关键字。

实际场景中,应该根据实际的需要(主要考虑生命周期),来选择是使用 by-copy 还是 by-reference 来捕获 this.

回顾 & 总结

  1. lambda 本质上其实就是使用一个匿名的 functor(带有 operator() 的 class),并把 capture 的变量作为该类的属性
  2. lambda 默认生成的 operator() 是 const,如果需要修改 capture 的变量副本,需要加 mutable 关键字修饰
  3. 通过 [=] [&] 隐式捕获 还是 [this] 显式捕获 this 都是 by-reference 的,只有 [*this]by-copy 的。注意实现的区别,以及如何进行选择。

Ref:

  1. Lambdas, how to capture everything and stay sane - Dawid Zalewski (Meeting C++ 2022 on Youtube)
  2. Lambda expressions (CppReference)