介绍Lambda表达式中的一些细节:避免隐式捕获、最好通过初始化(移动)进行捕获、如何处理右值。

首先先澄清一些术语:

  • lambda表达式就是一个表达式,如下面代码花括号部分

    std::find_if(container.begin(), container.end(), 
                 { return 0 < val && val < 10; });
    
  • closure闭包是通过lambda表达式创建的对象,闭包持有被捕获的拷贝和引用。lambda通常用于一次使用的场景。但closure通常是可复制的,因此一个lambda表达式可能会对应着多个closure。

    auto c1 = [x](int y) { return x * y > 55; };
    auto c2 = c1;
    
  • closure class闭包类是一个闭包的实现。编译器会为每个lambda表达式生成一个唯一的closure class,lambda表达式中的代码会成为这个类的成员函数的可执行代码。

Item 31:Avoid default capture modes.

先介绍一些基础知识。Lambda表达式的完整声明格式如下:

[capture list] (params list) mutable exception-> return type { function body }

各项含义如下:

  • capture list:捕获外部变量列表
  • params list:形参列表
  • mutable指示符:用来说用是否可以修改捕获的变量
  • exception:异常设定
  • return type:返回类型
  • function body:函数体

明确几个捕获类型:

  • 值捕获

    int a = 123;
    auto f = [a] { cout << a << endl; };
    
  • 引用捕获

    int a = 123;
    auto f = [&a] { cout << a << endl; };
    
  • 隐式捕获

    隐式捕获有两种方式,分别是[=]和[&]。[=]表示以值捕获的方式捕获外部变量,[&]表示以引用捕获的方式捕获外部变量。可以让编译器根据函数体中的代码来推断需要捕获哪些变量。

那么为什么要避免使用隐式捕获呢?

如果一个由lambda创建的闭包的生命期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。

void addDivisorFilter()
{
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();

    auto divisor = computeDivisor(calc1, calc2);

    filters.emplace_back(
      [&](int value) { return value % divisor == 0; }   // 危险!对divisor的引用会空悬
    );
}

这代码有个定时炸弹。lambda引用了局部变量divisor, 但是局部变量的生命期在addDivisorFilter返回时终止,也就是在filters.emplace_back返回之后。由于filters容器不属于这个局部,我们采用的是引用的形式,当本体作为局部变量被销毁后,就会变为未定义行为。

即使我们加上显示引用捕获divisor,会存在着同样的问题:

filters.emplace(
  [&divisor](int value)          // 危险!对divisor的引用依然会空悬
  { return value % divisor == 0; }
);

虽然依然存在问题,但我们可以从中看到lambda的活性依赖于divisor的生命期,显示地写出能够提醒我们要确保divisor的生命期至少和lambda闭包一样长。即使是我们知道一个closure只在当前作用域范围内使用(不同于上面的filters),不会传播出去,使用隐式捕获依然可能出现一些问题。

template <typename C>
void workWithContainer(const C& container) {
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    auto divisor = computeDivisor(calc1, calc2);

    using ContElemT = typename C::value_type;
    using std::begin;
    using std::end;

    if (std::all_of(
        begin(container), end(container),
        [&](const ContElemT& value) { return value % divisor == 0; })) {
        ...
    ) else {
        ...
    }
}
//c++14 auto can be used in params type
if (std::all_of(
    begin(container), end(container),
    [&](const auto& value) { return value % divisor == 0; }))

这段代码本身没什么问题,但你没办法保证不会有人把这段代码拷贝到其它地方,没注意这里有个默认的引用捕获,结果出现孤悬引用。

为了解决这个问题,人们提出了几种可能的解决方案:

(1)默认的值捕获 可行,但不能捕获指针

