与你共享小喵的心得与感悟

0%

C++ Lambda表达式

小喵的唠叨话: 寒假之后,小喵在家里无所事事,最近用C++写代码的时候,用到了std::sort这个函数,每次用这个函数,小喵似乎都得查一下lambda表达式的写法。正好最近很闲,不如总结一下。 在Bing上搜索_C++ lambda_,第一条记录就是MSDN上的C++ lambda的介绍。本文也是基于这篇文章来写的。 那么接下来,我们分几个部分来介绍。

一、什么是Lambda表达式

MSDN上对lambda表达式的解释:

在 C++ 11 中,lambda 表达式(通常称为 "lambda")是一种在被调用的位置或作为参数传递给函数的位置定义匿名函数对象的简便方法。 Lambda 通常用于封装传递给算法或异步方法的少量代码行。[1]

看了这个解释,相信大家已经理解lambda表达式是什么。简而言之,lambda表达式就是一种定义函数的简单的方法。

举一个简单的例子:求一个数的阶乘。这是一般的函数的写法:

1
2
3
4
5
6
7
// 这里要求n>=0,同时n的取值不能太大,会溢出
// 为了方便,这里并没有处理上面说到的问题
int factorial(int n) {
int fact = 1;
for (int i = 1; i <= n; ++ i) fact *= i;
return fact;
}

Lambda表达式的写法:

1
2
3
4
5
auto factorial = [](int n) {
int fact = 1;
for (int i = 1; i <= n; ++ i) fact *= i;
return fact;
};

乍一看,这两种定义方式十分的相似。但其实这是两种完全不同的方式,前一种是函数定义式,而后一种是一个表达式。factorial是变量名,等于号后面的是值,也就是一个lambda表达式,本质上是一个匿名的函数。最终factorial就是一个函数。

很多时候,我们只是直接书写lambda表达式,而不需要给他一个名字。比如排序的时候,sort可以接受一个自定义的比较函数,这时候直接书写lambda表达式即可。

二、Lambda表达式的作用

由于lambda本身其实也就是一种函数的定义方式。因此它的主要作用还是和一般函数一样。但是lambda表达式相对于一般函数,又有一些功能之外的作用。参考了知乎上的一些回答[2],小喵也进行了总结。 1) 可以用表达式来定义函数,这样使得函数的定义和调用在一起,语意和逻辑上更为紧凑。同时,对于只是用一次的短小的函数,直接调用匿名的lambda表达式是最好的选择,这样就不需要给每个函数起名字了。/* 起名字一直是一个很令人头疼的问题 */ 2) 闭包(Closure)。这个小喵的写javascript的时候时常会用到。闭包本质上就是能够访问上下文环境中变量的代码块。

这里我们简单的举个例子,还是之前的求阶乘的问题,现在我们有些提高需求。 现在需要完成下面的三种阶乘的运算:

1
2
3
n! = n * (n - 1) * (n - 2) * ...
n!! = n * (n - 2) * (n - 4) * ...
n!!! = n * (n - 3) * (n - 6) * ...

要求编写3个函数,分别完成上述3种计算。 使用一般的方式写很容易实现,我们这里直接使用lambda表达式来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <functional>

std::function<int(int)> getFactorialFunc(int n) {
return [n](int x) {
int fact = 1;
for (; x >= 1; x -= n) fact *= x;
return fact;
};
}

int main() {
// 构造要求的三个函数
auto factorial1 = getFactorialFunc(1);
auto factorial2 = getFactorialFunc(2);
auto factorial3 = getFactorialFunc(3);
// 调用
std::cout << factorial1(10) << std::endl;
std::cout << factorial2(10) << std::endl;
std::cout << factorial3(10) << std::endl;
}

编译的时候要注意,lambda表达式是C++11开始支持的,所以需要指定一下C++的版本。

1
g++ factorial_lambda.cpp -o factorial_lambda.out --std=c++11

运行之后的结果为:

1
2
3
4
./factorial_lambda.out
3628800
3840
280

