Effective Modern C++ 条款32 对于lambda,使用初始化捕获来把对象移动到闭包

使用初始化捕获来把对象移动到闭包

有时候,你想要的既不是值捕获,也不是引用捕获。如果你想要把一个只可移动对象(例如,std::unique_ptrstd::future类型对象)放入闭包中,C++11没有办法做这事。如果你有个对象的拷贝操作昂贵,但移动操作廉价(例如,大部分的标准容器),然后你需要把这个对象放入闭包中,那么比起拷贝这个对象你更愿意移动它。但是,C++11还是没有办法完成这事。

但那是C++11,C++14就不一样啦,它直接支持将对象移动到闭包。如果你的编译器支持C++14,欢呼吧,然后继续读下去。如果你依然使用C++11的编译器,你还是应该欢呼和继续读下去,因为C++11有接近移动捕获行为的办法。

缺少移动捕获被认为是C++11的一个缺陷,最直接的补救方法是在C++14中加上它,但标准委员会采用了另外一种方法。它们提出了一种新的、十分灵活的捕获技术,引用捕获只是属于这种技术的其中一种把戏。这种新能力被称为初始化捕获(init capture),实际上,它可以做C++11捕获格式能做的所有事情,而且更多。初始化捕获不能表示的是默认捕获模式,不过条款31解释过无论如何你都应该远离默认捕获模式。(对于将C++11捕获转换为初始化捕获的情况,初始化捕获的语法会比较啰嗦,所以如果C++11捕获能解决问题的情况下,最好使用C++11捕获。)

使用初始化捕获让你有可能指定

  1. 成员变量的名字(留意,这是闭包类的成员变量,这个闭包类由lambda生成)和
  2. (初始化那成员变量的)表达式 。

这里是如何使用初始化捕获来把std::unique_ptr移动到闭包内:

class Widget {
public:
    ...
    bool isValidated() const;
    bool isProcessed() const;
    bool isArchived() const;
private:
    ...
};

auto pw = std::make_unique<Widget>();  //创建Widget

...              // 配置*pw

auto func = [pw = std::move(pw)]  // 以std::move(pw)来初始化闭包中成员变量pw
            { return pw->isValidated() && pw->isArchived(); }  

初始化捕获的代码部分是pw = std::move(pw),“=”左边的是你指定的闭包类的成员变量名,右边的是进行初始化表达式。有趣的是,“=”左边的作用域和右边的作用域不同,左边的作用域是在闭包类内,而右边的作用域和lambda被定义的地方的作用域相同。在上面的例子中,“=”左边的名字pw指的是闭包类的成员变量,而右边的名字pw指的是在lambda之前声明的对象,即由make_unique创建的对象。所以pw = std::move(pw)的意思是:在闭包中创建一个成员变量pw,然后用——对局部变量pw使用std::move的——结果初始化那个成员变量。

通常,lambda体内代码的作用域在闭包类内,所以代码中的pw指的是闭包类的成员变量。

在上面例子中,注释“配置*pw”表明了在std::make_unique创建Widget之后,在lambda捕获指向Widget的std::unique_ptr之前,Widget在某些方面会被修改。如果这个配置不是必需的,即,如果std::make_unique创建的Widget对象的状态已经适合被lambda捕获,那么局部变量pw是不必要的,因为闭包类的成员变量可以直接被std::make_unique初始化:

auto func = [pw = std::make_unique<Widget>()]        // 以调用make_unique的结果
            { return pw->isValidated() && pw->isArchived(); }; // 来初始化闭包的局部变量pw

这应该清楚地表明在C++14中,C++11的“捕获”概念得到显著推广,因为在C++11,不可能捕获一个表达式的结果。因此,初始化捕获的另一个名字是generalized lambda capture(广义lambda捕获?)。

但如果你使用的编译器不支持C++14的初始化捕获,那该怎么办呢?在不支持引用捕获的语言中,你该怎样完成引用捕获呢?

你要记得,一个lambda表达式会生成一个类,而且会创建那个类的对象。lambda做不了的事情,你自己手写的类可以做。例如,就像上面展示的C++14的lambda代码,在C++11中可被写成这样:

class IsValAndArch {           // "is validated and archived
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多做了很多工作,事实上没有改变:在C++11中,如果你想要一个支持成员变量移动初始化的类,那么你和你的需求之间相隔的唯一东西,就是花费一点时间在你的键盘上。

如果你想要坚持使用lambda,C++11可以模仿移动捕获,通过

