《C++0x漫談》系列之:右值引用(或“move語意與完美轉發”)(下)
《C++0x漫談》系列之:右值引用
或“move語意與完美轉發”(下)
By 劉未鵬(pongba)
《C++0x漫談》系列導言
這個系列其實早就想寫了,斷斷續續關注C++0x也大約有兩年餘了,其間看著各個重要proposals一路review過來:rvalue-references、concepts、memory-model、variadic-templates、template-aliases、auto/decltype、GC、initializer-lists…
總的來說C++09跟C++98相比的變化是極其重大的。這個變化體現在三個方面,一個是形式上的變化,即在編碼形式層面的支援,也就是對應我們所謂的程式設計正規化
這個系列如果能夠寫下去,會陸續將C++09的新特性介紹出來。鑑於已經有許多牛人寫了很多很好的tutor(這裡,這裡,還有C++標準主頁上的一些introductive
右值引用導言
右值引用(及其支援的Move語意和完美轉發)是C++0x將要加入的最重大語言特性之一,這點從該特性的提案在列表上高居榜首也可以看得出來。從實踐角度講,它能夠完美解決C++中長久以來為人所詬病的臨時物件效率問題。從語言本身講,它健全了C++中的引用型別在左值右值方面的缺陷。從庫設計者的角度講,它給庫設計者又帶來了一把利器。從庫使用者的角度講,不動一兵一卒便可以獲得“免費的”效率提升
完美轉發
完美轉發問題——不完美解決方案——模板引數推導規則——完美轉發
動機
關於“完美轉發”這個特性,其實已經講得非常清楚了,諸位可以直接去看N1385,如果實在還是覺得迷糊就再回來聽我嘮叨吧:-)
在泛型編碼中經常出現的一個問題是(這個問題在實際中出現的場景很多,我們留到文章末尾再提,目前我們將這個特定的問題先提取孤立出來考慮):
如何將一組引數原封不動地轉發給另一個函式
注意,這裡所謂“原封不動”就是指,如果引數是左值,那麼轉發給的那個函式也要接受到一個左值,如果引數是右值,那麼後者要接受到一個右值;同理,如果引數是const的,那麼轉發給的那個函式也要接受到一個const的值,如果是non-const的,那麼後者也要接受到一個non-const的值。
總之一句話:
保持引數的左值/右值、const/non-const屬性不變
聽上去很簡單嗎?不妨試試看。
(不完美的)解決方案
假設我們要寫一個泛型轉發函式f,f要將它的引數原封不動地轉發給g(不管g的引數型別是什麼):
template<typename T>
void f(/*what goes here?*/ t)
{
g(t);
}
上面的程式碼中,f的引數t的型別是什麼?T?T&?const T&?
我們一個個來分析。
Value
如果t的型別是T,即:
// take #1
template<typename T>
void f(T t)
{
g(t);
}
那麼很顯然,不能滿足如下情況:
void g(int& i) { ++i; }
int myInt = 0;
f(myInt); // error, the value g() has incremented is a local value(a.k.a. f’s argument ‘t’)
即,不能將左值轉發為左值。
Const&
如果t的型別為const T&,即:
// take #2
template<typename T>
void f(const T& t)
{
g(t);
}
則剛才的情況還是不能滿足。因為g接受的引數型別為non-const引用。
Non-const&
那如果t的型別是T&呢?
// take #3
template<typename T>
void f(T& t)
{
g(t);
}
我們驚訝地發現,這時,如果引數是左值,那麼不管是const左值還是non-const左值,f都能正確轉發,因為對於const左值,T將會被推導為const U(U為引數的實際型別)。並且,對於const右值,f也能正確轉發(因為const引用能夠繫結到右值)。只有對non-const右值不能完美轉發(因為這時T&會被推導為non-const引用,而後者不能繫結到右值)。
即四種情況裡面有三種滿足了,只有以下這種情況失敗:
void g(const int& i);
int source();
f(source()); // error
如果f是完美轉發的話,那麼f(source())應該完全等價於g(source()),後者應該通過編譯,因為g是用const引用來接受引數的,後者在面對一個臨時的int變數的時候應該完全能夠繫結。
而實際上以上程式碼卻會編譯失敗,因為f的引數是T&,當面對一個non-const的int型右值(source()的返回值)時,會被推導為int&,而non-const引用不能繫結到右值。
好,現在的問題就變成,如何使得non-const右值也被正確轉發,用T&作f的引數型別是行不通的,唯一能夠正確轉發non-const右值的辦法是用const T&來接受它,但前面又說過,用const T&行不通,因為const T&不能正確轉發non-const左值。
Const& + non-const&
那兩個加起來如何?
template<typename T>
void f(T& t)
{
g(t);
}
template<typename T>
void f(const T& t)
{
g(t);
}
一次過載。我們來分析一下。
對於non-const左值,過載決議會選中T&,因為繫結到non-const引用顯然優於繫結到const引用(const T&)。
對於const左值,過載決議會選中const T&,因為顯然這是個更specialized的版本。
對於non-const右值,T&根本就行不通,因此顯然選中const T&。
對於const右值,選中const T&,原因同第二種情況。
可見,這種方案完全保留了引數的左右值和const/non-const屬性。
值得注意的是,對於右值來說,由於右值只能繫結到const引用,所以雖然const T&並非“(non-)const右值”的實際型別,但由於C++03只能用const T&來表達對右值的引用,所以這種情況仍然是完美轉發。
組合爆炸
你可能會覺得上面的這個方案(const& + non-const&)已經是完美解決方案了。沒錯,對於單參的函式來說,這的確是完美方案了。
但是如果要轉發兩個或兩個以上的引數呢?
對於每個引數,都有const T&和T&這兩種情況,為了能夠正確轉發所有組合,必須要2的N次方個過載
比如兩個引數的:
template<typename T1, typename T2>
void f(T1& t1, T2& t2) { g(t1, t2); }
template<typename T1, typename T2>
void f(const T1& t1, T2& t2) { g(t1, t2); }
template<typename T1, typename T2>
void f(T1& t1, const T2& t2) { g(t1, t2); }
template<typename T1, typename T2>
void f(const T1& t1, const T2& t2) { g(t1, t2); }
(完美的)解決方案
理想情況下,我們想要:
template<typename T1, typename T2, … >
void f(/*what goes here?*/ t1, /**/ t2, … )
{
g(t1, t2);
}
填空處應該填入一些東西,使得當t1對應的實參是non-const/const的左/右值時,t1的型別也得是non-const/const的左/右值。目前的C++03中,non-const/const屬性已經能夠被正確推匯出來(通過模板引數推導),但左右值屬性還不能。
明確地說,其實問題只有一個:
對於non-const右值來說,模板引數推導機制不能正確地根據其右值屬性確定T&的型別(也就是說,T&會被編譯器不知好歹地推導為左值引用)。
修改T&對non-const右值的推導規則是可行的,比如對這種情況:
template<typename T>
void f(T& t);
f(1);
規定T&推導為const int&。
但這顯然會破壞既有程式碼。
很巧的是,右值引用能夠拯救世界,右值引用的好處就是,它是一種新的引用型別,所以對於它的規則可以任意制定而不會損害既有程式碼,設想:
template<typename T >
void f(T&& t){ g(t); }
我們規定:
如果實參型別為右值,那麼T&&就被推導為右值引用。
如果實參型別為左值,那麼T&&就被推導為左值引用。
Bingo!問題解決!為什麼?請允許我解釋。
f(1); // T&& 被推導為 int&&,沒問題,右值引用繫結到右值。
f(i); // T&& 被推導為 int&,沒問題,通過左值引用完美轉發左值。
等等,真沒問題嗎?對於f(1)的情況,t的型別雖然為int&&(右值引用),但那是否就意味著t本身是右值呢?既然t已經是具名(named)變量了,因此t就有被多次move(關於move語意參考上一篇文章)的危險,如:
void dangerous(C&& c)
{
C c1(c); // would move c to c1 should we allow treating c as a rvalue
c.f(); // disaster
}
在以上程式碼中,如果c這個具名變數被當成右值的話,就有可能先被move掉,然後又被悄無聲息的非法使用(比如再move一次),編譯器可不會提醒你。這個邪惡的漏洞是因為c是有名字的,因此可以被多次使用。
解決方案是把具名的右值引用作為左值看待。
但這就使我們剛才的如意算盤落空了,既然具名的右值引用是左值的話,那麼f(1)就不能保持1的右值屬性進行轉發了,因為f的形參t的型別(T&&)雖然被推導為右值引用(int&&),但t卻是一個左值表示式,也就是說f(1)把一個右值轉發成了左值。
最終方案
通過嚴格修訂對於T&&的模板引數推導原則,以上問題可以解決。
修訂後的模板引數推導規則為:
如果實參是左值,那麼T就被推導為U&(其中U為實參的型別),於是T&& = U& &&,而U& &&則退化為U&(理解為:左值引用的右值引用仍然是左值引用)。
如果實參是右值,那麼T就被推導為U,於是T&& = U&&(右值引用)。
如此一來就可以這樣解決問題:
template<typename T>
void f(T&& t)
{
g(static_cast<T&&>(t));
}
想想看,如果實參為左值,那麼T被推導為U&,T&&為U& &&,也就是U&,於是static_cast<T&&>也就是static_cast<U&>,轉發為左值。
如果實參為右值,那麼T被推導為U,T&&為U&&,static_cast<T&&>也就是static_cast<U&&>,不像t這個具名的右值引用被看作左值那樣,static_cast<U&&>(t)這個表示式由於產生了一個新的無名(unnamed)值,因而是被看作右值的。於是右值被轉發為了右值。
擴充套件閱讀
下篇預告
下篇會寫variadic templates。然後介紹tr1::tuple的新版實現。