条款32:使用初始化捕获将对象移入闭包

有时,按值的捕获和按引用的捕获皆非你所欲。如果你想要把一个只移动对象(例如, std::unique_ptr或std::future型别的对象)放入闭包,C++11未提供任何办法做到此事。如果你有个对象,其复制操作开销昂贵,而移动操作成本低廉(例如,大部分标准容器库),而你又需要把该对象放入闭包。那么你肯定更愿意移动该对象,而非复制它。但是,C++11中也还是没有让你实现这一点的途径。

但那只是C++11,C++14则有云泥之别。它为对象移动入闭包提供了直接支持。如果你的编译器兼容C++14,则只需欢呼雀跃尔后继续阅读本书即可。但如果你还在使用C++11编译器,则你仍可欢呼尔后继续阅读本文,因为C++11中有近似达成移动捕获行为的做法。

移动捕获的缺失即使在C++11标准被刚接受时,也被视为一种缺陷。最直接的补救措施本是在C++14中添加这一特性,但标准委员会却另辟蹊径。委员们提出了一种全新的捕获机制,它是如此灵活,按移动的捕获只不过属于该机制能够实现的多种效果之一罢了。这种新能力称为初始化捕获。实际上,它可以做到C++11的捕获形式能够做到的所有事情,而且还不止如此。初始化捕获不能表示者,则是默认捕获模式,但是条款31解释过,这是你无论如何都能应该原理的一种模式(对于将C++11捕获可以实现的情况,若用初始化捕获语法则会稍显啰嗦,所以若使用C++11捕获已能解决问题,则大可以使用之)。

使用初始化捕获,则你会得到机会指定:

  1. 由lambda生成的闭包类中的成员变量的名字。
  2. 一个表达式,用以初始化该成员变量。

以下是如何使用初始化捕获将std::unique_ptr移动到闭包内:

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

private:
    ...
};

...                                     //配置 *pw

auto pw = std::make_unique();   //创建Widget
                                        //关于std::make_unique,参见条款21

auto func = [pw = std::move(pw)]        //采用std::move(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”这条注释表明,在Widget经由std::make_unique创建之后,并在指涉到该Widget的std::unique_ptr被lambda式捕获之前,该Widget会在某些方面加以修改。如果这样的配置并非必要动作。即,经由std::make_unique创建的Widget对象已具备被lambda式捕获的合适状态,则作为局部变量pw就亦非必要,因为闭包类成员变量可以经由std::make_unique实施初始化:

auto func = [pw = std::make_unique()]  //经以make_unique的调用结果
            { return pw->isValidated() && pw->isArchived();}; //初始化闭包类的成员

这里,应该已经昭明,C++11的“捕获”概念在C++14中得到了显著的泛化,因为在C++11中不可能捕获一个表达式的结果。因此,初始化捕获还有另一美名,称为广义lambda捕获。

但如果你使用的编译器缺少对C++14初始化捕获的支持,又将如何是好?在不支持按移动捕获的语言中,又该如何实现按移动捕获呢?

回想一下,一个Lambda表达式不过是生成一个类并且创建一个该类的对象的手法罢了。并不存在lambda能做,而你手工做不到的事情。以上面所见的C++14示例代码为例,就可以如下用C++11写作:

class IsValAndArch                                   //表示“已校验并已归档”
{
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中可以采用一下方法模拟,只需要:

  1. 把需要捕获的对象移动到std::bind产生的函数对象中。
  2. 给到lambda式一个指涉到欲“捕获”的对象的引用

如果你本来就熟悉std::bind,代码就显得十分直截了当。如果你还不熟悉std::bind,则代码需要一些时间来习惯,但这些投入是值得的。

假如你想要创建一个局部的std::vector对象,向其放入合适的一组值,然后将其移入闭包。在C++14,这是举手之劳:

std::vecot data;   //欲移入闭包的对象

...                        //灌入数据

auto func = [data=std::move(data)]   //C++14的初始化捕获
            { /*对数据加以运用*/  };

代码的关键部分已加以突显:欲移动的对象的型别(std::vector)和该对象的名字(data),还为初始化捕获而准备的初始化表达式(std::move(data))。使用C++11撰写的等价代码如下,我也把同样的关键部分加以突显:

std::vecot data;   //同前

...                        //同前

auto func =
    std::bind(                               //C++11中
      [](const std::vector& data)    //模拟初始化捕获的部分
      {  /*对数据加以运用*/ },
      std::move(data)
);

和lambda表达式类似地,std::bind也生成函数对象。我称std::bind返回的对象为绑定对象,std::bind的第一个实参是科调用对象,接下来的所有实参表示传给该对象的值。

绑定对象含有传递给std::bind所有实参的副本,对于每个左值实参,在绑定对象内的对应的对象内对其实施的是复制构造;对对于每个右值实参,实施的则是移动构造。在这个例子中,第二个实参是个右值(即std::move的结果,参见条款23),所以data在绑定对象中实施的是移动构造。而该移动构造动作是实现模拟移动捕获的核心所在,因为把右值移入绑定对象,正是绕过C++11无法将右值到闭包的手法。

当一个绑定的对象被“调用”(即,其函数调用运算符被唤起)时,它所存储的实参会传递给原先传递给std::bind的那个可调用对象。在本例中,也就是当func(绑定对象)被调用时,func内经由移动构造出所得到的data的副本就会作为实参传递给那个原先传递给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(                                 //C++11中针对可变lambda式
      [](std::vector& data) mutable    //模拟初始化捕获的部分
      {  /*对数据加以运用*/ },
      std::move(data)
);

因为绑定对象存储着传递给std::bind所有实参的副本,在本例中的绑定对象就包含一份由作为std::bind的第一个实参的lambda式产生的闭包的副本。这么一来,该闭包的生命期和绑定对象就是相同的。这一点很重要,因为这意味着只要闭包还存在,则绑定对象内的伪移动捕获对象也存在。

如果这是你和std::bind的首次接触,那么再深究前面讨论的细节之前,你可能需要先垂询最喜欢C++11参考书,即使如此,现在你至少也已经弄清楚了以下基础知识:

  1. 移动构造一个对象入C++11闭包是不可能实现的,但移动构造一个对象入绑定对象则是可能实现的
  2. 欲在C++11中模拟移动捕获包括一下步骤:先构造一个对象入绑定对象,然后按引用把该移动构造所得的对象传递给lambda式。
  3. 因为绑定对象的生命期和闭包相同,所以针对绑定对象中的对象和闭包里的对象可以采用同样手法加以处置。

关于使用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->isArchied(); },
        std::make_unique()
    };
    
);

我在这里展示的是如何使用std::bind来绕开C++11中lambda式语法的限制,这不免有些讽刺,因为在条款34中,我又提倡优先选用lambda式,而非std::bind。不过那个条款也有解释说,C++11中有一些情况下,std::bind可能会更有用,而以上就是这些情况之一(在C++14中,像初始化捕获和auto形参这些特征可以消除那些情况)。

要点速记

  1. 使用C++14的初始化捕获将对象移入闭包
  2. 在C++11中,经由手工实现的类或std::bind去模拟初始化捕获。

你可能感兴趣的:(Effective,Modern,C++,开发语言,c++)