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模仿初始化捕獲。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章