这里作为返回值的lambda表达式,可以访问先前传入的参数,这也就是闭包。具体的语法,我们后面会讲到。

  1. 柯里化(Currying)。这部分小喵也是第一次接触,维基百科有如下解释:

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。[3]

下面给出一个例子(也是实现之前的阶乘):

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
#include <iostream>
#include <functional>

// 两个参数的阶乘
int factorial(int n, int step) {
int r = 1;
for (; n >= 1; n -= step) {
r *= n;
}
return r;
}

// curring化的阶乘
std::function<int(int)> currying_factorial(int step) {
return [step](int n) {
return factorial(n, step);
};
}

int main() {
// 调用普通函数
std::cout << factorial(10, 1) << std::endl;
std::cout << factorial(10, 2) << std::endl;
std::cout << factorial(10, 3) << std::endl;

// 调用currying函数
std::cout << currying_factorial(1)(10) << std::endl;
std::cout << currying_factorial(2)(10) << std::endl;
std::cout << currying_factorial(3)(10) << std::endl;
return 0;
}
  1. lambda表达式整体可以被当做函数的参数或者返回值。 闭包和currying的例子就是将整个lambda表达式作为返回值。现在再举一个作为参数的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <functional>

int operate(int x, int y, const std::function<int(int, int)> &op) {
return op(x, y);
}

int main() {
auto add = [](int x, int y) { return x + y;};
auto mul = [](int x, int y) { return x - y;};

std::cout << operate(10, 5, add) << std::endl;
std::cout << operate(10, 5, mul) << std::endl;

return 0;
}

运行的结果:

1
2
15
5

其实函数也可以当参数传入的(函数指针),但是lambda表达式要更为直观和灵活一些。谁能一眼看出int (*func(int))(int)究竟是什么意思呢(这是一个函数的定义,输入的参数是int,返回值是一个函数指针,函数指针对应的函数的输入和输出类型都是int)。

三、Lambda表达式的语法

看到前面的lambda表达式的各种有趣的功能,现在是不是非常迫切的想尝试一把?

ISO C++ 标准展示了作为第三个参数传递给 std::sort() 函数的简单 lambda:

1
2
3
4
5
6
7
8
9
10
11
#include <algorithm>  
#include <cmath>

void abssort(float* x, unsigned n) {
std::sort(x, x + n,
// Lambda expression begins
[](float a, float b) {
return (std::abs(a) < std::abs(b));
} // end of lambda expression
);
}

lambda表达式的组成部分见下图:

lambda expression syntax

  1. Capture 子句(在 C++ 规范中也称为 lambda 引导。)
  2. 参数列表(可选)。 (也称为 lambda 声明符)
  3. 可变规范(可选)。
  4. 异常规范(可选)。
  5. 尾随返回类型(可选)。
  6. “lambda 体”

接下来我们需要学习这6个部分。

1、Capture 子句

我们知道,一般情况下,函数只能访问自己的参数和外部的全局变量。而lambda表达式却可以访问上下文的变量(参见闭包的例子)。那么如何指定要访问的变量,以及访问的方式(值或者引用)呢?这就是Capture 子句要解决的问题。

Lambda 可在其主体中引入新的变量(用 C++14),它还可以访问(或“捕获”)周边范围内的变量。LambdaCapture 子句(标准语法中的 lambda 引导)开头,它指定要捕获的变量以及是通过值还是引用进行捕获:

  • 有与号 (&) 前缀的变量通过引用访问,没有该前缀的变量通过值访问。
  • capture 子句 [ ] 指示 lambda 表达式的主体不访问封闭范围中的变量。可以使用默认捕获模式(标准语法中的 capture-default)来指示如何捕获 lambda 中引用的任何外部变量:
    • [&] 表示通过引用捕获引用的所有变量
    • [=] 表示通过值捕获它们。
  • 可以使用默认捕获模式,然后为特定变量显式指定相反的模式。

