0. 什么是lambda表达式
《C++ Primer(第5版)》对 lambda表达式(lambda expression) 的定义为:
一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个未命名的内联函数。
lambda表达式的形式如下:
[capture list](parameter list) -> return type { function body }
- 捕获列表,capture list
- 参数列表,parameter list
- 返回类型,return type
- 函数体,function body
lambda表达式是可调用对象(callable object) 的一种。
1. 如何使用lambda表达式
本节将宏观的介绍几种lambda表达式的用法,对整体有一个认知。
首先介绍谓词——lambda最广泛的应用之一,然后介绍如何向谓词传递lambda表达式以及简单的使用捕获列表。
1.1 向函数传递谓词
谓词(predicate) 是一个可调用的表达式,其返回结果是一个能用作条件的值。
C++标准库中的泛型算法大多使用了谓词,允许我们自己定义一些操作。例如:
bool shorter(const string &s1, const string &s2) {
return s1.size() < s2.size();
}
// 假设A是一个包含string的vector
std::sort(A.begin(), A.end(), shorter)
// sort会根据A中每个string的size来排序
std::sort
的第三个参数,就是一个谓词类型。我们传递给std::sort
的是函数名shorter
,因为函数也是一个可调用对象。
std::sort
会使用shorter
定义的操作代替<
,来比较容器中的元素进行排序。
从一定程度上说,谓词帮助我们拓展了函数的功能,定制操作。若按<
来排序string
,仅仅是按照字母表排序;现在可以使用字符串的长度来排序,甚至是先按照长度,再按照字母表。
谓词分为一元谓词和二元谓词,即在调用谓词时传递给谓词的参数是1个还是2个。
谓词和可调用对象的区别就是,谓词是一个约束了参数列表和输出类型的可调用对象,在向谓词传递可调用对象时必须符合谓词的约束。比如必须传递两个参数,返回布尔类型(这一点和运算符的感觉比较像)。
1.2 lambda表达式可以作为谓词
可调用对象一共有四种,因此向谓词传递函数、函数指针、重载了函数调用运算符的类和lambda表达式都可以。本文的重点是lambda表达式。
我们使用lambda表达式将shorter
函数重写:
std::sort(A.begin(), A.end(),
[](const string &a, const string &b)
-> bool
{ return a.size() < b.size(); });
我们列出第0节介绍的lambda表达式的四部分:
- 捕获列表,这里为空
[]
- 参数列表,两个参数,
const string &a, const string &b
- 返回类型,
bool
类型 - 函数体,
return a.size() < b.size();
OK,lambda表达式就是这么写。
当然还可以举一个传递一元谓词的例子,比如find_if
:
// 代码获得A中第一个长度>=3的元素的迭代器
auto res = std::find_if(A.begin(), A.end(),
[](const string &a)
-> bool
{ return a.size() >= 3; });
- 捕获列表,这里为空
[]
- 参数列表,一个参数,
const string &a
- 返回类型,
bool
类型 - 函数体,
return a.size() >= 3;
1.3 省略更优美
进一步,就是lambda表达式的省略形式:
- 如果参数列表为空,则可以省略
- 返回类型也可以省略(通常都省略),可以从函数体中推导出来,但是函数体必须简单
// 省略参数列表
auto f = [] { return 42; };
// 省略返回类型
auto res = std::find_if(A.begin(), A.end(),
[](const string &a)
{ return a.size() >= 3; });
这里假如我想返回的是长度大于等于某个变量sz
的值,该怎么修改?
1.4 使用捕获列表
写成return a.size() >= sz;
?
sz
哪里来的呢?用参数传递进来可以吗?写成下面这样试试:
[](const string &a, int sz) -> bool { return a.size() >= sz; }
这样定义一个lambda表达式没问题,只不过这个表达式不能在find_if
里面使用了,因为find_if
约束,最后一个谓词类型的参数列表只能有一个参数。
此时就要用到捕获列表了。
通过捕获列表,我们可以在lambda表达式中,使用上层作用域的局部非static变量。
还是find_if
:
vector<string>::iterator find_sz_bigger(vector<string> &A, int sz) {
return std::find_if(A.begin(), A.end(),
[sz](const string &a)
-> bool
{ return a.size() >= sz; });
}
这儿的重点就是sz
。
在上层作用域find_sz_bigger
函数中,我们有一个形参sz
,它是一个局部变量,函数调用结束后它就被销毁了。
在lambda表达式的捕获列表中,我们填写了一个sz,意思就是捕获了find_sz_bigger
作用域中名为sz
对象的值,在lambda表达式的函数体中,我们可以使用sz
。
注意此处的捕获方式,是把sz
的值拿了过来,即按值传递(当然还有别的捕获方式,见第2节)。
1.5 小结
到目前你已经学会了如何简单的使用lambda表达式了。
不仅如此,你还了解了如下内容:
- 谓词,std中的算法很多都使用,如
find_if
,sort
- 可调用对象,只有四种
- lambda表达式,一种可调用对象,给std中的算法传递谓词很方便
- 捕获列表,这是lambda表达式和其他的可调用对象区别比较大的地方
- 省略形式更优美
接下来我们来看看lambda表达式的细节,以及注意事项。
2. 深入捕获列表
捕获列表指引lambda在其内部包含访问局部变量所需的信息。
捕获列表捕获变量的方式有三种:
- 值捕获
- 引用捕获
- 隐式捕获
2.1 值捕获
与函数调用值传递类似,采用值捕获的前提是变量可以拷贝。与函数参数不同,被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝。
例如:
void fcn1() {
size_t v1 = 42;
auto f = [v1] { return v1; };
v1 = 0;
auto j = f(); // j为42
}
在lambda创建时拷贝,随后我们修改v1
的值,不会影响f()
调用的结果。
值传递还包括指针的情况,因此若捕获的是某个对象的指针,一定要注意指针的指向一定是有效的,必须确保被指向的对象在lambda执行的时候是存在的。
使用值捕获得到的对象(例如v1
),我们不能在lambda函数体中修改它的值。
若希望修改它的值,必须使用mutable
关键字修饰lambda表达式。
void fcn2() {
size_t v1 = 42;
auto f = [v1]() mutable { return ++v1; };
v1 = 0;
auto j = f(); // j为43
}
此时的lambda称为可变lambda,形参列表不能省略()
。
2.2 引用捕获
一个以引用方式捕获的变量与其他任何类型的引用的行为类似:
void fcn3() {
size_t v1 = 42;
auto f = [&v1] { return v1; };
v1 = 0;
auto j = f(); // j为0
}
如果我们采用引用方式捕获一个变量,就必须确保被引用的对象在lambda执行的时候是存在的。
lambda捕获的都是局部变量,在上层代码块结束后局部变量就会被销毁,因此使用lambda表达式捕获引用变量一定要注意。
引用捕获的必要性在于捕获那些不能拷贝的对象,例如ostream
。
使用引用捕获得到的对象,是否可以修改,要根据源对象是否有const
。
2.3 隐式捕获
除了上述2种方式——我们显示的在捕获列表中说明捕获的对象(显示捕获)——之外,还可以让编译器根据lambda函数体中的代码来推断我们要使用哪些变量,这就是隐式捕获。
为了指示编译器推断捕获列表,需要在捕获列表中写一个=
或者&
,分别代表函数体中使用的变量捕获的方式为值捕获或者引用捕获。
例如之前的例子:
int sz = 3;
// 函数体中出现了sz
// 捕获列表中使用了 =
// 函数体中sz使用值捕获
auto it = std::find_if(A.begin(), A.end(),
[=](const string &a)
{ return a.size() >= sz; });
还可以混合显示捕获和隐式捕获:
- 捕获列表第1个参数放置
=
或者&
,代表默认捕获方式; - 捕获列表其余的参数,均采用和默认捕获方式相异的方式。
例如:
ostream &os = std::cout;
char c = ' ';
// os采用默认的引用捕获
// c采用值捕获
std::for_each(A.begin(), A.end(),
[&, c](const string &s)
{ os << s << c; });
// c采用默认的值捕获
// os采用引用捕获
std::for_each(A.begin(), A.end(),
[=, &os](const string &s)
{ os << s << c; });
注意:引用捕获不论何时都需要&
标志,如上面的第二个例子,即使默认是值捕获,其后的参数也要声明&
。
2.4 小结
捕获方式分为显示捕获、隐式捕获和混合式捕获。
显示捕获包括值捕获和引用捕获。
隐式捕获使用=
或者&
设置函数体中所有对象的捕获方式为值捕获或者引用捕获。
混合式捕获第一个参数放置默认捕获方式,其余参数与默认捕获方式相异。
值捕获得到的对象在lambda函数体中不可以修改,可以使用mutable
。
尽量避免引用捕获。
3. 深入返回类型
3.1 省略时一定只有return语句
lambda表达式的返回类型通常都是省略的,这是因为lambda表达式面向的编程需求,就是函数体尽可能的短小精悍(若函数功能复杂,尽量使用其他可调用对象来实现)、一目了然的。
但是注意,如果我们一边省略了返回类型,一边在lambda函数体中包含了return语句之外的任何语句,编译器会假定此lambda返回void
。
例如:
// 假设vi是vector<int>
// 这是一个正确的例子
std::transform(vi.begin(), vi.end(), vi.begin(),
[](int i)
{ return i < 0 ? -i : i; });
上述代码实现了将vi
中的所有元素取绝对值。
三元运算符的等价形式是if else
语句:
// ERROR: 这是一个错误的例子
std::transform(vi.begin(), vi.end(), vi.begin(),
[](int i)
{ if (i < 0) return -i; else return i; });
上述代码产生编译错误,错误的原因是transform
的第四个参数类型不匹配。不匹配的原因是lambda表达式的返回结果是void
。
返回是void
的原因是我们省略了返回类型,在函数体中书写了return语句之外的内容,编译器推断lambda的返回类型为void
。
此时必须显示定义返回类型:(尾置形式)
// 这是一个正确的例子
std::transform(vi.begin(), vi.end(), vi.begin(),
[](int i) -> int
{ if (i < 0) return -i; else return i; });
// 但是有三元运算符,
// 尽量不要使用if else分支
// 让程序执行效率更高
上述代码是对的,但是尽量使用三元运算符。
返回类型可以是值类型,也可以是引用类型。
返回引用类型也需要注意调用点处返回的对象必须要存在。
4. 编译器如何看待lambda
4.1 两个“未命名”
我们看到的lamdba表达式是由[]
、()
、->
以及{}
组合起来的漂亮形式;编译器却为lambda表达式生成了一个对应的新的未命名类类型,并且把它变成了一个该未命名类类型的未命名函数对象。
这里的关键词,我们逐个解析:
- 未命名类类型:类类型都知道,就是class
- 未命名函数对象:如果一个类定义了调用运算符,那么该类的对象就称作函数对象
现在明白了,回想可调用对象,其中有一个就是重载了函数调用运算符的类,它的官名就是函数对象。
意思就是编译器将lambda编译成了一个新类型,和新类型的对象。
我们可以将之前的例子改写一下,例如:
std::sort(A.begin(), A.end(),
[](const string &a, const string &b)
{ return a.size() < b.size(); });
它的未命名类类型是:
class XXXXXXX {
public:
bool operator() (const string &a, const string &b) const
{ return a.size() < b.size(); }
}
std::sort(A.begin(), A.end(), XXXXXXX());
虽然定义上说lambda表达式可以被理解为“一个未命名的内联函数”,但实际编译器却使用函数对象来实现。
现在真相大白了。
4.2 捕获列表是什么
捕获列表是如何变身为函数对象中的内容的?
引用捕获不需要变为函数对象中的数据成员,在调用lambda时,编译器直接将引用源的值拿来使用(这也是引用的意义,外部改变了lamdba内部也要改变)。
值捕获会创建一个数据成员,以及初始化该数据成员的构造函数。
例如:
int sz = 3;
auto it = std::find_if(A.begin(), A.end(),
[sz](const string &a)
{ return a.size() >= sz; });
它的未命名类类型是:
class XXXXXXX {
private:
int sz;
public:
XXXXXXX(int x1): sz(x1) { }
bool operator() (const string &a, const string &b) const
{ return a.size() < b.size(); }
}
auto it = std::find_if(A.begin(), A.end(), XXXXXXX(sz));
这也是值捕获的意义,在创建时刻捕获到程序中某些值,不论外部如何修改,我只需要那个时刻的值。
因此在2.4节中提到的“尽量避免引用捕获”也是出于此分析。
现在真相大大白了。
4.3 合成的函数还有吗
我们知道,若一个类没有默认构造函数、析构函数、以及赋值运算符,C++编译器会为类合成默认的相关函数。
但是lambda表达式产生的类没有。
它是否含有默认的拷贝构造函数、移动构造函数会根据捕获的数据成员类型而定。
4.4 mutable lambda
使用mutable
修饰的lambda表达式,在转换为函数对象时,只需要把调用运算符的const
去掉即可。
5. 总结
一个lambda表达式表示一个可调用的代码单元。它由捕获列表、参数列表、返回类型、函数体四部分组成。
捕获列表可以捕获上层作用域中的对象的内容。
参数列表决定调用lambda表达式时所需的参数。
返回类型通常省略,但是注意除return语句之外的情况。
函数体通常精悍短小、一目了然。
可以使用mutable
修饰lamdba表达式,表明可以修改值捕获的对象的值。
lambda表达式是可调用对象的一种,编译器把它转换为未命名类类型的未命名函数对象来实现其功能。