谈谈C++的lambda表达式
基本概念
我能想到的在 C++ 中有 3 种调用代码段的方法:
- 函数指针,通过函数指针可以将一段代码作为函数参数传递。
- 仿函数(重载了函数运算符的对象)。
- Lambda 表达式。
函数指针就不说了,是 C 语言中十分常用的方式,许多系统级 API 都使用这种形式进行代码段的传递。仿函数类似于下面的代码:
1 | class Functor { |
定义了一个类,并对其函数调用运算符进行了重写,这样可以通过对象()
的形式进行调用。
使用 Lambda 表达式实现上面功能就十分简洁:
1 | div = 2; |
Lambda 表达式使编译器创建一个闭包,闭包是运行期对象,根据不同的捕获方式,闭包持有数据的副本或者引用。实际上就是编译器创建了一个上面写的仿函数对象,将繁杂的工作交给了编译器,同时使得我们的代码变得简洁了。
这里不讨论语法(实际上也很简单),读者可以查阅其它资料了解 Lambda 表达式的值捕获、引用捕获和默认捕获等概念,这里讨论一下使用时的注意点,都是在《Effective Modern C++》中提到的条款。
避免默认捕获
C++ 11 提供了值捕获和引用捕获两种默认捕获方式,分别是在捕获表达式中采用=
和&
符号,按照引用捕获非常容易出现悬空引用的后果,使用按照值捕获可能也会出现类似问题。
- 默认引用捕获
1 | void addDivisorFilter() { |
上面是按照默认默认引用捕获的方式,引用捕获了divisor
变量,但是divisor
是一个局部变量,离开addDivisorFilter
函数作用域之后被销毁,这样 Lambda 表达式闭包中持有了一个悬空引用,在调用 Lambda 表达式时将发生未定义行为!
实际上改为显式的引用捕获也存在同样的问题:
- 显式引用捕获
1 | filters.emplace_back([&divisor](int value) { |
没有解决上面的引用悬空问题,但是却能够清楚的看出该闭包是依赖于divisor
变量的引用的,程序员在编写时更不容易犯错,相比于[&]
要直观的多。
解决这个问题的方法就是采用值捕获:
1 | // 值捕获,将局部变量 divisor 复制一份进入闭包 |
那么使用默认值捕获是不是就安全了呢?答案是否定的。考虑下面例子:
- 默认值捕获
1 | class Widget { |
这个例子使用了默认值捕获,然而闭包中并不会持有一个 divisor
变量的复制,因为捕获只能对于在创建 Lambda 表达式的作用域内可见的非静态局部变量生效,也就是说这里根本没有捕获divisor
变量,换句话说,如果使用显式捕获的方式根本无法通过编译:
1 | // 无法通过编译 |
实际上,上面的默认值捕获捕获的并不是divisor
变量,而是Widget
对象的this
指针,等同于下面代码:
1 | auto ptr = this; |
这样导致闭包中含有一个指向 Widget 对象的指针,从而存在悬空的危险。因此使用显式捕获就不会有这个问题,如果想把成员变量复制进闭包中,可以先定义一个局部变量,然后按值捕获来解决:
1 | auto div = divisor; |
在 C++ 14 中,可以广义捕获(generalized lambda capture):
1 | filters.emplace_back([divisor=divisor](int value) { |
但不管怎么说,你都不应该使用默认值捕获。下面是使用默认值捕获得到意料之外结果的例子:
1 | void addDivisorFilter() { |
上面咋一看divisor
变量通过默认值捕获被复制进了闭包中,但实际上捕获对静态变量捕获是无效的,上面的默认捕获没有捕获任何东西,而闭包内部使用的divisor
变量就是外部的静态变量,因此改变这个变量会对 Lambda 表达式造成影响。
总之,使用默认捕获方式(无论是按值捕获还是按引用捕获)都有可能以你意料之外的方式工作,因此应该避免使用默认捕获方式。
使用 Lambda 表达式代替 std::bind
在 C++ 11 之前的代码中,经常能够看到使用 std::bind
来返回一个函数对象,std::bind
参数为一个函数对象和若干参数及占为符,能够对传入函数对象进行调用形式改造。
这里也是举一个常见用法的例子:
1 | class Channel : noncopyable{ |
另一个类:
1 | using namespace std; |
需要的函数是只有一个形参的void handleEvent(Timestamp t)
的形式,而我们已有的函数是EventLoop
类的成员函数,这个函数具有一个隐含的EventLoop*
类型的this
指针参数,为此使用std::bind
进行改造:
1 | bind(&EventLoop::handleRead, this, _1); // 返回形式就是只有一个参数的可调用对象 |
即使是比较有经验的程序员也需要对_1
,_2
这种占为符和实际参数的映射关系做一定的分析。改用 Lambda 表达式可以更加清楚简洁的实现上述效果:
1 | channel_->setReadCallback([this](Timestamp t){ |
除此之外,使用std::bind
时,将this
参数传入时,其生成的可调用对象需要对其进行保存,从std::bind
调用上来看看不出来是按引用还是按值复制的,只能按照std::bind
的原理知道是按值复制存储的,也就是相当于 Lambda 表达式的按值捕获了 this
变量。
同样,std::bind
返回的可调用对象中,参数的传递方式是按值还是引用呢?也不能从代码中看出来,答案是按照引用传递,其函数调用运算符采用的完美转发。
相反,使用 Lambda 表达式很容易看出this
变量是按值复制保存的(值捕获),返回的可调用对象参数传递方式也是显然的(按值传递)。
所以经过上述讨论,使用 Lambda 表达式相比于 std::bind
而言,可读性和表达能力更强,没有什么理由可以使用后者了(除非所在的工作小组 C++ 标准低于 C++ 14 或 C++ 11)。
总结
本篇博客讨论了 C++ 中的 Lambda 表达式,使用时需要注意区分不同的参数捕获方式,以及避免使用默认捕获方式,尽量使用 Lambda 表达式代替古老的 std::bind
写法,使用现代的一些工具写出更具可读性的代码。
参考文献
- 《Effective Modern C++》