例如,如果 lambda 体通过引用访问外部变量 total 并通过值访问外部变量 factor,则以下 capture 子句等效:

1
2
3
4
5
6
[&total, factor]
[factor, &total]
[&, factor]
[factor, &]
[=, &total]
[&total, =]

我们之前的闭包中使用的就是通过值访问。

  • 使用 capture-default 时,只有 lambda 中提及的变量才会被捕获。
  • 如果 capture 子句包含 capture-default &,则该 capture 子句的 identifier 中没有任何 capture 可采用 & identifier 形式。
  • 同样,如果 capture 子句包含 capture-default =,则该 capture 子句的 capture 不能采用 = identifier 形式。
  • identifier 或 this 在 capture 子句中出现的次数不能超过一次。

以下代码片段给出了一些示例。

1
2
3
4
5
6
7
8
9
struct S { void f(int i); };  

void S::f(int i) {
[&, i]{}; // OK
[=, &i]{}; // OK
[&, &i]{}; // ERROR: i preceded by & when & is the default
[=, this]{}; // ERROR: this when = is the default
[i, i]{}; // ERROR: i repeated
}

capture 后跟省略号是包扩展,如以下可变参数模板[4]示例中所示:

1
2
3
4
5
template<class... Args>  
void f(Args... args) {
auto x = [args...] { return g(args...); };
x();
}

要在类方法的正文中使用 lambda 表达式,需要将 this 指针传递给 Capture 子句,以提供对封闭类的方法和数据成员的访问权限。

这里大家可能觉得有点奇怪,将this指针传给Capture子句?

其实我们常使用的成员函数也是用类似的方法实现的。我们知道,使用成员函数需要有一个类实例,但是调用类函数就不需要。这是因为成员函数的第一个参数是this,当然这个参数我们编写代码的时候不需要自己手动写出,而是默认的。使用像python这样的语言的时候就是需要显示的写出的。在使用类实例调用成员函数的时候,会默认将this指针传入。成员函数有这么一个参数,就可以访问类实例的各种变量和方法。而类函数是没有这个参数的,也就是没有this这个指针,因此它的调用并不需要类实例,当然也就不能访问类实例的变量。

在使用 capture 子句时,要记住以下几点(尤其是使用采取多线程的 lambda 时):

  1. 引用捕获可用于修改外部变量,而值捕获却不能实现此操作。(mutable允许修改副本,而不能修改原始项。)
  2. 引用捕获会反映外部变量的更新,而值捕获却不会反映。
  3. 引用捕获引入生存期依赖项,而值捕获却没有生存期依赖项。当 lambda 以异步方式运行时,这一点尤其重要。 如果在异步 lambda 中通过引用捕获本地变量,该本地变量将很可能在 lambda 运行时消失,从而导致运行时访问冲突。

通用捕获 (C++14) 在 C++14 中,可在 Capture 子句中引入并初始化新的变量,而无需使这些变量存在于 lambda 函数的封闭范围内。

初始化可以任何任意表达式表示;且将从该表达式生成的类型推导新变量的类型。此功能的一个好处是,在 C++14 中,可从周边范围捕获只移动的变量(例如 std::unique_ptr)并在 lambda 中使用它们。

1
2
3
4
5
6
pNums = make_unique<vector<int>>(nums);  
//...
auto a = [ptr = move(pNums)]()
{
// use ptr
};

2、参数列表

除了捕获变量,lambda 还可接受输入参数。 参数列表(在标准语法中称为 lambda 声明符)是可选的,它在大多数方面类似于函数的参数列表。

1
2
3
4
auto add = [] (int first, int second)  
{
return first + second;
};

在 C++14 中,如果参数类型是泛型,则可以使用 auto 关键字作为类型说明符。 这将告知编译器将函数调用运算符创建为模板。参数列表中的每个 auto 实例等效于一个不同的类型参数。

1
2
3
4
auto add = [] (auto first, auto second)  
{
return first + second;
};

lambda 表达式可以将另一个 lambda 表达式作为其参数。