filters.emplace_back([=](int value) 
                     { return value % divisor == 0; }

(2)拷贝一份成员变量,再捕获 可行

class Widget {
public:
    ...
    void addFilter() const;
private:
    int divisor;
void Widget::addFilter() const {
    auto divisorCopy = divisor;
    filters.emplace_back([divisorCopy] (int value) 
    {
        return value % divisorCopy == 0;
    });
}
};

(3)初始化捕获作用于14

void Widget::addFilter() const {
    filters.emplace_back(
        [divisor = divisor] (int value) {
            return value % divisor == 0;
        }
    );
}

Item 32:Use init capture to move objects into closures.

有时候我们想把一个对象移动到closure中,比如一个只能移动的对象(std::unique_ptrstd::future),或是移动的代价远小于复制的对象(比如大多数的STL容器),这个时候默认的引用捕获和值捕获都无法做到。C++14提供了一种方式,叫“初始化捕获”,能满足这一需求。C++11无法直接实现,但后面会介绍一种间接实现的方式。

class Widget;
...
auto pw = std::make_unique<Widget>();
...                                     // confiture *pw
auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };

pw = std::move(pw)中,=左边的是数据成员的名字,它的作用域就是这个closure;右边是它的初始化式,它的作用域就是closure所在的作用域

举个更简单的例子:

auto testNumber = 10;
auto divisor = 3;

auto pw = make_unique<int>(divisor);
auto fun = [pw = std::move(pw)](auto& divided){ return divided / *pw; };

cout << fun(testNumber) << endl; // result is 3

C++11实现

先介绍一下std::bind,上代码。

#include <iostream>  
#include <functional>  
using namespace std;  
using namespace std::placeholders;  

int main()  
{  
    auto fun = [](int *array, int n, int num){  
        for (int i = 0; i < n; i++)  
        {  
            if (array[i] > num)  
                cout << array[i] << ends;  
        }  
        cout << endl;  
    };  
    int array[] = { 1, 3, 5, 7, 9 };  
    //_1,_2 是占位符  
    auto fun1 = bind(fun, _1, _2, 5);  
    //等价于调用fun(array, sizeof(array) / sizeof(*array), 5);  
    fun1(array, sizeof(array) / sizeof(*array));  
    cin.get();  
    return 0;  
}

fun1函数由闭包转化而来,bind就是用于这种捆绑转化,它通过占位符汲取了闭包的第一、二个参数,然后将第三个参数置为5。这样就形成了:

void fun(int* array,int n)
{
        for (int i = 0; i < n; i++)  
        {  
            if (array[i] > 5)  
                cout << array[i] << ends;  
        }  
        cout << endl;  
};

在C++11中我们可以用bind+lambda实现14中的移动捕获

std::vector<double> data;
...
auto func = std::bind(
    [](const std::vector<double>& data) {...},
    std::move(data)
);

默认情况下closure class的operator()会被认为是const,因此我们在lambda中无法修改捕获的对象。这时我们可以给lambda添加上mutable标识符,令它可以修改捕获的对象:

auto func = std::bind(
    [](std::vector<double>& data) mutable {...},
    std::move(data)
);

Item 33:Use decltype on auto&& parameters to std::forward them.

C++14的一项引入注目的新功能就是泛型lambda,即lambda的参数可以用auto来修饰。

它的实现很直接:closure class的operator()是个模板函数。给定下面的lambda:···

auto f = [](auto x) {return normalize(x);};

// closure class
class SomeClosureClass {
public:
    template <typename T>
    auto operator()(T x) const {
        return normalize(x);
    }
    ...
};

上面的例子中,如果normalize处理左值参数和右值参数的方式上有区别,那么我们写的还不算对,应该用上完美转发。

auto f = [](auto&& x) 
{ return normalize(std::forward<???>(x)); };

但问题是std::forward的实例化类型是什么。通常的完美转发我们能有一个模板参数T,但在泛型lambda中我们只有auto。closure class的模板函数中有这个T,但我们没办法用上它。decltype可以解决这个问题!

我们在Item3学到过:通过decltype保证返回变量的本来类型这一特性,保证不丢失CV限制符,和引用等。

auto f = [](auto&& x) {
    return normalize(std::forward<decltype(x)>(x));
};

甚至还支持变量个数可变的泛型lambda:

auto f = [](auto&&... xs) {
    return normalize(std::forward<decltype(xs)>(xs)...);
};