Effective Modern C++ 條款31 對於lambda表達式,避免使用默認捕獲模式

對於lambda表達式,避免使用默認捕獲模式

個人看法 英文原著中使用的是“avoid default capture modes”,所以我在文中翻譯爲“避免使用默認捕獲模式”,但是我認爲把“默認捕獲模式”稱爲“隱式捕獲模式”更好,因爲作者所指的“默認捕獲模式”是指在捕獲語句中只出現等號或者引用符號(即“[=]”或“[&]”),而不出現捕獲的變量名,但爲了符合英文,還是把“default capture”翻譯爲“默認捕獲”。

C++11中有兩種默認捕獲模式:引用捕獲或值捕獲。默認的引用捕獲模式可能會導致懸掛引用,默認的值捕獲模式誘騙你——讓你認爲你可以免疫剛說的問題(事實上沒有免疫),然後它又騙你——讓你認爲你的閉包是獨立的(事實上它們可能不是獨立的)。

那就是本條款的總綱。如果你是工程師,你會想要更具體的內容,所以讓我們從默認捕獲模式的危害開始說起吧。

引用捕獲會導致閉包包含一個局部變量的引用或者一個形參的引用(在定義lamda的作用域)。如果一個由lambda創建的閉包的生命期超過了局部變量或者形參的生命期,那麼閉包的引用將會空懸。例如,我們有一個容器,它的元素是過濾函數,這種過濾函數接受一個int,返回bool表示傳入的值是否可以滿足過濾條件:

using FilterContainer =                       // 關於using,看條款9
    std::vector<std::function<bool(int)>>;    // 關於std::function,看條款2

FilterContainer filters;               // 含有過濾函數的容器 

我們可以通過添加一個過濾器,過濾掉5的倍數,像這樣:

filters.emplace_back(         // 關於emplace_back, 看條款42
  [](int value) { return value % 5 == 0; }
);

但是,我們可能需要在運行期間計算被除數,而不是直接把硬編碼5寫到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返回之後,所以添加到容器的函數本質上就像是一到達容器就死亡了。使用那個過濾器會產生未定義行爲,這實際上是在創建過濾器的時候就決定好的了。

現在呢,如果顯式引用捕獲divisor,會存在着同樣的問題:

filters.emplace(
  [&divisor](int value)          // 危險!對divisor的引用依然會空懸
  { return value % divisor == 0; }
);

不過使用顯示捕獲,很容易就可以看出lambda的活性依賴於divisor的生命期。而且,寫出名字“divisor”會提醒我們,要確保divisor的生命期至少和lambda閉包一樣長。比起用“[&]”表達“確保不會空懸”,顯式捕獲更容易讓你想起這個告誡。

如果你知道一個閉包創建後馬上被使用(例如,傳遞給STL算法)而且不會被拷貝,那麼引用的局部變量或參數就沒有風險。在這種情況下,你可能會爭論,沒有空懸引用的風險,因此沒有理由避免使用默認的引用捕獲模式,例如,我們的過濾lambda只是作爲C++11的std::all_of的參數(std::all_of返回範圍內元素是否都滿足某個條件):

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;    // 關於通用性,看條款13
    using std::end;      // 條款13

    if (std::all_of(                    // 如果容器中的元素都是divisor的倍數...
         begin(container), end(container),
         [&](const ContElemT& value)
         { return value % divisor == 0; }
        )  {
        ...         
    } else {
        ...
    }
} 

當然,這是安全的,但是它的安全有點不穩定,如果發現lambda在其他上下文很有用(例如,作爲函數加入到過濾器容器),然後拷貝及粘貼到其他上下文,在那裏divisor已經死亡,而閉包還健全,你又回到了空懸的境地,同時,在捕獲語句中,也沒有特別提醒你對divisor進行生命期分析(即沒有顯式捕獲)。

從長期來看,顯式列出lambda依賴的局部變量或形參是更好的軟件工程。

順便說下,C++14的lambda形參可以使用auto聲明,意味着上面的代碼可以用C++14簡化,ContElemT的那個typedef可以刪去,然後把if語句的條件改成這樣:

if (std::all_of(begin(container), end(container),
                [&](const auto& value)         // C++14
                { return value % divisor == 0; })

解決這個問題的一種辦法是對divisor使用默認的值捕獲模式。即,我們這樣向容器添加lambda:

filters.emplace_back(            // 現在divisor就不會空懸
  [=](int value)  { return value % divisor == 0; }

這滿足這個例子的需要,但是,總的來說,默認以值捕獲不是對抗空懸的長生不老藥。問題在於,如果你用值捕獲了個指針,你在lambda創建的閉包中持有這個指針的拷貝,但你不能阻止lambda外面的代碼刪除指針指向的內容,從而導致你拷貝的指針空懸。

“這不可能發生”你在抗議,“自從看了第四章,我十分熱愛智能指針。只有智障的C++98程序員纔會使用原生指針和delete。”你說的可能是正確的,但這是不相關的,因爲實際上你真的會使用原生指針,而它們實際上也會在你眼皮底下被刪除,只不過在你的現代C++編程風格中,它們(原生指針)在源代碼中不露跡象。。

假如Widget類可以做的其中一件事是,向過濾器容器添加條目:

class Widget {
public:
    ...          // 構造函數等
    void addFilter() const;  // 添加一個條目

private:
    int divisor;         // 用於Widget的過濾器中
};

Widget::addFilter可能定義成這樣:

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

對於外行人,這看起來像是安全的代碼。lambda依賴divisor,但默認的以值捕獲模式確保了divisor被拷貝到lambda創建的閉包裏,對嗎?

錯了,完全錯了。

捕獲只能用於可見(在創建lambda的作用域可見)的非static局部變量(包含形參)。在Widget::addFilter內部,divisor不是局部變量,它是Widget類的成員變量,它是不能被捕獲的,如果默認捕獲模式被刪除,代碼就不能編譯了:

void Widget::addFilter() const
{
    filters.emplace_back(              
      [](int value) { return value % divisor == 0; }   // 錯誤,不能得到divisor
    );
}

而且,如果試圖顯式捕獲divisor(無論是值捕獲還是引用捕獲,這都沒有關係),捕獲不會通過編譯,因爲divisor不是局部變量或形參:

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

所以如果在默認值捕獲語句中(即“[=]”),捕獲的不是divisor,而不是默認值捕獲語句就不能編譯,那麼前者發生了什麼?

問題解釋取決於原生指針的隱式使用:this。每一個非static成員函數都有一個this指針,然後每當你使用類的成員變量時都用到這個指針。例如,在Widget的一些成員函數中,編譯器內部會把divisor替換成this->divisor。在Widget::addFiliter的默認值捕獲版本中,

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

被捕獲的是Widget的this指針,而不是divisor,編譯器把上面的代碼視爲這樣寫的:

void Widget::addFilter() const 
{
    auto currentObjectPtr = this;

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

理解了這個就相當於理解了lambda閉包的活性與Widget對象的生命期有緊密關係,閉包內含有Widget的this指針的拷貝。特別是,思考下面的代碼,它根據第4章,只是用智能指針:

using FilterContainer = std::vector<std::function<bool(int)>>;   // 如前

FilterContainer filters;         // 如前

void doSomeWork()
{
    auto pw = std::make_unique<Widget>();  // 創建Widge
                                           // 關於std::make_unique,看條款21
    pw->addFilter();     // 添加使用Widget::divisor的過濾函數

    ...
}             // 銷燬Widget,filters現在持有空懸指針!

當調用doSomeWork時,創建了一個過濾函數,它依賴std::make_unique創建的Widget對象,即,那個過濾函數內含有指向Widget指針——即,Widget的this指針——的拷貝。這個函數被添加到filters中,不過當doSomeWork執行結束之後,Widget對象被銷燬,因爲它的生命期由std::unique_ptr管理(看條款18)。從那一刻起,filters中含有一個帶空懸指針的條目。

通過將你想捕獲的成員變量拷貝到局部變量中,然後捕獲這個局部拷貝,就可以解決這個特殊的問題了:

void Widget::addFilter() const 
{
    auto divisorCopy = divisor;          // 拷貝成員變量
    
    filters.emplace_back(
      [divisorCopy](int value)            // 捕獲拷貝
      { return value % divisorCopy == 0; }   // 使用拷貝
    );
}

實話說,如果你用這種方法,那麼默認值捕獲也是可以工作的:

void Widget::addFilter() const
{
    auto divisorCopy = divisor;          // 拷貝成員變量

    filters.emplace_back(
      [=](int value)                // 捕獲拷貝
      { return value % divisorCopy == 0; } //使用拷貝
    );
};

但是,我們爲什麼要冒險呢?在一開始的代碼,默認值捕獲就意外地捕獲了this指針,而不是你以爲的divisor

在C++14中,捕獲成員變量一種更好的方法是使用廣義lambda捕獲(generalized lambda capture,即,捕獲語句可以是表達式,看條款32):

void Widget::addFilter() const 
{
    filters.emplace_back(               // C++14
      [divisor = divisor](int value)    // 在閉包中拷貝divisor
      { return value % divisor == 0; }  // 使用拷貝
    );
}

廣義lambda捕獲沒有默認捕獲模式,但是,就算在C++14,本條款的建議——避免使用默認捕獲模式——依然成立。

使用默認值捕獲模式的一個另外的缺點是:它們表明閉包是獨立的,不受閉包外數據變化的影響。總的來說,這是不對的,因爲lambda可能不會依賴於局部變量和形參,但它們會依賴於靜態存儲週期的對象(static storage duration)。這樣的對象定義在全局作用域或者命名空間作用域,又或者在類中、函數中、文件中聲明爲static。這樣的對象可以在lambda內使用,但是它們不能被捕獲。如果你使用了默認值捕獲模式,這些對象會給你錯覺,讓你認爲它們可以捕獲。思考下面這個修改版本的addDivisorFilter函數:

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

    static auto divisor = computeDivisor(calc1, calc2);   // static

    filters.emplace_back(
      [=](int value)                    // 沒有捕獲任何東西
      { return value % divisor == 0; }  // 引用了上面的divisor
    );

    ++divisor;          // 修改divisor
};

這份代碼,對於隨便的讀者,他們看到“[=]”然後想,“很好,lambda拷貝了它內部使用的對象,因此lambda是獨立的。”,這可以被諒解。但這lambda不是獨立的,它沒有使用任何的非static局部變量和形參,所以它沒有捕獲任何東西。更糟的是,lambda的代碼引用了static變量divisor。在每次調用addDivisorFilter的最後,divisor都會被遞增,通過這個函數,會把好多個lambda添加到filiters,每一個lambda的行爲都是新的(對應新的divisor值)。從實踐上講,這個lambda是通過引用捕獲divisor,和默認值捕獲語句表示的含義有直接的矛盾。如果你一開始就遠離默認的值捕獲模式,你就能消除理解錯代碼的風險。


總結

需要記住的2點:

  • 默認引用捕獲會導致空懸引用。
  • 默認值捕獲對空懸指針(尤其是this)很敏感,而且它會誤導地表明lambda是獨立的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章