lambda也出现了好长时间,一直以来也仅仅限于使用,今天,借助此文,我们从使用、实现的角度聊聊lambda。
在开始正文之前,我们先看一个问题,对下面的vector进行排序:
std::vector<int> v = {1, 3, 2};
在C++11之前,我们可能会这么做(普通函数
,即函数指针作为参数):
bool Compare(int a, int b) {
return a < b;
}
int main() {
std::vector<int> v = {1, 3, 2};
std::sort(v.begin(), v.end(), Compare);
return 0;
}
也有可能这样做(函数对象
,即类对象作为参数):
int main() {
struct Compare {
bool operator()(int a, int b) {
return a < b;
}
};
std::vector<int> v = {1, 3, 2};
std::sort(v.begin(), v.end(), Compare());
return 0;
}
但是上述两种方式均有其局限性,对于普通函数的实现方式来说,其优点
是具有最小的语法开销,缺点
是不能限定作用域(即必须在使用作用域外进行定义),而对于函数对象的实现方式来说,其优点
是可以在作用域内进行定义,但缺点
是需要有类定义的语法开销。
既然函数指针和函数对象都有其优缺点,那么有没有其它方式既保持了二者的优点,又摒弃了二者的缺点呢?当然有了,这就是lambda
。
本文的主要内容如下:
概念
自C++11开始,引入了lambda(一般称之为为lambda表达式)
,一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个匿名的内联函数。lambda表达式跟普通函数相比不需要定义函数名,取而代之的多了一对方括号[]
。
先看下lambda的基本语法,如下:
[capture](parameters) specifiers exception attr -> return type { /*code; */ }
在上面定义中:
-
[capture]代表捕获列表,括号内为外部变量的传递方式,包括值传递、引用传递等
-
(parameters)代表参数列表,其中括号内为形参,和普通函数的形参一样
-
specifiers exception attr代表附加说明符,一般为
mutable
、noexcept
等 -
->return type代表lambda函数的返回类型如
-> int
、-> string
等。在大多数情况下不需要,因为编译器可以推导类型 -
{}内为函数主体,和普通函数一样
为了便于我们对lambda的使用有个初步认识,下面是一些常用的例子:
// 1. 最简单的lambda,没有任何行为操作:
[]{};
// 2. 包含两个参数的lambda:
[](float f, int a) { return a * f; };
[](int a, int b) { return a < b; };
// 3. 有返回值的lambda:
[](MyClass t) -> int { auto a = t.compute(); print(a); return a; };
// 4. 存在附加说明符的lambda:
[x](int a, int b) mutable { ++x; return a < b; };
[](float param) noexcept { return param*param; };
[x](int a, int b) mutable noexcept { ++x; return a < b; };
// 5. 参数列表可选:
[x] { std::cout << x; }; // 去掉()
[x] mutable { ++x; }; // 编译失败!
[x]() mutable { ++x; }; // 正常编译,这是因为在附加说明符前面需要有()
[] noexcept { }; // 编译失败!
[]() noexcept { }; // 正常编译,这是因为在附加说明符前面需要有()
好了,现在回到正题,如果我们使用lambda来实现之前排序的话,应该怎么做呢?如下:
int main() {
std::vector<int> v = {1, 3, 2};
std::sort(v.begin(), v.end(), [](int a, int b){
return a < b;
});
return 0;
}
从上述实现可以看出,其相较于函数指针
和函数对象
的实现方式,更为简洁直观
。
捕获列表
在上一节中,我们提到了lambda定义中的几个基本点:捕获列表
、函数参数
、附加说明符
、返回类型
以及函数体
。函数参数、返回类型和函数体在普通函数或者类成员函数中我们都有用到,那么什么是捕获列表和附加说明符呢?这就是本节的内容。
捕获的作用是捕获lambda所在函数的局部变量(捕获全局变量或者静态变量编译器会报warning,后面有说明)。其中捕获的类型可以分为值捕获,引用捕获和隐式捕获:
-
值捕获 与函数中的值传递类似。lambda表达式捕获的是变量的一个拷贝,因此我们如果在lambda表达式后面改变该变量值的话,不会影响捕获前的该变量值,这就是所谓的值捕获
int a = 1; [a](){printf("%d\n", a;);}
-
引用捕获 引用捕获和值捕获形式完全一样,只是在捕获列表中传的是变量的引用,类似于函数中的引用传递,变成下面这个样子
int a = 1; [&a](){printf("%d\n", a;);}
-
隐式捕获的方式,就是捕获的列表可以用
=
和&
代替,让编译器隐式的推断你使用的是哪个变量,然后这两个字符表示捕获的类型=
表示值捕获,&
是引用捕获;写出来之后就变成了如下的形式:int a = 1; [=](){printf("%d\n", a);}; [&](){printf("%d\n", a;);}
下面是捕获列表的一些语法规则:
-
[&]
通过引用捕获作用域内的全部局部变量 -
[=]
通过引用捕获作用域内的全部局部变量 -
[x, &y]
x
按照值捕获和y
按照引用捕获。 -
[x = expr]
带有初始化表达式的捕获 (C++14) -
[args...]
捕获模板参数包,全部按值。 -
[&args...]
捕获模板参数包,全部通过引用。 -
[...capturedArgs = std::move(args)](){}
通过移动操作符捕获包(C++20)
捕获规则示例代码如下:
int x = 2, y = 3;
const auto l1 = []() { return 1; }; // 没有捕获任何内容
const auto l2 = [=]() { return x; }; // 按值捕获所有变量
const auto l3 = [&]() { return y; }; // 按引用捕获所有变量
const auto l4 = [x]() { return x; }; // 仅对x进行按值捕获
const auto l5 = [&y]() { return y; }; // 仅对y进行按引用捕获
const auto l6 = [x, &y]() { return x * y; }; // 对x按值捕获,对y按引用捕获
const auto l7 = [=, &x]() { return x + y; }; // 对x按引用捕获,其余的按值捕获
const auto l8 = [&, y]() { return x - y; }; // 对y按值捕获,其余的按引用捕获
const auto l9 = [this]() { } // 捕获this指针
const auto la = [*this]() { } // 按值捕获*this对象
值捕获
lambda表达式可以将作用域内的变量捕获到lambda函数中。在lambda的表达式定义中,我们有提到[=]
指定可以按值捕获作用域内的任何变量,[x]
则仅仅按值捕获变量x。
仅捕获某个变量,代码如下:
int main() {
int x = 5;
auto fun = [x]() { printf("%d\n", x); };
fun();
return 0;
}
捕获所有变量,代码如下:
int main() {
int x = 5;
int y = 6;
auto fun = [=]() { printf("%d, %d\n", x, y); };
fun();
return 0;
}
引用捕获
可以使用引用捕获
调用lambda表达式。当使用引用捕获时候,捕获的值实际上是对lambda外部范围内变量的引用。
int main() {
int x = 5;
auto fun = [&x]() { printf("%d\n", ++x); };
fun();
printf("%d\n", x);
return 0;
}
输出如下:
6
6
如果外部变量很多,想按引用捕获外部所有变量的话,可以使用[&]
方式,如下:
int main() {
int x = 5;
int y = 0;
auto fun = [&]() { printf("%d, %d\n", ++x, --y); };
fun();
printf("%d, %d\n", x, y);
return 0;
}
输出如下:
6 -1
6 -1
mutable关键字
本来mutable关键字应该单列一节来进行说明,但是因为其与捕获列表关系紧密,所以就暂时放在了本节一起来进行说明。
我们经常有一种需求,需要对某个变量进行修改,或者说局部范围内的修改,当退出该作用域的时候,变量又恢复原值。对于这种需求,我们可以尝试使用值捕获
来完成,代码如下:
int main() {
int x = 5;
auto fun = [x]() { printf("%d\n", ++x); };
fun();
printf("%d\n", x);
return 0;
}
编译之后,发现编译器会报错,如下:
错误:令只读变量‘x’自增
auto fun = [x]() { printf("%d\n", ++x); };
从上述编译器的输出来看,对于按值捕获的变量,编译器会将其设置为只读(read only
),所以对只读变量进行尝试修改的操作是不被编译器所允许的,而mutable
则可以解决此类错误,如下:
int main() {
int x = 5;
auto fun = [x]() mutable { printf("%d\n", ++x); };
fun();
printf("%d\n", x);
return 0;
}
代码输出如下:
6
5
捕获全局变量和静态变量
一般情况下,lambda是用来捕获局部变量的,如果用其来捕获全局变量或者静态变量,那么编译器会报warning
,如下代码:
#include <iostream>
#include <vector>
#include <algorithm>
int x = 4;
int main() {
auto fun = [x]() { printf("%d\n", x); };
fun();
return 0;
}
编译器输出如下:
test.cc: In function ‘int main()’:
test.cc:7:15: warning: capture of variable ‘x’ with non-automatic storage duration
7 | auto fun = [x]() { printf("%d\n", x); };
| ^
test.cc:5:5: note: ‘int x’ declared here
5 | int x = 4;
| ^
捕获初始化表达式
自C++14开始,在捕获列表中可以使用初始化表达式,也就是说可以创建新的变量并在捕获子句中对其进行初始化。这种方式称之为带有初始化程序的捕获
或者广义lambda捕获
。
int main() {
int x = 1;
int y = 2;
auto fun = [z = x + y]() { printf("%d\n", z); };
fun();
return 0;
}
在上面的例子中,编译器生成一个新的成员变量并用x+y
对其进行初始化,也就是是说上面示例等价于:
int main() {
int x = 1;
int y = 2;
int z = x + y;
auto fun = [z]() { printf("%d\n", z); };
fun();
return 0;
}
混合捕获
混合捕获,还是比较好理解的,话不多说,直接上代码:
int main() {
int x = 1;
int y = 2;
auto fun = [x, &y](){
printf("%d, %d\n", x, ++y);
};
fun();
return 0;
}
在上述代码中,对x进行按值捕获,而堆y则进行按引用捕获。
编译器实现
经常看我文章的读者,可能发现我的文章有个特点,喜欢说明白底层实现,其实这也是C++开发人员的一个特点,知其然,更要知其所有然,毕竟知己知彼,方能百战不殆嘛。
好了,言归正传,开始聊聊lambda的底层实现。那么我们该如何知道编译器的底层是如何实现的呢?在这里推荐一个工具cppinsights
,是一款C++源代码到源代码的转换,它可以把C++中的模板、auto以及C++11新特性展开。通过使用cppinsights
,我们可以清楚地看到编译器做了哪些事情。
值捕获
仍然使用前面的代码,如下:
int main() {
int x = 5;
auto fun = [x]() { printf("%d\n", x); };
fun();
return 0;
}
cppinsights输出如下:
int main()
{
int x = 5;
class __lambda_8_14
{
public:
inline /*constexpr */ void operator()() const
{
printf("%d\n", x);
}
private:
int x;
public:
__lambda_8_14(int & _x)
: x{_x}
{}
};
__lambda_8_14 fun = __lambda_8_14{x};
fun.operator()();
return 0;
}
从上面内容,我们可以看出,编译器针对lambda会生成一个类__lambda_8_14,然后调用该类的成员函数:
-
__lambda_8_14为由编译器针对lambda函数生成的一个类
-
__lambda_8_14定义了一个成员变量x,其初始值为
-
__lambda_8_14重载operator()其函数体为lambda函数体(本例中为printf("%d\n", x))
-
源码中的fun在编译器实现之后,变成了一个__lambda_8_14对象
-
对fun函数的调用,变成了调用__lambda_8_14对象的operator()函数
如果捕获列表内容为[=],则类的private成员变量中会包含范围内的且在lambda中被使用的局部变量。假如有x和y两个变量,如果只使用了x这个变量,那么private成员变量就只有x,反之如果都使用了,则成员变量就变成了x和y。
如下代码:
int main() {
int x = 5;
int y = 6;
auto fun = [=]() { printf("%d, %d\n", x, y); };
fun();
return 0;
}
上述代码的lambda部分,经过编译器编译之后,会变成如下:
class __lambda_9_14
{
public:
inline /*constexpr */ void operator()() const
{
printf("%d, %d\n", x, y);
}
private:
int x;
int y;
public:
__lambda_9_14(int & _x, int & _y)
: x{_x}
, y{_y}
{}
};
在捕获列表中使用[=]
,但是lambda实现体内只使用变量x,那么编译器又将如何操作呢?
int main() {
int x = 5;
int y = 6;
auto fun = [=]() { printf("%d\n", x); };
fun();
return 0;
}
编译器对lambda部分的实现如下所示:
class __lambda_9_14
{
public:
inline /*constexpr */ void operator()() const
{
printf("%d\n", x);
}
private:
int x;
public:
__lambda_9_14(int & _x)
: x{_x}
{}
};
上述输出中可见,对于[=]
捕获方式,如果函数体内没有使用的变量,编译器不会生成对应的成员变量。
引用捕获
在上述值列表中,编译器会生成对应的成员变量,这样成员变量是对值列表中对应变量的一个拷贝
,那么如果是引用列表,则成员变量则是对应变量的一个引用
。
int main() {
int x = 5;
auto fun = [&x]() { printf("%d\n", ++x); };
fun();
printf("%d\n", x);
return 0;
}
lambda部分经过编译器操作之后,如下:
class __lambda_8_14
{
public:
inline /*constexpr */ void operator()() const
{
printf("%d\n", ++x);
}
private:
int & x;
public:
__lambda_8_14(int & _x)
: x{_x}
{}
};
可以看到,成员变量部分是引用列表中的引用,即int &x
。
如果列表为[&]
,则编译器将会生成对应变量的引用,规则与值列表类似,在此不再赘述。
C++中,const对成员变量的影响是:- const函数不能修改普通成员变量的值
- 但可以修改可变(mutable)成员变量
- 也可以修改引用类型的成员变量(因为引用本身是可以修改的)所以引用类型的n作为成员变量,不受const约束,可以在const operator()中被修改。这就是编译器특意设置的一个机制,来实现通过引用捕获访问外部变量的效果。
总结一下:
1. 引用捕获使外部变量成为lambda类的引用型成员
2. 引用类型成员不受const约束
3. 所以可以在const operator()中修改,所以引用捕获不用加mutable
mutable关键字
在前面内容中,可以看到,无论是按值捕获还是按引用捕获,编译器都会生成一个成员函数operator()
,且被声明为const
,这也就意味着不能修改成员变量。
如果要修改此行为,则需要在参数列表后添加mutable
关键字,这样就可以将const从operator()函数的声明中去除。
int main() {
int x = 5;
auto fun = [x]() mutable { printf("%d\n", ++x); };
fun();
return 0;
}
上述lambda在编译器中的实现如下:
class __lambda_8_14
{
public:
inline /*constexpr */ void operator()()
{
printf("%d\n", ++x);
}
private:
int x;
public:
__lambda_8_14(int & _x)
: x{_x}
{}
};
混合捕获
混合列表是值列表和引用列表的一种组合,了解了这两种实现,混合列表的编译器实现就更好理解了。
int main() {
int x = 1;
int y = 2;
auto fun = [x, &y](){
printf("%d, %d\n", x, ++y);
};
fun();
return 0;
}
lambda部分编译器的底层实现如下:
class __lambda_9_14
{
public:
inline /*constexpr */ void operator()() const
{
printf("%d, %d\n", x, ++y);
}
private:
int x;
int & y;
public:
__lambda_9_14(int & _x, int & _y)
: x{_x}
, y{_y}
{}
};
生成规则
看了前面的内容,lambda编译器的底层实现基本有了一个初步的认识,借助此文,将这个规则整理下:
编译器对lambda的生成规则如下:
-
lambda表达式中的捕获列表,对应lambda_xxxx类的private 成员
-
lambda表达式中的形参列表,对应lambda_xxxx类成员函数 operator()的形参列表
-
lambda表达式中的mutable,对应lambda_xxxx类成员函数 operator() 的常属性 const,即是否是常成员函数
-
lambda表达式中的返回类型,对应lambda_xxxx类成员函数 operator() 的返回类型
-
lambda表达式中的函数体,对应lambda_xxxx类成员函数 operator() 的函数体
效率
作为cpp开发人员,最关心的是性能问题。有些读者看完编译器对lambda的实现之后,感觉这么复杂的代码会不会效率很低?为了打消读者的疑虑,在本节中将从汇编
角度进行分析。
我们以下面代码为例:
int main() {
int x = 1;
auto fun = [x](){
printf("%d\n", x);
};
fun();
return 0;
}
使用-std=c++17 -stdlib=libc++ -O3
优化之后,汇编代码如下:
main: # @main
push rax
mov edi, offset .L.str
mov esi, 1
xor eax, eax
call printf
xor eax, eax
pop rcx
ret
.L.str:
.asciz "%d\n"
从上述汇编代码可以看出,经过编译器优化之后,效率非常高,所以我们上面的担心完全是多余的。