  1. 把需要捕获的对象移动到std::bind产生的函数中,
  2. 给lambda一个要“捕获”对象的引用(作为参数)。

如果你熟悉std::bind,代码是很直截了当的;如果你不熟悉std::bind,代码会有一些需要习惯的、但值得的问题。

假如你创建了一个局部的std::vector,把一系列合适的值放进去,然后想要把它移动到闭包中。在C++14,这很容易:

std::vector<double> data;     // 要移动到闭包的对象

...       // 添加数据

auto func = [data = std::move(data)]    // C++14初始化捕获
            {  /* uses of data */ };

这代码的关键部分是:你想要移动的对象的类型(std::vector<double>)和名字(data),还有初始化捕获中的初始化表达式(std::move(data))。C++11的对等物也是一样:

std::vector<double> data;        // 如前

...           // 添加数据

auto func =               // 引用捕获的C++11模仿物
    std::bind(
      [](const std::vector<double>& data)     // 代码关键部分!
      { /* uses of data */ },
      std::move(data);              // 代码关键部分!
   );

类似于lambda表达式,std::bind产生一个函数对象。我把std::bind返回的函数对象称为bind object(绑定对象)。std::bind的第一个参数是一个可执行对象,后面的参数代表传给可执行对象的值。

一个绑定对象含有传递给std::bind的所有实参的拷贝。对于每一个左值实参,在绑定对象内的对应的对象被拷贝构造,对于每一个右值实参,对应的对象被移动构造。在这个例子中,第二个实参是右值(std::move的结果——看条款23),所以data在绑定对象中被移动构造。这个移动构造是移动捕获模仿物的关键,因为把一个右值移动到绑定对象,我们就绕过C++11的无能——无法移动一个右值到C++11闭包。

当一个绑定对象被“调用”(即,它的函数调用操作符被调用),它存储的参数会传递给最开始的可执行对象(std::bind的第一个参数)。在这个例子中,那意味着当func(绑定对象)被调用时,func里的移动构造出的data拷贝作为参数传递给lambda(即,一开始传递给std::bind的lambda)。

这个lambda和C++14版本的lambda一样,除了形参,data,它相当于我们的虚假移动捕获对象。这个参数是一个——对绑定对象内的data拷贝的——左值引用。(它不是一个右值引用,因为,即使初始化data拷贝的表达式是std::move(data),但data拷贝本身是一个左值。)因此,在lambda里使用的data,是在操作绑定对象内移动构造出的data的拷贝。

默认地,lambda生成的闭包类里的operator()成员函数是const的,这会导致闭包里的所有成员变量在lambda体内都是const。但是,绑定对象里移动构造出来的data拷贝不是const的,所以为了防止data拷贝在lambda内被修改,lambda的形参声明为常量引用。如果lambda被声明为mutable,闭包里的operator()函数就不会被声明为const,所以此时在lambda声明中省略const比较合适:

auto func = 
    std::bind(                             // 可变lambda,初始化捕获的C++11模仿物
      [](std::vector<double>& data) mutable
      { /* uses of data */ },
      std::move(data);
  );

因为一个绑定对象会存储传给std::bind的所有实参的拷贝,在我们的例子中,绑定对象持有一份由lambda产生的闭包的拷贝,它是std::bind的第一个实参。因此闭包的生命期和绑定对象的生命期相同,那是很重要的,因为这意味着只要闭包存在,绑定对象内的虚假移动捕获对象也存在。

如果这是你第一次接触std::bind,那么在深陷之前讨论的细节之前,你可能需要咨询你最喜欢的C++11参考书了。即使是这种情况,这些关键点你应该要清楚:

  • 在一个C++11闭包中移动构造一个对象是不可能的,但在绑定对象中移动构造一个对象是有可能的。
  • 在C++11中模仿移动捕获需要在一个绑定对象内移动构造出一个对象,然后把该移动构造对象以引用传递给lambda。
  • 因为绑定对象的生命期和闭包的生命期相同,可以把绑定对象中的对象(即除可执行对象外的实参的拷贝)看作是闭包里的对象。

作为使用std::bind模仿移动捕获的第二个例子,这里是我们之前看到的在C++14,闭包内创建std::unique_ptr的代码:

auto func = [pw = std::make_unique<Widget>()]   // 如前,在闭包内创建pw
            { return pw->isValidated() && pw->isArchived(); };

这是C++11的模仿物:

auto func = std::bind(
              [](const std::unique_ptr<Widget>& pw)
              { return pw->isValidated() && pw->isArchived(); },
              std::make_unique<Widget>()
           );

我展示了如何使用std::bind来绕开C++11的lambda的限制,这是很讽刺的,因为在条款34中,我提倡尽量使用lambda来代替std::bind。但是,那条款解释了,在C++11的某些情况std::bind是有用的,这里就是其中一个例子。(在C++14,初始化捕获和auto形参这两个特性可以消除那些情况。)


总结

需要记住的2点:

  • 使用C++14的初始化捕获来把对象移到到闭包。
  • 在C++11,借助手写类或std::bind模仿初始化捕获。
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页