對於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是獨立的。