Effective Modern C++:06lambda表示式
lambda表示式實際上是語法糖,任何lambda表示式能做到的,手動都能做到,無非是多打幾個字。但是lambda作為一種建立函式物件的手段,實在太過方便,自從有了lambda表示式,使用複雜謂詞來呼叫STL中的”_if”族演算法(std::find_if,std::remove_if等)變得非常方便,這種情況同樣發生在比較函式的演算法族上。在標準庫之外,lambda表示式可以臨時製作出回撥函式、介面適配函式或是語境相關函式的特化版本以供一次性呼叫。下面是關於lambda相關術語的提醒:
lambda表示式,是表示式的一種,比如下面程式碼中紅色的就是lambda表示式:
std::find_if(container.begin(), container.end(), [](int val) { return 0 < val && val < 10; });
閉包,是lambda表示式建立的執行期物件,在上面對std::find_if的呼叫中,閉包就是作為第三個實參在執行期傳遞給std::find_if的物件。
閉包類,是例項化閉包的類,每個lambda表示式都會觸發編譯器生成一個獨一無二的閉包類,而lambda表示式中的語句會變成閉包類成員函式的可執行指令。
閉包可以複製,所以,對應於單獨一個lambda表示式的閉包型別可以由多個閉包:
int x; // x is local variable auto c1 = [x](int y) { return x * y > 55; }; // c1 is copy of the closure produced by the lambda auto c2 = c1; // c2 is copy of c1 auto c3 = c2; // c3 is copy of c2 …
c1、c2和c3都是同一個lambda表示式產生的閉包的副本。
在非正式場合,lambda表示式,閉包和閉包類之間的界限可以模糊一些。但是在下面的條款中,需要能區別哪些存在於編譯期(lambda表示式和閉包類),哪些存在於執行期(閉包),以及它們之間的相互聯絡。
31:避免預設捕獲模式
C++11中有兩種預設捕獲模式:按引用或按值。按引用的預設捕獲模式可能導致空懸引用,而按值的預設捕獲模式可能會讓你覺得不存在空懸引用的問題(實際上不是)。
按引用捕獲會導致閉包包含指向區域性變數(或形參)的引用,一旦由lambda表示式所建立的閉包的生命期超過了該區域性變數或形參的生命期,那麼閉包內的引用就會空懸,比如下面的程式碼:
using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters; // filtering funcs void addDivisorFilter() { auto calc1 = computeSomeValue1(); auto calc2 = computeSomeValue2(); auto divisor = computeDivisor(calc1, calc2); filters.emplace_back( [&](int value) { return value % divisor == 0; } ); }
這段程式碼隨時會出錯,lambda中按引用捕獲了局部變數divisor,但當addDivisorFilter函式返回時區域性變數被銷燬,使用filters就會產生未定義行為。
如果不這樣做,使用顯式方式按引用捕獲divisor,問題依然存在:
filters.emplace_back( [&divisor](int value) { return value % divisor == 0; } );
但是通過顯示捕獲,就比較容易看出lambda表示式的生存依賴於divisor的生命期。顯式的寫出”divisor”可以提醒我們要保證divisor至少應該和lambda具有一樣長的生命期,這要比[&]這種所傳達的不痛不癢的“要保證沒有空懸引用”式的勸告更讓人印象深刻。
如果知道閉包會立即使用(比如傳遞給STL演算法)且不會被複制,這種情況下,你可能會爭論說,既然沒有空懸引用的風險,也就沒有必要避免使用預設引用捕獲模式。但是從長遠觀點來看,顯示的列出lambda表示式所依賴的區域性變數或形參,是更好的軟體工程實踐。
上面的例子中,解決問題的一種辦法是對divisor採用按值的預設捕獲模式:
filters.emplace_back( [=](int value) { return value % divisor == 0; } );
對於這個例子而言,這樣做確實是沒問題的。但是按值的預設捕獲並非一定能避免空懸引用,問題在於如果按值捕獲了一個指標,在lambda表示式建立的閉包中持有的是這個指標的副本,但是沒有辦法阻止lambda表示式之外的程式碼針對該指標實施delete操作導致的指標副本空懸。比如下面的程式碼:
class Widget { public: … // ctors, etc. void addFilter() const; // add an entry to filters private: int divisor; // used in Widget's filter }; void Widget::addFilter() const { filters.emplace_back( [=](int value) { return value % divisor == 0; } ); }
這樣的程式碼看起來安全,然而實際上卻是大錯特錯的。捕獲只能針對於在建立lambda表示式的作用域內可見的非靜態區域性變數(包括形參),而在Widget::addFilter函式體內,divisor並非區域性變數,而是Widget類的成員變數,它根本沒辦法捕獲。這麼一來,如果不使用預設捕獲模式,程式碼就不會通過編譯:
void Widget::addFilter() const { filters.emplace_back( [](int value) { return value % divisor == 0; } ); }
而且,如果試圖顯示捕獲divisor(無論是按值還是按引用),這個捕獲語句都不能通過編譯,因為divisor既不是區域性變數,也不是形參:
void Widget::addFilter() const { filters.emplace_back( [divisor](int value) { return value % divisor == 0; } ); }
但是為什麼一開始的程式碼沒有發生編譯錯誤呢?this指標是關鍵所在,每一個非靜態成員函式都持有一個this指標,每當提及該類的成員變數時都會用到這個指標。比如在Widget的任何成員函式中,編譯器內部都會把divisor替換成this->divisor。因此,在Widget::addFilter的按值預設捕獲版本中,被捕獲的實際上是Widget的this指標,而不是divisor。從編譯器的角度來看,實際的程式碼相當於:
void Widget::addFilter() const { auto currentObjectPtr = this; filters.emplace_back( [currentObjectPtr](int value) { return value % currentObjectPtr->divisor == 0; } ); }
因此,該lambda閉包的存活,與它含有this指標指向的Widget物件的生命期是綁在一起的,比如下面的程式碼:
using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters; void doSomeWork() { auto pw = std::make_unique<Widget>(); pw->addFilter(); … }
當呼叫doSomeWork時建立了一個篩選函式,它依賴於std::make_unique建立的Widget物件,該函式被新增到filters中,然而當doSomeWork結束後,Widget物件隨著std::unique_ptr的銷燬而銷燬,從那一刻起,filters中就含有了一個帶有空懸指標的元素。
這一問題可以通過將想捕獲的成員變數複製到區域性變數中,而後捕獲該區域性變數的部分得意解決:
void Widget::addFilter() const { auto divisorCopy = divisor; filters.emplace_back( [divisorCopy](int value) { return value % divisorCopy == 0; } ); }
在C++14中,捕獲成員變數的一種更好的方式是使用廣義lambda捕獲(generalized lambda):
void Widget::addFilter() const { filters.emplace_back( [divisor = divisor](int value) { return value % divisor == 0; } ); }
對廣義lambda捕獲而言,沒有預設捕獲模式一說,但是,就算在C++14中,本條款的建議,避免使用預設捕獲模式依然成立。
使用按值預設捕獲的另一個缺點,在於它似乎表明閉包是自治的,與閉包外的資料變化絕緣,然而作為一般性的結論,這是不正確的。因為lambda表示式可能不僅依賴於區域性變數或形參,他還可能依賴於靜態儲存期物件,這樣的物件定義在全域性或名字空間作用域中,或是在類,函式,檔案中以static飾詞宣告。這樣的物件可以在lambda內使用,但是它們不能被捕獲。如果使用了按值預設捕獲模式,這些物件就會給人以錯覺,認為它們可以加以捕獲:
void addDivisorFilter() { static auto calc1 = computeSomeValue1(); static auto calc2 = computeSomeValue2(); static auto divisor = computeDivisor(calc1, calc2); filters.emplace_back( [=](int value) { return value % divisor == 0; } ); ++divisor; }
看到[=]就認為lambda複製了它內部使用的物件,得出lambda是自治的這種結論,是錯誤的。實際上該lambda表示式並不獨立,它沒有使用任何的非靜態區域性變數或形參,所以它沒能捕獲任何東西。更糟糕的是lambda表示式的程式碼中使用了靜態變數divisor,每次呼叫addDivisorFilter後,divisor會遞增,使得新增到filters中的每個lambda表示式的行為都不一樣。如果一開始就避免使用按值的預設捕獲模式,也就能消除程式碼被誤讀的風險了。
32:能夠初始化捕獲將物件移入閉包
有時按值捕獲和按引用捕獲並不能滿足所有的需求。比如想要把move-only物件(如std::unique_ptr或std::future)放入閉包,或者想把複製昂貴而移動低廉的物件移入閉包時,C++11沒有提供可行的方法,但是C++14為物件移動提供了直接支援。
實際上,C++14提供了一種全新的捕獲方式,按移動的捕獲只不過是該機制能夠實現的多種效果之一罷了。這種方式稱為初始化捕獲(init capture),它可以做到C++11的捕獲形式所有能夠做到的事情(除了預設捕獲模式,而這是需要遠離的),不過初始化捕獲的語法稍顯囉嗦,如果C++11的捕獲能解決問題,則大可以使用之。
下面是初始化捕獲實現移入捕獲的例子:
class Widget { public: bool isValidated() const; bool isProcessed() const; bool isArchived() const; private: … }; auto pw = std::make_unique<Widget>(); … auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };
上面的例子中,位於”=”左側的pw,是lambda建立的閉包類中成員變數的名字;而位於”=”右側的是其初始化表示式,所以”pw=std::move(pw)”表達了在閉包類中建立一個成員變數pw,然後使用針對區域性變數pw實施std::move的結果來初始化該成員變數。在lambda內部使用pw也是指的閉包類的成員變數。一旦定義lambda表示式之後,因為區域性變數pw已經被move了,所以其不再掌握任何資源。
上面的例子還可以不使用區域性變數pw:
auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
這種捕獲方式在C++14中還稱為廣義lambda捕獲(generalized lambda capture)。
但是如果編譯器尚不支援C++14,則該如何實現按移動捕獲呢?要知道一個lambda表示式不過是生成一個類並建立一個該類的物件的手法罷了,並不存在lambda能做而手工不能做的事情,上面C++14的例子,如果使用C++11,可以寫為:
class IsValAndArch { 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麻煩很多。
如果非要使用lambda實現按移動捕獲,也不是全無辦法,可以藉助std::bind實現:把要捕獲的物件移動到std::bind產生的函式物件中;給lambda表示式一個指向欲捕獲的物件的引用。比如C++14中的寫法:
std::vector<double> data; … // populate data auto func = [data = std::move(data)] { /* uses of data */ };
如果採用C++11中使用std::bind和lambda的寫法,等價程式碼如下:
std::vector<double> data; … // as above auto func = std::bind( [](const std::vector<double>& data) { /* uses of data */ }, std::move(data) );
std::bind也生成函式物件,可以將它生成的物件稱為繫結物件。std::bind的第一個實參是個可呼叫物件,接下來的所有實參表示傳遞給該物件的值。
繫結物件內含有傳遞給std::bind所有實參的副本。對於左值實參,繫結物件內對應的副本實施的是複製構造;對於右值實參,實施的是移動構造。上面的例子中,第二個實參是個右值,所以在繫結物件內,使用區域性變數data移動構造其副本,這種移動構造動作正是實現模擬移動捕獲的關鍵所在,因為把右值移入繫結物件,正是繞過C++11無法將右值移動到閉包的手法。
當一個繫結物件被呼叫時,它所儲存的實參會傳遞給std::bind的那個可呼叫物件,也就是func被呼叫時,func內經由移動構造得到的data副本就會作為實參傳遞給那個原先傳遞給std::bind的lambda表示式。這個C++11寫法比C++14多了一個形參data,該形參是個指向繫結物件內部的data副本的左值引用,這麼一來,在lambda內對data形參所做的操作,都會實施在繫結物件內移動構造而得的data副本之上,與原區域性變數data無關。
預設情況下,lambda閉包類中的operator()成員函式會帶有const飾詞,因此閉包裡的所有成員變數在lambda表示式的函式體內都帶有const飾詞,但繫結物件內移動構造而得的data副本並不帶有const飾詞,所以為了防止該data部分在lambda表示式內被意外修改,lambda的形參就宣告為常量引用。但是如果lambda表示式帶有mutable飾詞,則閉包中的operator()函式就不會在宣告時帶有const飾詞,相應的做法就是在lambda宣告中略去const:
auto func = std::bind( [](std::vector<double>& data) mutable { /* uses of data */ }, std::move(data) );
繫結物件儲存著傳遞給std::bind所有實參的副本,因此本例中的繫結物件就包含一份由第一個實參lambda表示式產生的閉包的副本。這麼一來,該閉包的生命期就和繫結物件是相同的。
另外一個例子,下面是C++14的程式碼:
auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
如果使用C++11採用bind的寫法:
auto func = std::bind( [](const std::unique_ptr<Widget>& pw) { return pw->isValidated() && pw->isArchived(); }, std::make_unique<Widget>() );
33:要對auto&&型別的形參使用std::forward,則需要使用decltype
泛型lambda表示式(generic lambda)是C++14最振奮人心的特性之一:lambda表示式的形參列表中可以使用auto,它的實現直截了當,閉包類中的operator()採用模板實現。比如下面的lambda表示式,以及其對應的實現:
auto f = [](auto x){ return func(normalize(x)); }; class SomeCompilerGeneratedClassName { public: template<typename T> auto operator()(T x) const { return func(normalize(x)); } … };
這個例子中,lambda表示式對x的動作就是將其轉發給normalize,如果normalize區別對待左值和右值,則該lambda表示式的實現是有問題的,正確的寫法應該是使用萬能引用並將其完美轉發給normalize:
auto f = [](auto&& x) { return func(normalize(std::forward<???>(x))); };
這裡的問題是,std::forward的模板實參”???”應該怎麼寫?
這裡可以使用decltype(x),但是decltype(x)產生的結果,卻於std::forward的使用慣例有所不同。如果傳入的是個左值,則x的型別是左值引用,decltype(x)得到的也是左值引用;如果傳入的是右值,則x的型別是右值引用,decltype(x)得到的也是右值引用,但是,std::forward的使用慣例是std::forward<T>,其中T要麼是個左值引用,要麼是個非引用。
再看一下條款28中std::forward的簡單實現:
template<typename T> T&& forward(remove_reference_t<T>& param) { return static_cast<T&&>(param); }
如果客戶程式碼想要完美轉發Widget型別的右值,則按照慣例它應該採用Wdiget型別,而非引用型別來例項化std::forward,然後std::forard模板例項化結果是:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
如果使用右值引用例項化T,也就是Widget&&例項化T,得到的結果是:
Widget&& && forward(Widget& param) { return static_cast<Widget&& &&>(param); }
實施了引用摺疊之後:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
經過對比,發現這個版本和T為Widget時的std::forward是完全一樣的,因此,例項化std::forward時,使用一個右值引用和使用非引用型別,結果是相同的。所以,我們的完美轉發lambda表示式如下:
auto f = [](auto&& param) { return func(normalize(std::forward<decltype(param)>(param))); };
稍加改動,就可以得到能接收多個形參的完美轉發lambda式版本,因為C++14中的lambda能夠接受變長形參:
auto f = [](auto&&... params) { return func(normalize(std::forward<decltype(params)>(params)...)); };
34:優先使用lambda表示式,而非std::bind
std::bind在2005年就已經是標準庫的組成部分了(std::tr1::bind),這意味著std::bind已經存在了十多年了,你可能不太願意放棄這麼一個運作良好的工具,然而有時候改變也是有益的,因為在C++11中,相對於std::bind,lambda幾乎總會是更好的選擇,而到了C++14,lambda簡直已成了不二之選。
lambda表示式相對於std::bind的優勢,最主要的是其具備更高的可讀性:
// typedef for a point in time (see Item 9 for syntax) using Time = std::chrono::steady_clock::time_point; // see Item 10 for "enum class" enum class Sound { Beep, Siren, Whistle }; // typedef for a length of time using Duration = std::chrono::steady_clock::duration; // at time t, make sound s for duration d void setAlarm(Time t, Sound s, Duration d); // setSoundL ("L" for "lambda") is a function object allowing a // sound to be specified for a 30-sec alarm to go off an hour // after it's set auto setSoundL = [](Sound s) { // make std::chrono components available w/o qualification using namespace std::chrono; setAlarm(steady_clock::now() + hours(1), s, seconds(30)); };
這裡的lambda表示式,即使是沒什麼經驗的讀者也能看出來,傳遞給lambda的形參會作為實參傳遞給setAlarm。到了C++14中,C++14提供了秒,毫秒和小時的標準字面值,所以,可以寫成這樣:
auto setSoundL = [](Sound s) { using namespace std::chrono; using namespace std::literals; setAlarm(steady_clock::now() + 1h, s, 30s); };
而下面的程式碼是使用std::bind的等價版本,不過實際上它還有一處錯誤的,後續在解決這個錯誤:
using namespace std::chrono; using namespace std::literals; using namespace std::placeholders; // needed for use of "_1" auto setSoundB = // "B" for "bind" std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);
對於初學者而言,佔位符”_1”簡直好比天書,而即使是行家也需要腦補出從佔位符數字到它在std::bind形參列表中的位置對映關係,才能理解在呼叫setSoundB時傳入的第一個實參,會作為第二個實參傳遞給setAlarm。該實參的型別在std::bind的呼叫過程中是未加識別的,所以還需要檢視setAlarm的宣告才能決定應該傳遞何種型別的實參到setSoundB。
這段程式碼的錯誤之處在於,在lambda表示式中,表示式”steady_clock::now() + 1h”是setAlarm的實參之一,這一點清清楚楚,該表示式會在setAlarm被呼叫時求值,這樣是符合需求的,就是需要在setAlarm被呼叫的時刻之後的一個小時啟動報警。但是在std::bind中,”steady_clock::now() + 1h”作為實參傳遞給std::bind,而非setAlarm,該表示式在呼叫std::bind時就進行求值了,並且求得的結果會儲存在繫結物件中,這導致的結果是報警的啟動時刻是在std::bind呼叫之後的一個小時,而非setAlarm呼叫之後的一個小時。
要解決這個問題,就需要std::bind延遲表示式的求值到呼叫setAlarm的時刻,實現這一點,就是需要巢狀第二層std::bind的呼叫:
auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s);
在C++14中,標準運算子模板的模板型別實參大多數情況下可以省略不寫,所以此處也沒必要在std::plus中提供了,而C++11中還沒有這樣的特性,所以在C++11中,想要實現上面的程式碼,只能是:
using namespace std::chrono; // as above using namespace std::placeholders; auto setSoundB = std::bind(setAlarm, std::bind(std::plus<steady_clock::time_point>(), steady_clock::now(), hours(1)), _1, seconds(30));
如果對setAlarm實施了過載,則又會有新的問題:
enum class Volume { Normal, Loud, LoudPlusPlus }; void setAlarm(Time t, Sound s, Duration d, Volume v); auto setSoundL = [](Sound s) { using namespace std::chrono; setAlarm(steady_clock::now() + 1h, s, 30s); };
即使有了過載,lambda表示式依然能正常工作,過載決議會選擇有三個引數版本的setAlarm。但是到了std::bind,就沒辦法通過編譯了:
auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(),steady_clock::now(),1h), _1, 30s);
這是因為編譯器無法確定應該將哪個setalarm傳遞給set::bind,它拿到的所有資訊只有一個函式名。為了使std::bind能夠通過編譯,setAlarm必須強制轉換到適當的函式指標型別:
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d); auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm), std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s);
但是這麼做又帶來了lambda和std::bind的另一個不同之處。在lambda生成的setSoundL的函式呼叫運算子中,呼叫setAlarm採用的是常規函式喚起方式,這麼一來,編譯器就可以用慣常的手法將其內聯:
setSoundL(Sound::Siren); // body of setAlarm may well be inlined here
而std::bind呼叫中使用了函式指標,這意味著在setSoundB的函式呼叫運算子中,setAlarm是通過函式指標來呼叫的,編譯器一般無法將函式指標發起的函式呼叫進行內聯,所以lambda表示式就有可能生成比std::bind更快的程式碼。
在setAlarm例子中,僅僅涉及了函式的呼叫而已,如果你想做的事比這更復雜,則lambda表示式的優勢則更加明顯。比如:
auto betweenL = [lowVal, highVal] (const auto& val) { return lowVal <= val && val <= highVal; };
這裡的lambda使用了捕獲。std::bind要想要實現同樣的功能,必須用比較晦澀的方式來構造程式碼,下面分別是C++14和C++11的寫法:
using namespace std::placeholders; auto betweenB = std::bind(std::logical_and<>(), std::bind(std::less_equal<>(), lowVal, _1), std::bind(std::less_equal<>(), _1, highVal)); auto betweenB = std::bind(std::logical_and<bool>(), std::bind(std::less_equal<int>(), lowVal, _1), std::bind(std::less_equal<int>(), _1, highVal));
還是需要使用std::bind的延遲計算方法。
再看下面的程式碼:
enum class CompLevel { Low, Normal, High }; Widget compress(const Widget& w, CompLevel lev); //make compressedcopy of w Widget w; using namespace std::placeholders; auto compressRateB = std::bind(compress, w, _1);
這裡的w傳遞給std::bind時,是按值儲存在std::bind生成的物件中的,在std::bind的呼叫中,按值還是按引用儲存只能是牢記規則。std::bind總是複製其實參,但是呼叫方可以通過對實參實施std::ref的方法達到按引用儲存的效果,因此:
auto compressRateB = std::bind(compress, std::ref(w), _1);
結果就是compressRateB的行為如同持有的是個指向w的引用,而非其副本。
而在lambda中,w無論是按值還是按引用捕獲,程式碼中的書寫方式都很明顯:
auto compressRateL = [w](CompLevel lev) { return compress(w, lev); };
同樣明顯的還有形參的傳遞方式:
compressRateL(CompLevel::High); // arg is passed by value compressRateB(CompLevel::High); // how is arg passed?
Lambda返回的閉包中,很明顯實參是按值傳遞給lev的;而在std::bind返回繫結物件中,形參的傳遞方式是什麼呢?這裡也只能牢記規則,繫結物件的所有實參都是按引用傳遞的,因為此種物件的函式呼叫運算子使用了完美轉發。
總而言之,lambda表示式要比std::bind可讀性更好,表達能力更強,執行效率也可能更好,在C++14中,幾乎沒有std::bind的適當用例,而在C++11中,std::bind僅在兩個受限場合還算有使用的理由:
移動捕獲,C++11沒有提供移動捕獲的語法,參考上一條款;
多型函式物件,因為繫結物件的函式呼叫運算子使用了完美轉發,所以可以接收任何型別的實參,因此當需要繫結的物件具有一個函式呼叫運算子模板時,是有利用價值的:
class PolyWidget { public: template<typename T> void operator()(const T& param); … }; PolyWidget pw; auto boundPW = std::bind(pw, _1); boundPW(1930); // pass int to PolyWidget::operator() boundPW(nullptr); // pass nullptr to PolyWidget::operator() boundPW("Rosebud"); // pass string literal to PolyWidget::operator()
C++11中的lambda表示式沒有辦法實現這一點,但是在C++14中,使用帶有auto型別形參的lambda表示式可以很容易的實現這一點:
auto boundPW = [pw](const auto& param) { pw(param); };