欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2022-05-13 16:14:31
使用初始化捕获来把对象移动到闭包 有时候,你想要的既不是值捕获,也不是引用捕获。如果你想要把一个只可移动对象(例如,std::unique_ptr或std::future类型对象...

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

有时候,你想要的既不是值捕获,也不是引用捕获。如果你想要把一个只可移动对象(例如,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捕获。)

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

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

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

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

auto pw = std::make_unique();  //创建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()]        // 以调用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;

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

这比起写lambda多做了很多工作,事实上没有改变:在C++11中,如果你想要一个支持成员变量移动初始化的类,那么你和你的需求之间相隔的唯一东西,就是花费一点时间在你的键盘上。

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

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

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

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

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

...       // 添加数据

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

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

std::vector data;        // 如前

...           // 添加数据

auto func =               // 引用捕获的C++11模仿物
    std::bind(
      [](const std::vector& 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& 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()] // 如前,在闭包内创建pw
  { return pw->isValidated() && pw->isArchived(); };

这是C++11的模仿物:

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

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


总结

需要记住的2点:

使用C++14的初始化捕获来把对象移到到闭包。 在C++11,借助手写类或std::bind模仿初始化捕获。