Effective Modern C++:06lambda表达式

         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); };

猜你喜欢

转载自www.cnblogs.com/gqtcgq/p/9937013.html