由于参数列表是可选的,因此在不将参数传递到 lambda 表达式,并且其 lambda-declarator: 不包含 exception-specificationtrailing-return-type 或 mutable 的情况下,可以省略空括号。

1
[]{}; // 这就是最简单的lambda表达式

3、可变规范

通常,lambda 的函数调用运算符为 const-by-value,但对 mutable 关键字的使用可将其取消。它不会生成可变的数据成员。利用可变规范,lambda 表达式的主体可以修改通过值捕获的变量。本文后面的一些示例将显示如何使用 mutable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
int main()
{
int n = 10;
auto lambda1 = [n](int x) {
/* ++ n; */ // 这句编译会出错,错误信息如下:
// error: cannot assign to a variable captured
// by copy in a non-mutable lambda
return x + n;
};
auto lambda2 = [n](int x) mutable {
++ n;
return x + n;
};
std::cout << lambda1(5) << " " << n << std::endl;
std::cout << lambda2(5) << " " << n << std::endl;
return 0;
}

输出的结果是:

1
2
15 10
16 10

可以看出n确实是通过值来访问,在lambda1中,我们运行++ n,在编译的时候会报错。使用mutable修饰之后,就可以修改参数(副本)的值。

4、异常规范

你可以使用 throw() 异常规范来指示 lambda 表达式不会引发任何异常。与普通函数一样,如果 lambda 表达式声明 C4297 异常规范且 lambda 体引发异常,Visual C++ 编译器将生成警告 throw(),如下所示:

1
2
3
4
5
6
// throw\_lambda\_expression.cpp  
// compile with: /W4 /EHsc
int main() // C4297 expected
{
[]() throw() { throw 5; }();
}

在MSDN的异常规范[5]中,明确指出异常规范是在 C++11 中弃用的 C++ 语言功能。因此这里不建议大家使用。

5、返回类型

将自动推导 lambda 表达式的返回类型。无需使用 auto 关键字,除非指定尾随返回类型trailing-return-type 类似于普通方法或函数的返回类型部分。但是,返回类型必须跟在参数列表的后面,你必须在返回类型前面包含 trailing-return-type 关键字 ->。 如果 lambda 体仅包含一个返回语句或其表达式不返回值,则可以省略 lambda 表达式返回类型部分。如果 lambda 体包含单个返回语句,编译器将从返回表达式的类型推导返回类型。否则,编译器会将返回类型推导为 void

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <typeinfo>
int main() {
auto lambda1 = [](int i) {return i;};
auto lambda2 = [](int i) -> bool {return i;};
auto lambda3 = [](int i) -> float {return i;};
/* auto lambda4 = []{ return {1, 2}; };*/ // ERROR: return type is void
// cannot deduce lambda return type

auto x1 = lambda1(10);
auto x2 = lambda2(10);
auto x3 = lambda3(10);

std::cout << x1 << " " << typeid(x1).name() << std::endl;
std::cout << x2 << " " << typeid(x2).name() << std::endl;
std::cout << x3 << " " << typeid(x3).name() << std::endl;
return 0;
}

typeinfo的功能是获取一个变量的类型,由于它的实现依赖于编译器,所以在不同平台下的输出可能不完全一样。小喵这边的输出是:

1
2
3
10 i
1 b
10 f

可以看出,三个lambda的输出是不相同的。默认情况下,会返回一个最直接的类型。

6、lambda体

lambda体其实和函数体几乎完全相同。 lambda 表达式lambda 体(标准语法中的 compound-statement)可包含普通方法或函数的主体可包含的任何内容。普通函数和 lambda 表达式的主体均可访问以下变量类型:

  • 从封闭范围捕获变量,如前所述(Capture)。
  • 参数
  • 本地声明变量
  • 类数据成员(在类内部声明并且捕获 this 时)
  • 具有静态存储持续时间的任何变量(例如,全局变量)

这里要注意我们在Capture 规范中说到的值访问和引用访问的特点。

