《EffectiveModernC-》第六章-Lambda表达式
第六章 lambda表达式
Item31:避免使用默认捕获模式
lambda一大作用就是简化了谓词繁琐的创建
1 |
|
标题提到: 避免使用默认捕获模式,lambda中有两种默认捕获模式,按引用捕获和按值捕获。
其中按引用捕获很容易造成悬空引用的问题,例如:
1 |
|
Divisor会在函数AddFilterToContainer()
结束后生命周期结束,如果后面再使用到filters里的这个闭包,此时默认捕获的Divisor会造成悬空引用。
从长期来看,显式列出lambda依赖的局部变量和形参,是更加符合软件工程规范的做法。
额外提一下,C++14支持了在lambda中使用auto
来声明变量
1 |
|
假设在一个Widget
类,可以实现向过滤器的容器添加条目:
1 |
|
这是Widget::addFilter
的定义:
下面代码做法看起来是安全的代码。lambda依赖于divisor
,但默认的按值捕获确保divisor
被拷贝进了lambda对应的所有闭包中,但却有很大问题。
1 |
|
实际上上面的用法是错的,因为 捕获只能应用于lambda被创建时所在作用域里的non-static
局部变量(包括形参)。在Widget::addFilter
的视线里,divisor
并不是一个局部变量,而是Widget
类的一个成员变量。它不能被捕获。
而如果默认捕获模式被删除,代码就不能编译了:
1 |
|
另外,如果尝试去显式地捕获divisor
变量(或者按引用或者按值——这不重要),也一样会编译失败,因为divisor
不是一个局部变量或者形参。
1 |
|
所以如果默认按值捕获不能捕获divisor
,而不用默认按值捕获代码就不能编译,这是为什么?
在默认按值捕获的Widget::addFilter
版本中,
1 |
|
真正被捕获的是Widget
的this
指针,而不是divisor
。编译器会将上面的代码看成以下的写法:
1 |
|
也就是说使用=这种值捕获this,其实还是拿到了对象的指针来做操作,而往往你会认为你这是在对值捕获的对象做操作,这种误用可能会让你不小心修改了对象的成员而自己却不知道。
我们不想用引用的divisor,如何解决上面那个问题呢?其实拷贝出来一份副本就好了:
1 |
|
在C++14的广义捕获(generalized lambda capture)可以更简单的来写成:
1 |
|
我们再次强调 :lambda可能会依赖静态存储生命周期(static storage duration)的对象。这些对象定义在全局空间或者命名空间,或者在类、函数、文件中声明为static
。这些对象也能在lambda里使用,但它们不能被捕获。
一些初学者会认为能使用static或者全局变量是因为[=]
值捕获到了,但其实你还是引用的这些全局变量,即使你不写=,什么都不捕获依然可以使用这些静态存储生命周期的对象,如下面这份代码:
1 |
|
这个lambda没有使用任何的non-static
局部变量,所以它没有捕获任何东西。然而lambda的代码引用了static
变量divisor
,在每次调用addDivisorFilter
的结尾,divisor
都会递增,通过这个函数添加到filters
的所有lambda都展示新的行为(分别对应新的divisor
值)。这个lambda是通过引用捕获divisor
,这和默认的按值捕获表示的含义有着直接的矛盾。如果你一开始就避免使用默认的按值捕获模式,你就能解除代码的风险。
说了这么多规则,可以发现,你写默认捕获的代价是很大的,你需要仔细检查你到底犯没犯这些错误,那不如直接少用按值捕获模式或是按引用捕获模式。
请记住:
- 默认的按引用捕获可能会导致悬空引用。
- 默认的按值捕获对于悬空指针很敏感(尤其是
this
指针),并且它会误导人产生lambda是独立的想法。
Item32:使用初始化捕获来移动对象到闭包中(C++14后)
这一节要讲的是 初始化捕获/广义捕获(generalized lambda capture)
考虑这样一段代码:
1 |
|
里面使用了pw = std::move(pw)
初始化闭包数据成员,这个就称为Generalized lambda capture
对于初学者来说,可能会认为 等号左右边的pw是一个东西,但其实并不,“=
”左侧的名称pw
表示闭包类中的数据成员,而右侧的名称pw
表示在lambda上方声明的对象,即由调用std::make_unique
去初始化的变量。因此,“pw = std::move(pw)
”的意思是“在闭包中创建一个数据成员pw
,并使用将std::move
应用于局部变量pw
的结果来初始化该数据成员”。
再简单点来说 , 更直观来看, 这么写一样:
1 |
|
题目中也提到了广义捕获/初始化捕获在C++14后才推出,如果低于这个版本怎么使用呢?其实有很多种思路
一种是扔到花括号里再用move转成右值即可
另一种是: lambda本质就是个仿函数(C++insight这个网站可以试一下),因此自己写一下即可
1 |
|
除了上面两种,我们还可以使用std::bind
:
1 |
|
如lambda表达式一样,std::bind
产生函数对象。我将由std::bind
返回的函数对象称为bind对象(bind objects)。std::bind
的第一个实参是可调用对象,后续实参表示要传递给该对象的值。
一个bind对象包含了传递给std::bind
的所有实参的副本。对于每个左值实参,bind对象中的对应对象都是复制构造的。对于每个右值,它都是移动构造的。 在上面的代码中,我们用了std::move(data)
,因此将data
移动构造到Bind对象中去,这其实就是移动捕获,做到了和generalized lambda capture一样的功能。意味着当调用func
(bind对象)时,func
中所移动构造的data
副本将作为实参传递给std::bind
中的lambda。
另外要提示的一点是:默认情况下,从lambda生成的闭包类中的operator()
成员函数为const
的。这具有在lambda主体内把闭包中的所有数据成员渲染为const
的效果。但是,bind对象内部的移动构造的data
副本不是const
的,因此,为了防止在lambda内修改该data
副本,lambda的形参应声明为reference-to-const
。 这就是我们班在上面的代码中const std::vector<double>& data
是const的原因。
请记住:
- 使用C++14的初始化捕获将对象移动到闭包中。
- 在C++11中,通过手写类或
std::bind
的方式来模拟初始化捕获。
Item33:对auto&&形参使用decltype以std::forward它们
从泛型lambda说起:
泛型lambda(generic lambdas)是C++14中最值得期待的特性之一——因为在lambda的形参中可以使用auto
关键字。这个特性的实现是非常直截了当的:即在闭包类中的operator()
函数是一个函数模版。例如存在这么一个lambda,
1 |
|
对应的闭包类中的函数调用操作符看来就变成这样:
1 |
|
那么这里有个问题就是: 如果函数normalize
对待左值右值的方式不一样,这个lambda的实现方式就不大合适了,因为即使传递到lambda的实参是一个右值,lambda传递进normalize
的总是一个左值(形参x
)。
可以看出泛型lambda固然方便,但是要记得完美转发哦。
改成这样:
1 |
|
这里还是有个疑问点,???
填写什么类型呢?
一般来说,当你在使用完美转发时,从上面的仿函数类来看,你是在一个接受类型参数为T
的模版函数里,所以你可以写std::forward<T>
。但在泛型lambda中,没有可用的类型参数T
,auto替代了T。
Item28解释过如果一个左值实参被传给通用引用的形参,那么形参类型会变成左值引用。传递的是右值,形参就会变成右值引用。这意味着在这个lambda中,可以通过检查形参x
的类型来确定传递进来的实参是一个左值还是右值,decltype
就可以实现这样的效果(看item3)。
也就是说传递给lambda的是一个左值,decltype(x)
就能产生一个左值引用;如果传递的是一个右值,decltype(x)
就会产生右值引用。
上次我们提到了forward的实现原理如下:
1 |
|
当你要转发的是一个右值,产生以下的函数:
1 |
|
左值同理:
1 |
|
对比这个实例和用Widget
设置T
去实例化产生的结果,它们完全相同。话句话说:用右值引用类型和用非引用类型去初始化std::forward
产生的相同的结果。这是个很重要的等价定理。
现在我们这么写即可:
1 |
|
如果你害怕上面的代码可读性不高,给同事解释起来比较麻烦,不妨这样做:
其实一般来说,我们常会在forward<T>
中的T希填写为非引用类型,因此除了上面的做法我们也可以直接remove_refernce
拿到非引用类型即可:
1 |
|
请记住:
- 对
auto&&
形参使用decltype
以std::forward
它们。
Item34:考虑lambda而非std::bind
在C++11中,lambda几乎总是比std::bind
更好的选择。 从C++14开始,lambda的作用不仅强大,而且是完全值得使用的。但是我们仍要学习std::bind
, 原因很简单:别人可能会使用,而你有可能要阅读/维护别人代码,所以需要了解。下面我们说明为何lambda优于std::bind
。
考虑我们给一个闹钟添加
1 |
|
由于C++11后支持了用户自定义字面量,我们通过使用标准后缀如秒(s
),毫秒(ms
)和小时(h
)等简化在C++14中的代码,其中标准后缀基于C++11对用户自定义常量的支持。
因此hours(1)
和seconds(30)
也可以写成1h,30s:
1 |
|
考虑写一个bind的版本:
1 |
|
代码并不完全正确。在lambda中,表达式steady_clock::now() + 1h
显然是setAlarm
的实参。调用setAlarm
时将对其进行计算。可以理解:我们希望在调用setAlarm
后一小时响铃。但是,在std::bind
调用中,将steady_clock::now() + 1h
作为实参传递给了std::bind
,而不是setAlarm
。这意味着将在调用std::bind
时对表达式进行求值,并且该表达式产生的时间将存储在产生的bind对象中。结果,警报器将被设置为在调用std::bind
后一小时发出声音,而不是在调用setAlarm
一小时后发出。
所以我们希望std::bind
推迟对表达式的求值,直到调用setAlarm
为止,而这样做的方法是将对std::bind
的第二个调用嵌套在第一个调用中:
1 |
|
就是把这个计算式再用bind嵌套一下,我们同时发现了:
尖括号之间未指定任何类型,即该代码包含“std::plus<>
”,而不是“std::plus<type>
”。 在C++14中,通常可以省略标准运算符模板的模板类型实参,因此无需在此处提供。
到现在我们应该很容易看出来bind很是繁琐且易错,lambda是更好的选择,因此bind在表达式推迟计算上很容易造成错误,不如lambda好用。
现在考虑第二段代码:
1 |
|
bind版本出错的原因是编译器无法确定应将两个setAlarm
函数中的哪一个传递给std::bind
。 它们仅有的是一个函数名称,而这个单一个函数名称是有歧义的。
要使对std::bind
的调用能编译,必须将setAlarm
强制转换为适当的函数指针类型:
1 |
|
在这个case上造成bind和lambda的区别的原因是:
在
setSoundL
的函数调用操作符(即lambda的闭包类对应的函数调用操作符)内部,对setAlarm
的调用是正常的函数调用,编译器可以按常规方式进行内联。好比在调用处那行代码内联成:setSoundL(Sound::Siren);
但是,对
std::bind
的调用是将函数指针传递给setAlarm
,这意味着在setSoundB
的函数调用操作符(即绑定对象的函数调用操作符)内部,对setAlarm
的调用是通过一个函数指针。 编译器不太可能通过函数指针内联函数。因此,使用lambda还可能会比使用std::bind
能生成更快的代码
setAlarm
示例仅涉及一个简单的函数调用。如果你想做更复杂的事情,使用lambda会更有利。 例如,考虑以下C++14的lambda使用,它返回其实参是否在最小值(lowVal
)和最大值(highVal
)之间的结果,其中lowVal
和highVal
是局部变量:
1 |
|
使用std::bind
可以表达相同的内容,但是该构造是一个通过晦涩难懂的代码来保证工作安全性的示例:
1 |
|
再考虑到lambda还有初始化捕获/广义捕获,泛型lambda等,lambda确实是更好的选择。
1 |
|
当我们将w
传递给std::bind
时,必须将其存储起来,以便以后进行压缩。它存储在对象compressRateB
中,但是它是如何被存储的呢?答案是bind中都是按值捕获的,他总会拷贝他的实参。如果想用引用捕获需要一个std::ref
的东西auto compressRateB = std::bind(compress, std::ref(w), _1);
即可。
std::ref
的原理是什么呢?看了下源码发现返回的就是一个`reference_wrapper
, reference_wrapper
不妨简单理解成里面装着指针的一个引用包装。所以bind拷贝实参的时候会拷贝reference_wrapper
,即拷贝了变量的指针。
只有在C++11下,那是lambda的初始化捕获和泛型lambda还没有出现时,可以用bind来替代:
初始化捕获:
1 |
|
泛型lambda:
1 |
|
std::bind
可以如下绑定一个PolyWidget
对象:
1 |
|
boundPW
可以接受任意类型的对象了:
1 |
|
这一点无法使用C++11的lambda做到。 但是,在C++14中,可以通过带有auto
形参的lambda轻松实现:
1 |
|
话说回来谁还只用c++11编译器呢?lambda几乎总是更好的选择。
请记住:
- 与使用
std::bind
相比,lambda更易读,更具表达力并且可能更高效。 - 只有在C++11中,
std::bind
可能对实现移动捕获或绑定带有模板化函数调用运算符的对象时会很有用。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!