lambda表达式实际上是语法糖,任何lambda表达式能做到的,手动都能做到,无非是多打几个字。但是lambda作为一种创建函数对象的手段,实在太过方便,自从有了lambda表达式,使用复杂谓词来调用STL中的”_if”族算法(std::find_if,std::remove_if等)变得非常方便,这种情况同样发生在比较函数的算法族上。在标准库之外,lambda表达式可以临时制作出回调函数、接口适配函数或是语境相关函数的特化版本以供一次性调用。下面是关于lambda相关术语的提醒:
lambda表达式,是表达式的一种,比如下面代码中红色的就是lambda表达式:
std::find_if(container.begin(), container.end(), [](int val) { return 0 < val && val < 10; });
闭包,是lambda表达式创建的运行期对象,在上面对std::find_if的调用中,闭包就是作为第三个实参在运行期传递给std::find_if的对象。
闭包类,是实例化闭包的类,每个lambda表达式都会触发编译器生成一个独一无二的闭包类,而lambda表达式中的语句会变成闭包类成员函数的可执行指令。
闭包可以复制,所以,对应于单独一个lambda表达式的闭包类型可以有多个闭包:
int x; // x is local variable auto c1 = [x](int y) { return x * y > 55; }; // c1 is copy of the closure produced by the lambda auto c2 = c1; // c2 is copy of c1 auto c3 = c2; // c3 is copy of c2 …
c1、c2和c3都是同一个lambda表达式产生的闭包的副本。
在非正式场合,lambda表达式,闭包和闭包类之间的界限可以模糊一些。但是在下面的条款中,需要能区别哪些存在于编译期(lambda表达式和闭包类),哪些存在于运行期(闭包),以及它们之间的相互联系。
31:避免默认捕获模式
C++11中有两种默认捕获模式:按引用或按值。按引用的默认捕获模式可能导致空悬引用,而按值的默认捕获模式可能会让你觉得不存在空悬引用的问题(实际上不是)。
按引用捕获会导致闭包包含指向局部变量(或形参)的引用,一旦由lambda表达式所创建的闭包的生命期超过了该局部变量或形参的生命期,那么闭包内的引用就会空悬,比如下面的代码:
using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters; // filtering funcs void addDivisorFilter() { auto calc1 = computeSomeValue1(); auto calc2 = computeSomeValue2(); auto divisor = computeDivisor(calc1, calc2); filters.emplace_back( [&](int value) { return value % divisor == 0; } ); }
这段代码随时会出错,lambda中按引用捕获了局部变量divisor,但当addDivisorFilter函数返回时局部变量被销毁,使用filters就会产生未定义行为。
如果不这样做,使用显式方式按引用捕获divisor,问题依然存在:
filters.emplace_back( [&divisor](int value) { return value % divisor == 0; } );
但是通过显示捕获,就比较容易看出lambda表达式的生存依赖于divisor的生命期。显式的写出”divisor”可以提醒我们要保证divisor至少应该和lambda具有一样长的生命期,这要比[&]这种所传达的不痛不痒的“要保证没有空悬引用”式的劝告更让人印象深刻。
如果知道闭包会立即使用(比如传递给STL算法)且不会被复制,这种情况下,你可能会争论说,既然没有空悬引用的风险,也就没有必要避免使用默认引用捕获模式。但是从长远观点来看,显示的列出lambda表达式所依赖的局部变量或形参,是更好的软件工程实践。
上面的例子中,解决问题的一种办法是对divisor采用按值的默认捕获模式:
filters.emplace_back( [=](int value) { return value % divisor == 0; } );
对于这个例子而言,这样做确实是没问题的。但是按值的默认捕获并非一定能避免空悬引用,问题在于如果按值捕获了一个指针,在lambda表达式创建的闭包中持有的是这个指针的副本,但是没有办法阻止lambda表达式之外的代码针对该指针实施delete操作导致的指针副本空悬。比如下面的代码:
class Widget { public: … // ctors, etc. void addFilter() const; // add an entry to filters private: int divisor; // used in Widget's filter }; void Widget::addFilter() const { filters.emplace_back( [=](int value) { return value % divisor == 0; } ); }
这样的代码看起来安全,然而实际上却是大错特错的。捕获只能针对于在创建lambda表达式的作用域内可见的非静态局部变量(包括形参),而在Widget::addFilter函数体内,divisor并非局部变量,而是Widget类的成员变量,它根本没办法捕获。这么一来,如果不使用默认捕获模式,代码就不会通过编译:
void Widget::addFilter() const { filters.emplace_back( [](int value) { return value % divisor == 0; } ); }
而且,如果试图显示捕获divisor(无论是按值还是按引用),这个捕获语句都不能通过编译,因为divisor既不是局部变量,也不是形参:
void Widget::addFilter() const { filters.emplace_back( [divisor](int value) { return value % divisor == 0; } ); }
但是为什么一开始的代码没有发生编译错误呢?this指针是关键所在,每一个非静态成员函数都持有一个this指针,每当提及该类的成员变量时都会用到这个指针。比如在Widget的任何成员函数中,编译器内部都会把divisor替换成this->divisor。因此,在Widget::addFilter的按值默认捕获版本中,被捕获的实际上是Widget的this指针,而不是divisor。从编译器的角度来看,实际的代码相当于:
void Widget::addFilter() const { auto currentObjectPtr = this; filters.emplace_back( [currentObjectPtr](int value) { return value % currentObjectPtr->divisor == 0; } ); }
因此,该lambda闭包的存活,与它含有this指针指向的Widget对象的生命期是绑在一起的,比如下面的代码:
using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters; void doSomeWork() { auto pw = std::make_unique<Widget>(); pw->addFilter(); … }
当调用doSomeWork时创建了一个筛选函数,它依赖于std::make_unique创建的Widget对象,该函数被添加到filters中,然而当doSomeWork结束后,Widget对象随着std::unique_ptr的销毁而销毁,从那一刻起,filters中就含有了一个带有空悬指针的元素。
这一问题可以通过将想捕获的成员变量复制到局部变量中,而后捕获该局部变量的部分得意解决:
void Widget::addFilter() const { auto divisorCopy = divisor; filters.emplace_back( [divisorCopy](int value) { return value % divisorCopy == 0; } ); }
在C++14中,捕获成员变量的一种更好的方式是使用广义lambda捕获(generalized lambda):
void Widget::addFilter() const { filters.emplace_back( [divisor = divisor](int value) { return value % divisor == 0; } ); }
对广义lambda捕获而言,没有默认捕获模式一说,但是,就算在C++14中,本条款的建议,避免使用默认捕获模式依然成立。
使用按值默认捕获的另一个缺点,在于它似乎表明闭包是自治的,与闭包外的数据变化绝缘,然而作为一般性的结论,这是不正确的。因为lambda表达式可能不仅依赖于局部变量或形参,他还可能依赖于静态存储期对象,这样的对象定义在全局或名字空间作用域中,或是在类,函数,文件中以static饰词声明。这样的对象可以在lambda内使用,但是它们不能被捕获。如果使用了按值默认捕获模式,这些对象就会给人以错觉,认为它们可以加以捕获:
void addDivisorFilter() { static auto calc1 = computeSomeValue1(); static auto calc2 = computeSomeValue2(); static auto divisor = computeDivisor(calc1, calc2); filters.emplace_back( [=](int value) { return value % divisor == 0; } ); ++divisor; }
看到[=]就认为lambda复制了它内部使用的对象,得出lambda是自治的这种结论,是错误的。实际上该lambda表达式并不独立,它没有使用任何的非静态局部变量或形参,所以它没能捕获任何东西。更糟糕的是lambda表达式的代码中使用了静态变量divisor,每次调用addDivisorFilter后,divisor会递增,使得添加到filters中的每个lambda表达式的行为都不一样。如果一开始就避免使用按值的默认捕获模式,也就能消除代码被误读的风险了。
32:使用初始化捕获将对象移入闭包
有时按值捕获和按引用捕获并不能满足所有的需求。比如想要把move-only对象(如std::unique_ptr或std::future)放入闭包,或者想把复制昂贵而移动低廉的对象移入闭包时,C++11没有提供可行的方法,但是C++14为对象移动提供了直接支持。
实际上,C++14提供了一种全新的捕获方式,按移动的捕获只不过是该机制能够实现的多种效果之一罢了。这种方式称为初始化捕获(init capture),它可以做到C++11的捕获形式所有能够做到的事情(除了默认捕获模式,而这是需要远离的),不过初始化捕获的语法稍显啰嗦,如果C++11的捕获能解决问题,则大可以使用之。
下面是初始化捕获实现移入捕获的例子:
class Widget { public: bool isValidated() const; bool isProcessed() const; bool isArchived() const; private: … }; auto pw = std::make_unique<Widget>(); … auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };
上面的例子中,位于”=”左侧的pw,是lambda创建的闭包类中成员变量的名字;而位于”=”右侧的是其初始化表达式,所以”pw=std::move(pw)”表达了在闭包类中创建一个成员变量pw,然后使用针对局部变量pw实施std::move的结果来初始化该成员变量。在lambda内部使用pw也是指的闭包类的成员变量。一旦定义lambda表达式之后,因为局部变量pw已经被move了,所以其不再掌握任何资源。
上面的例子还可以不使用局部变量pw:
auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
这种捕获方式在C++14中还称为广义lambda捕获(generalized lambda capture)。
但是如果编译器尚不支持C++14,则该如何实现按移动捕获呢?要知道一个lambda表达式不过是生成一个类并创建一个该类的对象的手法罢了,并不存在lambda能做而手工不能做的事情,上面C++14的例子,如果使用C++11,可以写为:
class IsValAndArch { public: using DataType = std::unique_ptr<Widget>; explicit IsValAndArch(DataType&& ptr) : pw(std::move(ptr)) {} bool operator()() const { return pw->isValidated() && pw->isArchived(); } private: DataType pw; }; auto func = IsValAndArch(std::make_unique<Widget>());
这种写法要比使用lambda麻烦很多。
如果非要使用lambda实现按移动捕获,也不是全无办法,可以借助std::bind实现:把要捕获的对象移动到std::bind产生的函数对象中;给lambda表达式一个指向欲捕获的对象的引用。比如C++14中的写法:
std::vector<double> data; … // populate data auto func = [data = std::move(data)] { /* uses of data */ };
如果采用C++11中使用std::bind和lambda的写法,等价代码如下:
std::vector<double> data; … // as above auto func = std::bind( [](const std::vector<double>& data) { /* uses of data */ }, std::move(data) );
std::bind也生成函数对象,可以将它生成的对象称为绑定对象。std::bind的第一个实参是个可调用对象,接下来的所有实参表示传递给该对象的值。
绑定对象内含有传递给std::bind所有实参的副本。对于左值实参,绑定对象内对应的副本实施的是复制构造;对于右值实参,实施的是移动构造。上面的例子中,第二个实参是个右值,所以在绑定对象内,使用局部变量data移动构造其副本,这种移动构造动作正是实现模拟移动捕获的关键所在,因为把右值移入绑定对象,正是绕过C++11无法将右值移动到闭包的手法。
当一个绑定对象被调用时,它所存储的实参会传递给std::bind的那个可调用对象,也就是func被调用时,func内经由移动构造得到的data副本就会作为实参传递给那个原先传递给std::bind的lambda表达式。这个C++11写法比C++14多了一个形参data,该形参是个指向绑定对象内部的data副本的左值引用,这么一来,在lambda内对data形参所做的操作,都会实施在绑定对象内移动构造而得的data副本之上,与原局部变量data无关。
默认情况下,lambda闭包类中的operator()成员函数会带有const饰词,因此闭包里的所有成员变量在lambda表达式的函数体内都带有const饰词,但绑定对象内移动构造而得的data副本并不带有const饰词,所以为了防止该data部分在lambda表达式内被意外修改,lambda的形参就声明为常量引用。但是如果lambda表达式带有mutable饰词,则闭包中的operator()函数就不会在声明时带有const饰词,相应的做法就是在lambda声明中略去const:
auto func = std::bind( [](std::vector<double>& data) mutable { /* uses of data */ }, std::move(data) );
绑定对象存储着传递给std::bind所有实参的副本,因此本例中的绑定对象就包含一份由第一个实参lambda表达式产生的闭包的副本。这么一来,该闭包的生命期就和绑定对象是相同的。
另外一个例子,下面是C++14的代码:
auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
如果使用C++11采用bind的写法:
auto func = std::bind( [](const std::unique_ptr<Widget>& pw) { return pw->isValidated() && pw->isArchived(); }, std::make_unique<Widget>() );
33:要对auto&&类型的形参使用std::forward,则需要使用decltype
泛型lambda表达式(generic lambda)是C++14最振奋人心的特性之一:lambda表达式的形参列表中可以使用auto,它的实现直截了当,闭包类中的operator()采用模板实现。比如下面的lambda表达式,以及其对应的实现:
auto f = [](auto x){ return func(normalize(x)); }; class SomeCompilerGeneratedClassName { public: template<typename T> auto operator()(T x) const { return func(normalize(x)); } … };
这个例子中,lambda表达式对x的动作就是将其转发给normalize,如果normalize区别对待左值和右值,则该lambda表达式的实现是有问题的,正确的写法应该是使用万能引用并将其完美转发给normalize:
auto f = [](auto&& x) { return func(normalize(std::forward<???>(x))); };
这里的问题是,std::forward的模板实参”???”应该怎么写?
这里可以使用decltype(x),但是decltype(x)产生的结果,却与std::forward的使用惯例有所不同。如果传入的是个左值,则x的类型是左值引用,decltype(x)得到的也是左值引用;如果传入的是右值,则x的类型是右值引用,decltype(x)得到的也是右值引用,但是,std::forward的使用惯例是std::forward<T>,其中T要么是个左值引用,要么是个非引用。
再看一下条款28中std::forward的简单实现:
template<typename T> T&& forward(remove_reference_t<T>& param) { return static_cast<T&&>(param); }
如果客户代码想要完美转发Widget类型的右值,则按照惯例它应该采用Wdiget类型,而非引用类型来实例化std::forward,然后std::forard模板实例化结果是:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
如果使用右值引用实例化T,也就是Widget&&实例化T,得到的结果是:
Widget&& && forward(Widget& param) { return static_cast<Widget&& &&>(param); }
实施了引用折叠之后:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
经过对比,发现这个版本和T为Widget时的std::forward是完全一样的,因此,实例化std::forward时,使用一个右值引用和使用非引用类型,结果是相同的。所以,我们的完美转发lambda表达式如下:
auto f = [](auto&& param) { return func(normalize(std::forward<decltype(param)>(param))); };
稍加改动,就可以得到能接收多个形参的完美转发lambda式版本,因为C++14中的lambda能够接受变长形参:
auto f = [](auto&&... params) { return func(normalize(std::forward<decltype(params)>(params)...)); };
34:优先使用lambda表达式,而非std::bind
std::bind在2005年就已经是标准库的组成部分了(std::tr1::bind),这意味着std::bind已经存在了十多年了,你可能不太愿意放弃这么一个运作良好的工具,然而有时候改变也是有益的,因为在C++11中,相对于std::bind,lambda几乎总会是更好的选择,而到了C++14,lambda简直已成了不二之选。
lambda表达式相对于std::bind的优势,最主要的是其具备更高的可读性:
// typedef for a point in time (see Item 9 for syntax) using Time = std::chrono::steady_clock::time_point; // see Item 10 for "enum class" enum class Sound { Beep, Siren, Whistle }; // typedef for a length of time using Duration = std::chrono::steady_clock::duration; // at time t, make sound s for duration d void setAlarm(Time t, Sound s, Duration d); // setSoundL ("L" for "lambda") is a function object allowing a // sound to be specified for a 30-sec alarm to go off an hour // after it's set auto setSoundL = [](Sound s) { // make std::chrono components available w/o qualification using namespace std::chrono; setAlarm(steady_clock::now() + hours(1), s, seconds(30)); };
这里的lambda表达式,即使是没什么经验的读者也能看出来,传递给lambda的形参会作为实参传递给setAlarm。到了C++14中,C++14提供了秒,毫秒和小时的标准字面值,所以,可以写成这样:
auto setSoundL = [](Sound s) { using namespace std::chrono; using namespace std::literals; setAlarm(steady_clock::now() + 1h, s, 30s); };
而下面的代码是使用std::bind的等价版本,不过实际上它还有一处错误的,后续在解决这个错误:
using namespace std::chrono; using namespace std::literals; using namespace std::placeholders; // needed for use of "_1" auto setSoundB = // "B" for "bind" std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);
对于初学者而言,占位符”_1”简直好比天书,而即使是行家也需要脑补出从占位符数字到它在std::bind形参列表中的位置映射关系,才能理解在调用setSoundB时传入的第一个实参,会作为第二个实参传递给setAlarm。该实参的类型在std::bind的调用过程中是未加识别的,所以还需要查看setAlarm的声明才能决定应该传递何种类型的实参到setSoundB。
这段代码的错误之处在于,在lambda表达式中,表达式”steady_clock::now() + 1h”是setAlarm的实参之一,这一点清清楚楚,该表达式会在setAlarm被调用时求值,这样是符合需求的,就是需要在setAlarm被调用的时刻之后的一个小时启动报警。但是在std::bind中,”steady_clock::now() + 1h”作为实参传递给std::bind,而非setAlarm,该表达式在调用std::bind时就进行求值了,并且求得的结果会存储在绑定对象中,这导致的结果是报警的启动时刻是在std::bind调用之后的一个小时,而非setAlarm调用之后的一个小时。
要解决这个问题,就需要std::bind延迟表达式的求值到调用setAlarm的时刻,实现这一点,就是需要嵌套第二层std::bind的调用:
auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s);
在C++14中,标准运算符模板的模板类型实参大多数情况下可以省略不写,所以此处也没必要在std::plus中提供了,而C++11中还没有这样的特性,所以在C++11中,想要实现上面的代码,只能是:
using namespace std::chrono; // as above using namespace std::placeholders; auto setSoundB = std::bind(setAlarm, std::bind(std::plus<steady_clock::time_point>(), steady_clock::now(), hours(1)), _1, seconds(30));
如果对setAlarm实施了重载,则又会有新的问题:
enum class Volume { Normal, Loud, LoudPlusPlus }; void setAlarm(Time t, Sound s, Duration d, Volume v); auto setSoundL = [](Sound s) { using namespace std::chrono; setAlarm(steady_clock::now() + 1h, s, 30s); };
即使有了重载,lambda表达式依然能正常工作,重载决议会选择有三个参数版本的setAlarm。但是到了std::bind,就没办法通过编译了:
auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(),steady_clock::now(),1h), _1, 30s);
这是因为编译器无法确定应该将哪个setalarm传递给set::bind,它拿到的所有信息只有一个函数名。为了使std::bind能够通过编译,setAlarm必须强制转换到适当的函数指针类型:
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d); auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm), std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s);
但是这么做又带来了lambda和std::bind的另一个不同之处。在lambda生成的setSoundL的函数调用运算符中,调用setAlarm采用的是常规函数唤起方式,这么一来,编译器就可以用惯常的手法将其内联:
setSoundL(Sound::Siren); // body of setAlarm may well be inlined here
而std::bind调用中使用了函数指针,这意味着在setSoundB的函数调用运算符中,setAlarm是通过函数指针来调用的,编译器一般无法将函数指针发起的函数调用进行内联,所以lambda表达式就有可能生成比std::bind更快的代码。
在setAlarm例子中,仅仅涉及了函数的调用而已,如果你想做的事比这更复杂,则lambda表达式的优势则更加明显。比如:
auto betweenL = [lowVal, highVal] (const auto& val) { return lowVal <= val && val <= highVal; };
这里的lambda使用了捕获。std::bind要想要实现同样的功能,必须用比较晦涩的方式来构造代码,下面分别是C++14和C++11的写法:
using namespace std::placeholders; auto betweenB = std::bind(std::logical_and<>(), std::bind(std::less_equal<>(), lowVal, _1), std::bind(std::less_equal<>(), _1, highVal)); auto betweenB = std::bind(std::logical_and<bool>(), std::bind(std::less_equal<int>(), lowVal, _1), std::bind(std::less_equal<int>(), _1, highVal));
还是需要使用std::bind的延迟计算方法。
再看下面的代码:
enum class CompLevel { Low, Normal, High }; Widget compress(const Widget& w, CompLevel lev); //make compressedcopy of w Widget w; using namespace std::placeholders; auto compressRateB = std::bind(compress, w, _1);
这里的w传递给std::bind时,是按值存储在std::bind生成的对象中的,在std::bind的调用中,按值还是按引用存储只能是牢记规则。std::bind总是复制其实参,但是调用方可以通过对实参实施std::ref的方法达到按引用存储的效果,因此:
auto compressRateB = std::bind(compress, std::ref(w), _1);
结果就是compressRateB的行为如同持有的是个指向w的引用,而非其副本。
而在lambda中,w无论是按值还是按引用捕获,代码中的书写方式都很明显:
auto compressRateL = [w](CompLevel lev) { return compress(w, lev); };
同样明显的还有形参的传递方式:
compressRateL(CompLevel::High); // arg is passed by value compressRateB(CompLevel::High); // how is arg passed?
Lambda返回的闭包中,很明显实参是按值传递给lev的;而在std::bind返回绑定对象中,形参的传递方式是什么呢?这里也只能牢记规则,绑定对象的所有实参都是按引用传递的,因为此种对象的函数调用运算符使用了完美转发。
总而言之,lambda表达式要比std::bind可读性更好,表达能力更强,运行效率也可能更好,在C++14中,几乎没有std::bind的适当用例,而在C++11中,std::bind仅在两个受限场合还算有使用的理由:
移动捕获,C++11没有提供移动捕获的语法,参考上一条款;
多态函数对象,因为绑定对象的函数调用运算符使用了完美转发,所以可以接收任何类型的实参,因此当需要绑定的对象具有一个函数调用运算符模板时,是有利用价值的:
class PolyWidget { public: template<typename T> void operator()(const T& param); … }; PolyWidget pw; auto boundPW = std::bind(pw, _1); boundPW(1930); // pass int to PolyWidget::operator() boundPW(nullptr); // pass nullptr to PolyWidget::operator() boundPW("Rosebud"); // pass string literal to PolyWidget::operator()
C++11中的lambda表达式没有办法实现这一点,但是在C++14中,使用带有auto类型形参的lambda表达式可以很容易的实现这一点:
auto boundPW = [pw](const auto& param) { pw(param); };