下面的例子都是MSDN上给出的。以下示例包含显式捕获变量 n 和引用隐式捕获变量 m 的 lambda 表达式:

1
2
3
4
5
6
7
8
9
10
11
12
// captures_lambda_expression.cpp  
// compile with: /W4 /EHsc
#include <iostream>
using namespace std;

int main()
{
int m = 0;
int n = 0;
[&, n] (int a) mutable { m = ++n + a; }(4);
cout << m << endl << n << endl;
}

输出结果:

1
2
5
0

由于变量 n 是通过值捕获的,因此在调用 lambda 表达式后,变量的值仍保持 0 不变。  mutable 规范允许在 lambda 中修改 n

尽管 lambda 表达式只能捕获具有自动存储持续时间的变量,但你可以在 lambda 表达式的主体中使用具有静态存储持续时间的变量。

以下示例使用 generate 函数和 lambda 表达式为 vector 对象中的每个元素赋值。 lambda 表达式将修改静态变量以生成下一个元素的值。

1
2
3
4
5
6
7
8
9
10
11
void fillVector(vector<int>& v)  
{
// A local static variable.
static int nextValue = 1;

// The lambda expression that appears in the following call to
// the generate function modifies and uses the local static
// variable nextValue.
generate(v.begin(), v.end(), [] { return nextValue++; });
//WARNING: this is not thread-safe and is shown for illustration only
}

四、应用Lambda的比较函数的编写

为什么要补充这一部分呢?因为我们在写程序的时候,往往最常用到lambda的地方就是数组的sort。 首先,我们知道std::sort默认是接受2个参数的,表示需要排序的序列的开始和结尾。对于一些复杂的数据类型,我们可以给它添加一个用来比较的函数 operator <。但更多的是通过给sort添加第三个参数来实现。而这个参数就是一个比较器。

sort默认使用 < 比较符来进行比较,排序的结果是升序。我们写的比较函数的功能就是代替 <。记住这个特点,就不会在编写比较函数的时候理不清思路。

这里举一个小例子,给一组点坐标,按欧氏距离排序:

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

int main() {
vector< pair<int, int> > arr;
arr.push_back(make_pair(1, 4));
arr.push_back(make_pair(2, 3));
arr.push_back(make_pair(5, 7));
arr.push_back(make_pair(6, 2));

sort(arr.begin(), arr.end(),
[](pair<int, int> left, pair<int, int> right) {
int d1 = left.first * left.first + left.second * left.second;
int d2 = right.first * right.first + right.second * right.second;
return d1 < d2;
});

for (auto &p: arr) {
cout << "(" << p.first << ", " << p.second << ")" << endl;
}
return 0;
}

输出结果:

1
2
3
4
(2, 3)
(1, 4)
(6, 2)
(5, 7)

唯一需要注意的是,我们的比较函数取代的是 <

至此,关于Lambda 表达式的介绍也就结束了。说得不够详细的地方,请大家参考MSDN的官方文档,写的真心不错。小喵这里有一些地方都是直接照搬过来的。https://msdn.microsoft.com/zh-cn/library/dd293608.aspx

五、补充

最近在类中使用了lambda表达式,结果出现奇怪的问题,故记录下来。lambda表达式可以允许捕获局部变量,但是类成员变量并不是局部变量,因此不能被lambda捕获。我们想要在lambda中使用类成员变量的话,只需要捕获this指针就可以。之后在lambda函数体中就可以随时使用成员变量了。对于this的捕获,永远是值传递的方式,即使指定了默认捕获的方式为引用,另外[&this]这样的捕获方式是不允许的。

转载请注明出处~

  • [1] https://msdn.microsoft.com/zh-cn/library/dd293608.aspx
  • [2] https://www.zhihu.com/question/20125256
  • [3] https://zh.wikipedia.org/wiki/柯里化
  • [4] https://msdn.microsoft.com/zh-cn/library/dn439779.aspx
  • [5] https://msdn.microsoft.com/zh-cn/library/wfa0edys.aspx
一杯奶茶也是心意~