Part 1 of this series covered lambdas, auto, and static_assert.
今天要講的是 rvalue references,rvalue reference 可以讓我們做到兩件事情:move 語意,還有完美轉發。move 語意還有完美轉發這兩件事常常讓很多人頭大,因為 lvalue reference 跟 rvalue reference 在這兩件事裡面有很大的差異,而只有非常少數的 C++98/03 程式設計師很熟知 lvalue reference 跟 rvalue reference 的差異。這篇文章很長,因為我會把 rvalue reference 如何運作的機制解釋得非常清楚。
免驚,rvalue reference 用起來很簡單,比聽起來簡單多了。至於要怎麼在自己的程式碼裡面實作 move 語意跟完美轉發,只要依照我後面示範的例子就可以了。學習 rvalue reference 跟 move 語意是絕對值回票價的,他可以讓你的程式效能有數量級的增進,而完美轉發讓你可以輕輕鬆鬆的寫出高度泛型的程式碼。
lvalue and rvalue in C++98/03
要了解 C++0x 的 rvalue references,你必須要先了解 C++98/03 的 lvalue 跟 rvalue。
「lvalue」跟「rvalue」這兩個術語很容易被搞混,因為他們的歷史淵源本來就很混淆。(順帶一提,這兩個術語的發音是「L values」跟「R values」,雖然他們通常都寫成一個單字。)這兩個觀念是從 C 繼承而來的,而到了 C++ 這兩個觀念被探究的更清楚。為了節省時間,我就不談他們的歷史,也不談他們為什麼叫做「lvalue」跟「rvalue」,我會直接說他們在 C++98/03 是怎麼運作的。(好吧,其實也不是什麼大秘密:「L」代表「left」而「R」代表「right」。但是現在這兩個觀念已經演化了,lvalue 跟 rvalue 這兩個名字已經不能精準的表達他們現在實際上的意義。與其幫你上一整堂的歷史課,不如直接想一想「上夸克」跟「下夸克」的差別,這樣應該就懂了。)
C++03 3.10/1 提到:「Every expression is either an lvalue or an rvalue.(每一個運算式要嘛是一個 lvalue,不然就是一個 rvalue)」這很重要,請一定要記住,lvalue 跟 rvalue 所指的對象,是運算式,而不是物件(object)。
Lvalue 指的是在單一一個運算式之後所代表的一個續存物件(persistent object,也就是非暫時物件),舉例來說,
obj
,*ptr
,ptr[index]
,還有 ++x
都是 lvalue。Rvalue 則是那些當整個運算式一結束的時候就會消失的無影無蹤的暫時物件。舉例來說:
1729
,x + y
,std::string("meow")
,還有 x++
都是 rvalue。注意一下
++x
跟 x++
的差別。當我們寫 int x = 0;
的時候,x
是一個 lvalue,因為他代表的是一個會持續存在的物件。運算式 ++x
也是一個 lvalue,這個運算式改變了 x
的值,並且賦予他一個新的名字「++x
」,但是實際上他依然代表 x
這個續存物件。然而,運算式 x++
卻是一個 rvalue,他把本來的續存物件複製了一份,然後改變續存物件的值,最後把複製出來的值傳回去。而這個複製出來的物件是一個暫時物件,運算式結束之後,這個物件就消失了。++x
跟 x++
都會增加 x
值,但是 ++x
傳回一個續存物件,也就是他自己,而 x++
傳回的是一個暫時的複製品。這就是為什麼 ++x
是一個 lvalue,而 x++
是一個 rvalue。一個運算式是 lvalue 還是 rvalue,跟這個運算式做了些什麼沒有關係,只跟他代表的東西有關係,端看這個運算式是一個續存物件,還是一個暫時物件。如果你想要從另外一個直覺的角度來理解一個運算式是不是 lvalue,那你只要問自己:「我能不能對這個運算式取址(address of,
&
)?」如果可以,那這個運算式就是一個 lvalue,如果不行,那就是一個 rvalue。舉例來說: &obj
,&*ptr[index
],還有 &++x
都是合法的運算式(有的例子雖然很笨,但他們還是合法的)。而 &1729
,&(x + y)
,&std::string("meow")
,還有 &x++
都是不合法的運算式。為什麼這個判斷法有效?根據 C++03 5.3.1/2 當我們對一個運算元取址的時候,這個運算元必須是一個 lvalue。那為什麼標準要這樣定?因為對一個續存物件取址很安全,沒有問題,但是對一個暫時物件取值,是非常危險的行為,因為這個暫時物件馬上就會蒸發在空氣裡面,而你拿著他的記憶體位址,你不知道你會改到什麼東西。前面所講的用來解釋 lvalue 跟 rvalue 的例子,在運算子多載(operator overloading)的時候並不成立。根據 C++03 5.2.2/10「A function call is an lvalue if and only if the result type is a reference(只有當一個函數傳回一個 reference 的時候,呼叫這個函數得到的結果才會是一個 lvalue)」而運算子多載本質上也是函數。所以當我們寫
vector<int> v(10, 1729);
的時候,v[0]
是一個 lvalue,因為 operator[]()
的傳回型態是 int&
,而且 &v[0]
是合法的運算式,也很好用。而當我們寫 string s("foo"); string t("bar");
的時候,s + t
是一個 rvalue,因為 operator+()
傳回一個 string
,當然 &(s + t)
也是不合法的。Lvalue 跟 rvalue 都有可變動的(modifiable, non-const)跟不可變動的(non-modifiable, const)兩種,舉例來說:
string one("cute"); const string two("fluffy"); string three() { return "kittens"; } const string four() { return "are an essential part of a healthy diet"; } one; // modifiable lvalue two; // const lvalue three(); // modifiable rvalue four(); // const rvalue
一個
Type&
可以繫結(bind)到一個可變動的 lvalue(譯註:意思就是說使用一個可變動的 lvalue 來初始化你的 reference。比方說 int a; int& t = a;
),你可以透過這個 reference 來讀取原有的值跟寫入新的值。但是你不能把他繫結到一個 const lvalue,因為這樣會違反常數性。你也不能把他繫結到一個 non-const rvalue,這樣很危險。當你修改暫時變數的時候,可能會引來一些度爛的隱微 bug,所以 C++ 很明智的禁止這種行為了。(我應該要提一下,VC 有一個很邪惡的擴充,讓你可以做到這件事情,但是如果你編譯的時候加上 /W4 參數,那編譯器就會警告你說你用到了這個擴充功能。一般來說會警告啦~)你也不能用 const rvalue 來初始化一個 type&
,這種行為是智障加三級。(細心的讀者應該會發現我這邊沒有提到 template 引數的推導)(譯註:/W4 是 VC 最高的警告標準,預設是 /W3)一個
const Type&
可以用來繫結到任何一種型態:可變動的 lvalue,不可變動的 lvalue,可變動的 rvalue,還有不可變動的 rvalue。(然後你就可以透過這個 reference 來觀察這些被繫結的對象)每一個 reference 都有一個名字,所以一個繫結到 rvalue 的 reference,他本身是一個 lvalue(沒錯!Lvalue!)(譯註:叫的出名字的,比方說
int& a;
,當然就不是暫時變數,而是一個具體存在的物件,那當然是 lvalue)。(那如果是一個繫結到 rvalue 的 const reference 呢,就是一個 const lvalue)這很容易讓人搞混,但是這點跟之後要講的東西有很大的關係,所以我現在必需要解釋的很清楚。假設有一個函數 void observe(const string& str)
,這個函數內部的實作,str
是一個 const lvalue,只要在這個函數結束之前,這個 str
就可以被取址,取出來的位址也可以被使用,這點就算當初呼叫這個函數的時候,我們傳給他的是一個 rvalue 也一樣。就像上面的 three()
跟 four()
一樣。當我們呼叫 observe("purr")
的時候,我們會先根據 "purr"
建立一個暫時的 string
物件,然後 str
就會繫結到這個暫時物件(是個 rvalue)。three()
跟 four()
的傳回值沒有名字,所以是 rvalue,但是在 observe()
裡面,str
這東西有個名字,當然他就是一個 lvalue,正如我前面說過的啦:「Lvalue 跟 rvalue 所指的對象,是運算式,不是物件。」因為 str
可以繫結到一個運算式的結果,也可能是一個馬上就會消失的暫時變數,所以當 observe()
結束之後,我們就不應該在任何地方保存這個暫時變數的位址。那你有沒有曾經對一個繫結到 rvalue 的 const reference 取址過?你有!什麼時候?就是當你撰寫一個拷貝運算子
Foo& operator=(const Foo& other)
而且這個拷貝運算字有作「自我賦值」檢查 if (this != &other) { copy stuff; } return *this;
然後當你拷貝一個暫時變數 Foo make_foo();
的時候,你就對一個 rvalue const reference 取值啦。這個時候,你可能會問說:「那請問 const rvalue 跟 non-const rvalue 有什麼不一樣?如果我宣告一個 non-const Type&,我不能把這個 Type& 繫結到任何的 non-const rvalues,如果我宣告一個 const Type&,我也一樣沒辦法透過這個 reference 去改我繫結到的對象。總之我都改不到 rvalue,那到底我要怎麼樣才有辦法改變我繫結到的 rvalue?」在 C++98/03 的標準呢,這兩者還是有一點點很細微的差異的:non-const 的 rvalue 可以呼叫 non-const 的成員函數。C++ 不想讓你不小心變更一個暫時變數的值,但是當你直接呼叫一個 non-const 成員函數的時候,語意是非常明顯的,不可能是不小心,所以 C++ 允許你這麼作。而到了 C++0x,這個問題的答案有了劇烈的轉變:你可以透過這點來做到 move 語意。
賀!現在你已經有我所謂的「lvalue/rvalue 觀」,這讓你能夠看清楚一個運算式到底是 lvalue 還是 rvalue,再加上你本來就會的「const 觀念」,你現在已經完全可以理解為什麼當一個函數是
void mutate(string &ref)
時,mutate(one)
是合法的。但是 mutate(two)
,mutate(three())
,mutate(four())
跟 mutate("purr")
卻是不合法的了(one
,two
,three
請參照上面程式碼的定義)。如果你是一個 C++98/03 程式設計師,你本來就可以憑你的直覺(或是你的編譯器)分辨出這些東西哪些合法哪些不合法,你的直覺警報器會喔咿喔咿的跟你說 mutate(three())
是個魚目混珠的偽物。但是現在你所擁有的「lvalue/rvalue 觀」讓你更明確理解為什麼 three()
是一個 rvalue,也知道為什麼 non-const reference 不能被繫結到 rvalue。那知道這些東西有什麼用?對那些專門鑽研程式語言規則條例的「語言律師」來說,有用,但是對普通的程式設計師沒什麼用。畢竟就算你都不知道這些細節好了,其實對你也沒多大影響。但是重點來了:相較於 C++98/03,C++0x 的 lvalue/rvalue 觀念是非常非常有用的東西。(特別是當你可以分辨一個運算式是 rvalue 還是 rvalue,是 const 還是 non-const,然後根據這個差別做不同的事情。)為了要更有效的使用 C++0x 你必須要有 lvalue/rvalue 的觀念,現在萬事具備,我們可以開工啦!the copying problem
C++98/03 結合了不可思議的高度抽象化以及不可思議的效能,但是還是有個問題:C++98/03 超愛複製物件的。因為 value 語意,複製出來的物件是獨立的個體,修改複製出來的物件,不會影響本來物件的值。value 語意是很棒沒錯,但是也同時帶來了很多不必要的複製成本,像是
vector
跟 string
這類的 heavy 物件的複製就很昂貴。(heavy 是說複製起來很昂貴,比方說一個上百萬元素的 vector
的複製成本就很貴。)Return Value Optimization (RVO) 跟 Named Return Value Optimization (NEVO) 這兩種最佳化技術,在某些狀況下可以省掉拷貝建構子,減輕這個成本,但是還是沒有根治這個問題。最無謂的複製成本,就是當那些來源物件準備要被銷毀的時候。請問你會不會拷貝一份文件之後馬上把原稿銷毀?這樣不是很浪費嗎。你一開始就拿著原稿就好啦,何必多費事。下面是我從一份標準委員會文件的範例改來的,我所謂的「殺手範例」,假設你有一堆 string 像是這樣…
string s0("my mother told me that"); string s1("cute"); string s2("fluffy"); string s3("kittens"); string s4("are an essential part of a healthy diet");
然後你想要像這樣把他們接起來:
string dest = s0 + " " + s1 + " " + s2 + " " + s3 + " " + s4;
這樣作效率如何?(當然單指這個特例的話,我們也不用傷腦筋,因為反正他不用百萬分之一秒就可以作完了,但是我們討論的是一個更一般性的,一個語言層面的現象。)
每次呼叫
operator+()
的時候,就會傳回一個暫時物件,而這邊呼叫了八次,所以有八個暫時物件。每呼叫一次,他們的建構子就會作一次動態記憶體配置,拷貝目前已經拷貝的所有的字元,然後,馬上把他們銷毀,作一次動態記憶體釋放。(如果你聽說過所謂的 Small String Optimization,VC 附的 STL 實作有用上這個技術,這東西可以省去動態記憶體配置的成本。可惜這招在這邊不適用,因為我很故意的選了一個夠長的 s0
,所以就算你用上那個技術,你還是什麼都省不到!可能你也聽過一個叫做 Copy-On-Write 的最佳化技術,算了吧,這招不是用在這的,而且現在也沒人在用這東西了,因為在 multithread 的環境下這東西就絕體絕命啦。)(譯註:Small String Optimization 是一種可以把長度小於某個值的字串直接鑲嵌在類別裡面的最佳化技術,舉個例子,假設我實作的 字串類別大小是 24 bytes,那麼當我要存進去的字串長度小於 20 bytes 的時候,其實我可以用前四個 bytes 記住字串的長度,然後剩下的 20 個 bytes 直接存放字串的內容,這樣就省下另外動態配置記憶體的成本,這就是 Small String Optimization。雖然 Small String Optimization 跟 Copy-On-Write 在這邊都沒辦法用,但是其實我們還有 expression template,STLport 的 std::string
就有實作這個技術,當你在串接字串的時候,其實他沒有真的在把他們串起來,他只是先用一個 proxy class 把你要串接的字串記住,等到你要把結果指派出去的時候,他再一次把這些東西加起來。)事實上呢,因為每一個被串接出來的字串,都還再被拿去串耶,所以其實那個時間複雜度是字串長度的平方,媽阿,真浪費。這點實在讓 C++ 很尷尬。事情怎麼會搞成這樣?有沒有改善的方法?
現在問題的點是這樣,
operator+()
接收兩個參數,一個是 const string&
,另外一個是 const string&
或是 const char*
(還有其他的多載版本,但是這邊我們沒用到),但是呢,這個 operator+()
沒辦法分辨你塞給他的是 lvalue 還是 rvalue,所以他只好先建立一個暫時物件,然後再把那個暫時物件傳回來。那為什麼這邊會跟 lvalue/rvalue 有關?當我們要算
s0 + " "
的值的時候,我們一定要建立一個暫時物件。s0
是一個 lvalue,也就是是一個續存物件。所以我們不能去修改他。(好!有人注意到了!)但是當我們要算 (s0 + " ") + s1
的時候,我們可以直接把 s1
的內容接在那個暫時物件的後面嘛,這樣我們就不用建立第二個暫時物件然後還要把第一個丟掉。move 語意的核心觀念:因為 s0 + " "
是一個 rvalue,是一個運算式的結果產出的暫時物件,除了他自己,沒有人有辦法動到他。如果我們可以偵測到一個 non-const rvalue,那我們就可以任意修改裡面的值,而且還不會有人發現。operator+()
是不該去修改他的引數沒錯,但是如果他們修改的是一個 rvalue,那,誰在乎?我們可以用這個方法,把全部的字元都串接到單一一個暫時物件上面去。這個手法完全消除了不必要的複製成本跟動態記憶體配置成本,留下的是一個線性的複雜度,讚!技術上來說,在 C++0x,其實你每次呼叫
operator+()
的時候還是會傳回一個獨立的暫時物件。只是當你建立第二個暫時物件((s0 + " ") + s1
)的時候,他會從第一個暫時物件(s0 + " "
)那邊把記憶體偷過來,然後把 s1
的內容接在那塊記憶體後面(這也可能導致一個二次時間複雜度的記憶體重置動作)。「偷」這個動作呢,由指標的操作來達成:首先我們把第一個暫時物件的內部的指標拷貝到第二個暫時物件來,然後把第一個暫時物件的內部指標清成 null。這樣當第一個暫時物件要被銷毀的時候,因為他內部的指標是個 null,所以解構子啥也不會作。廣義的說,當我們有辦法偵測 non-const rvalue 的時候,我們就有辦法做到「剽竊資源」這件事。當一個物件實際上繫結到的是一個 non-const rvalue,且如果這個物件有掌握某些資源(比方說記憶體),那我們就可以直接偷過來,而不用像以前那樣要複製他們,反正他們馬上就要蒸發了。當你要從一個 rvalue 建立一個物件,或是當你要指派某個 rvalue 物件的值給另外一個物件的時候,這個偷取他們資源的行為,被歸類成「moving」,然後可以被 move 的物件就具有「move 語意」。
這個觀念在很多地方都超有用的,比方說
vector
的重新配置。當一個 vector
需要更多空間的時候(例如 push_back()
的時候),需要重新配置記憶體,然後把 vector
裡面的每一個元素從舊的記憶體拷貝到新的記憶體上面。而這些拷貝的動作可能很貴(比方說一個 vector<string>
,每一個 string
都要複製,都需要一次動態記憶體配置)。等等!舊的 vector
裡面的元素馬上就要被銷毀了耶,所以其實我們可以把裡面的東西 move 到新的那一個去,這樣就不用複製他們了。在這種狀況下,舊的記憶體區塊裡面的每一個元素都佔據一個續存的記憶體空間,而且你用來存取他們的運算式也是一個 lvalue(比方說 old_ptr[index
]),在重新配置記憶體的過程中,我們用 non-const rvalue reference 來繫結到這些元素,假裝這些舊的記憶體區塊是 non-const rvalue 有個好處,就是我們可以把裡面的東西 move 出來,省下複製物件的成本。(當我說「我要把那些 lvalue 當作是 non-const rvalue」的時候,等同於「我知道那些東西是續存的 lvalue,但是我不在乎這些 lvalue 會變成怎樣。反正這個 lvalue 就要被銷毀了,或是等等就會被指派新的值,管他的,反正我不在乎。所以如果我能從裡面偷東西,那我幹嘛不偷。」)C++0x 的 rvalue reference 讓我們可以偵測到 non-const rvalue 並且從裡面幹東西,而這點讓我們做到 move 語意。Rvalue reference 也帶給我們「把 lvalue 當作是 non-const rvalue」的能力。接下來我們就要看看 rvalue reference 是怎麼運作的!
rvalue references: initialization
C++0x 引入了一種新的 reference,rvalue reference,語法是
Type&&
跟 const Type&&
。根據目前 C++0x 的草案 N2798 8.3.2/2:「A reference type that is declared using & is called an lvalue reference, and a reference type that is declared using && is called an rvalue reference. Lvalue references and rvalue references are distinct types. Except where explicitly noted, they are semantically equivalent and commonly referred to as references.」(用 Type&
宣告的 reference 是一個 lvalue reference,用 Type&&
宣告的 reference 是 rvalue reference。lvalue reference 跟 rvalue reference 是不同的型別。除非特別註明的時候,不然他們在語意上都一樣是 reference)這代表你可以把你在 C++98/03 對 reference 的認知直接套用到 C++0x,只需要學他們之間不同的地方。(我自己習慣把
Type&
唸作「Type ref」,Type&&
唸作「Type ref ref」。這樣就是 Type
的 lvalue reference 跟 Type
的 rvalue reference。就像是一個常數指標指到一個 int
,我們寫作「int * const
」,也可以唸成「int star const」。)那他們之間到底差在哪裡?Rvalue reference 在初始化以及多載函數決議的時候跟 lvalue reference 有不同的行為。這兩者對於初始化的時候,繫結的對象以及多載決議的時候有不同的偏好。
Type&
,我們已經知道Type&
只能繫結到 non-const lvalue,其他的一概不能。const Type&
,我們也知道const Type&
可以繫結到所有的東西。Type&&
可以繫結到 non-const lvalue 還有 non-const rvalue,但是不能繫結到 const lvalue 跟 const rvalue,因為那樣會違反常數性)const Type&&
可以繫結到所有的東西。
這些規則看起來就像是火星文,不過其實可以從兩條很簡單的規則推導出來:
- 常數性,所以限制你不能把 non-const reference 繫結到 const reference。
- 為了避免不小心改到暫時變數的值,所以限制你把 non-const lvalue reference 繫結到 non-const rvalue。
阿如果你不喜歡看文字描述,比較喜歡看編譯器的錯誤訊息的話,這邊給你一個範例:
C:\Temp>type initialization.cpp #include <string> using namespace std; string modifiable_rvalue() { return "cute"; } const string const_rvalue() { return "fluffy"; } int main() { string modifiable_lvalue("kittens"); const string const_lvalue("hungry hungry zombies"); string& a = modifiable_lvalue; // Line 16 string& b = const_lvalue; // Line 17 - ERROR string& c = modifiable_rvalue(); // Line 18 - ERROR string& d = const_rvalue(); // Line 19 - ERROR const string& e = modifiable_lvalue; // Line 21 const string& f = const_lvalue; // Line 22 const string& g = modifiable_rvalue(); // Line 23 const string& h = const_rvalue(); // Line 24 string&& i = modifiable_lvalue; // Line 26 string&& j = const_lvalue; // Line 27 - ERROR string&& k = modifiable_rvalue(); // Line 28 string&& l = const_rvalue(); // Line 29 - ERROR const string&& m = modifiable_lvalue; // Line 31 const string&& n = const_lvalue; // Line 32 const string&& o = modifiable_rvalue(); // Line 33 const string&& p = const_rvalue(); // Line 34 } C:\Temp>cl /EHsc /nologo /W4 /WX initialization.cpp initialization.cpp initialization.cpp(17) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &' Conversion loses qualifiers initialization.cpp(18) : warning C4239: nonstandard extension used : 'initializing' : conversion from 'std::string' to 'std::string &' A non-const reference may only be bound to an lvalue initialization.cpp(19) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &' Conversion loses qualifiers initialization.cpp(27) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&' Conversion loses qualifiers initialization.cpp(29) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&' Conversion loses qualifiers
把一個 non-const rvalue reference 繫結到一個 const rvalue 是沒問題的。因為 non-const rvalue 的重點就是可以用來修改暫時變數。
雖然 lvalue reference 跟 rvalue reference 在初始化的時候很相似(只有在上面的第 18 跟 28 行不一樣),但是這個差異的確改善了多載決議時的解析能力。
rvalue references: overload resolution
函數可以根據 const 跟 non-const lvalue 來進行多載,這你已經很熟了。在 C++0x,函數還可以根據 const 跟 non-const 的 rvalue 來進行多載。當一個函數有全部的四個多載版本,你應該可以預期到,每一個函數都根據對應的運算式被呼叫。
C:\Temp>type four_overloads.cpp #include <iostream> #include <ostream> #include <string> using namespace std; void meow(string& s) { cout << "meow(string&): " << s << endl; } void meow(const string& s) { cout << "meow(const string&): " << s << endl; } void meow(string&& s) { cout << "meow(string&&): " << s << endl; } void meow(const string&& s) { cout << "meow(const string&&): " << s << endl; } string strange() { return "strange()"; } const string charm() { return "charm()"; } int main() { string up("up"); const string down("down"); meow(up); meow(down); meow(strange()); meow(charm()); } C:\Temp>cl /EHsc /nologo /W4 four_overloads.cpp four_overloads.cpp C:\Temp>four_overloads meow(string&): up meow(const string&): down meow(string&&): strange() meow(const string&&): charm()
在實務上,你真的去多載全部四個版本其實沒多大用處。真正好玩的是只多載
const Type&
跟 Type&&
這兩個版本:C:\type two_overloads.cpp #include <iostream #include <ostream> #include <string> using namespace std; void purr(const string& s) { cout << "purr(const string&): " << s << endl; } void purr(string&& s) { cout << "purr(string&&): " << s << endl; } string strange() { return "strange()"; } const string charm() { return "charm()"; } int main() { string up("up"); const string down("down"); purr(up); purr(down); purr(strange()); purr(charm()); } C:\Temp>cl /EHsc /nologo /W4 two_overloads.cpp two_overloads.cpp C:\Temp>two_overloads purr(const string&): up purr(const string&): down purr(string&&): strange() purr(const string&): charm()
為啥這招有用?理由如下:
- 初始化規則有「否決權」
- Lvalue 強烈偏好繫結到 lvalue reference,rvalue 強烈偏好繫結到 rvalue reference。
- non-const 運算式輕度的偏向 non-const reference。
(所謂「否決權」是說,當一個候選函數被認為被淘汰了,那他就一點機會都沒有了,根本不會被列入考慮)現在我們一條一條檢視這些規則。
purr(up),purr(const string&)
跟purr(string&&)
都沒有被否決。但是因為up
是一個 lvalue,而 lvalue reference 強烈的偏好 lvalue,所以purr(const string&)
贏了。purr(down)
,purr(string&&)
因為不滿足常數性被否決了,purr(const string&)
又贏了!purr(strange())
,兩個人都沒有被否決,但是strange()
是一個 rvalue,而 rvalue 強烈偏好 rvalue reference,non-const 只是輕度的偏向 non-const reference ,所以purr(string&&)
贏了。purr(charm())
,purr(string&&)
因為違反常數性被否決了,所以purr(const string&)
贏了。
這邊要注意到的重點是,當你只多載
const Type&
跟 Type&&
的時候,non-const rvalue 會繫結到 Type&&
,然後其他的都繫結到 const Type&
。就這樣,這一組多載函數就可以用來做到 move 語意。重要備註:當一個函數要以 by value 的方法傳回值的時候,要把他宣告成
Type
而不是 const Type
,因為後者這種宣告方式,會阻絕 Type&&
的繫結,造成的結果是 move 語意最佳化沒辦法施行。move semantics: the pattern
這邊有一個範例類別,
remote_integer
,內部保存一個指標,指到一個動態配置來的 int
。(這就是「遠端所有權」)對你來說,這個類別的預設建構子、單一參數建構子、拷貝建構子、指派運算子,還有解構子,應該都是非常熟悉的。在這邊我多加了一個 move 語意的拷貝建構子跟指派運算子,我把這兩個函數用 #define MOVABLE
包住,這樣我就可以示範有這兩個函數跟沒有這兩個函數有什麼差別。當然啦,真的寫程式的時候不會有人這樣作。C:\Temp>type remote.cpp #include <stddef.h> #include <iostream> #include <ostream> using namespace std; class remote_integer { public: remote_integer() { cout << "Default constructor." << endl; m_p = NULL; } explicit remote_integer(const int n) { cout << "Unary constructor." << endl; m_p = new int(n); } remote_integer(const remote_integer& other) { cout << "Copy constructor." << endl; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } #ifdef MOVABLE remote_integer(remote_integer&& other) { cout << "MOVE CONSTRUCTOR." << endl; m_p = other.m_p; other.m_p = NULL; } #endif // #ifdef MOVABLE remote_integer& operator=(const remote_integer& other) { cout << "Copy assignment operator." << endl; if (this != &other) { delete m_p; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } return *this; } #ifdef MOVABLE remote_integer& operator=(remote_integer&& other) { cout << "MOVE ASSIGNMENT OPERATOR." << endl; if (this != &other) { delete m_p; m_p = other.m_p; other.m_p = NULL; } return *this; }#endif // #ifdef MOVABLE ~remote_integer() { cout << "Destructor." << endl; delete m_p; } int get() const { return m_p ? *m_p : 0; } private: int * m_p; }; remote_integer square(const remote_integer& r) { const int i = r.get(); return remote_integer(i * i); } int main() { remote_integer a(8); cout << a.get() << endl; remote_integer b(10); cout << b.get() << endl; b = square(a); cout << b.get() << endl; } C:\Temp>cl /EHsc /nologo /W4 remote.cpp remote.cpp C:\Temp>remote Unary constructor. 8 Unary constructor. 10 Unary constructor. Copy assignment operator. Destructor. 64 Destructor. Destructor. C:\Temp>cl /EHsc /nologo /W4 /DMOVABLE remote.cpp remote.cpp C:\Temp>remote Unary constructor. 8 Unary constructor. 10 Unary constructor. MOVE ASSIGNMENT OPERATOR. Destructor. 64 Destructor. Destructor.
這邊有幾點要注意的。
- 對於建構子,我們多載了 copy 跟 move 兩種版本,對於指派運算子,我們也多載了 copy 跟 move 兩種版本。當我們多載
Type&
跟const Type&&
的時候,b = square(a);
會自動偵測出可以 move 的狀況且施行 move 語意。 - 現在我們直接從別的物件那邊偷記憶體,不用再動態配置了。當偷東西的時候,我們拷貝對方的指標,然後把對方的指標清成 null,於是當對方要被解構的時候就不會去作歸還記憶體的動作。
- copy/move 語意的建構子跟指派運算子都要作自我賦值檢查。大家都知道,簡單的內建型別,像是
int
,自我賦值(譬如x = x;
)的時候是絕對不會有問題的,所以我們自己設計的型別,在自我賦值的時候也應該要做到不能出問題。當我們自己手寫 code 的時候,幾乎不太可能發生自我賦值,但是當使用像是std::sort()
這樣的演算法函式庫的時候,卻很容易發生。在 C++0x,像std::sort()
這樣的演算法可以用 move 來取代複製,但是跟指派運算子同樣的潛在危機還是存在的。
現在你可能會很好奇 rvalue reference 以及 move 語意,跟編譯器自動產生(隱式宣告)的建構子還有指派運算子之間的交互作用是什麼。
- Move 建構子跟 move 指派運算子絕對不會自動產生。
- 任何一種使用者自訂的 copy 建構子跟 move 建構子都會遮蔽預設建構子。
- 使用者自訂的 copy 建構子會遮蔽預設拷貝建構子,但是使用者自訂的 move 建構子不會遮蔽預設的 copy 建構子。
- 使用者自訂的 copy 指派運算子會遮蔽預設的指派運算子,但是使用者自訂的 move 指派運算子不會遮蔽預設的指派運算子。
基本上,自動產生建構子跟指派運算子的規則,跟 move 語意沒有交互作用,只有一個例外,就是當你宣告一個 move 建構子的時候,會如同你宣告任何建構子一樣,會遮蔽預設的建構子。
move semantics: moving from lvalues
好,如果你喜歡用 copy 指派運算子來實作 copy 建構子,那很可能現在你也會想要用 move 指派運算子來實作 move 建構子。這樣作不是不可以,但是你必須要很小心。像這樣就是一個錯誤的例子:
C:\Temp>type unified_wrong.cpp #include <stddef.h> #include <iostream> #include <ostream> using namespace std; class remote_integer { public: remote_integer() { cout << "Default constructor." << endl; m_p = NULL; } explicit remote_integer(const int n) { cout << "Unary constructor." << endl; m_p = new int(n); } remote_integer(const remote_integer& other) { cout << "Copy constructor." << endl; m_p = NULL; *this = other; } #ifdef MOVABLE remote_integer(remote_integer&& other) { cout << "MOVE CONSTRUCTOR." << endl; m_p = NULL; *this = other; // WRONG } #endif // #ifdef MOVABLE remote_integer& operator=(const remote_integer& other) { cout << "Copy assignment operator." << endl; if (this != &other) { delete m_p; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } return *this; } #ifdef MOVABLE remote_integer& operator=(remote_integer&& other) { cout << "MOVE ASSIGNMENT OPERATOR." << endl; if (this != &other) { delete m_p; m_p = other.m_p; other.m_p = NULL; } return *this; } #endif // #ifdef MOVABLE ~remote_integer() { cout << "Destructor." << endl; delete m_p; } int get() const { return m_p ? *m_p : 0; } private: int * m_p; }; remote_integer frumple(const int n) { if (n == 1729) { return remote_integer(1729); } remote_integer ret(n * n); return ret; } int main() { remote_integer x = frumple(5); cout << x.get() << endl; remote_integer y = frumple(1729); cout << y.get() << endl; } C:\Temp>cl /EHsc /nologo /W4 /O2 unified_wrong.cpp unified_wrong.cpp C:\Temp>unified_wrong Unary constructor. Copy constructor. Copy assignment operator. Destructor. 25 Unary constructor. 1729 Destructor. Destructor. C:\Temp>cl /EHsc /nologo /W4 /O2 /DMOVABLE unified_wrong.cpp unified_wrong.cpp C:\Temp>unified_wrong Unary constructor. MOVE CONSTRUCTOR. Copy assignment operator. Destructor. 25 Unary constructor. 1729 Destructor. Destructor.
(編譯器在這邊使用了 RVO,沒有用上 NRVO。正如我之前所說的,有些拷貝建構子的成本可以藉由 RVO 或是 NRVO 省下來,但是沒有辦法全部省下來也是很合邏輯的。剩下的這些狀況就交給 move 建構子來處理。)
上面標注了 WRONG 的那一行,呼叫的是 copy 指派運算子!他正確的通過了編譯,也順利的執行完畢,但是他就是沒有施行 move 語意。
現在是什麼狀況?你還記得 C++98/03 裡面說過:具名的 lvalue reference 就是 lvalue(當我們寫
int& r = *p;
那 r
就是一個 lvalue),不具名的 lvalue reference 還是 lvalue(當 vector<int> v(10, 1729);
時 v[0
] 會傳回一個 int&
,這就是一個不具名的 lvalue reference,他雖然不具名,你還是可以對他取址)。Rvalue reference 就不一樣啦:- 具名的 rvalue reference 是 lvalue。
- 不具名的 rvalue reference 是 rvalue。
一個具名的 rvalue reference 是一個 lvalue,是因為你可以引用(mention)他好幾次,對他作很多不同的運算。但是如果是一個 rvalue 的話,那只有第一次的運算能碰到這個他,並且有機會從裡面偷東西,後續的運算則完全沒有機會。所謂「偷」就是說不能被發現,所以第一次偷完之後,這個東西就不能再被參用到。另外一方面呢,一個不具名的 rvalue reference 不可能被重複參用,所以他可以保持他 rvalue 的特性。
如果你真的很想用你的 move 指派運算子來實作你的 move 建構子,你需要一項特異功能:把一個 lvalue 看作是一個 rvalue。C++0x 的 <utility> 裡面的
std::move()
提供你這個能力。VC10 將會有這個功能(其實我自己現在用的開發版已經有了),但是不好意思現在還是 VC10 CTP,沒有。所以我會教你怎麼重頭打造一個 move()。C:\Temp>type unified_right.cpp #include <stddef.h> #include <iostream> #include <ostream> using namespace std; template <typename T> struct RemoveReference { typedef T type; }; template <typename T> struct RemoveReference<T&> { typedef T type; }; template <typename T> struct RemoveReference<T&&> { typedef T type; }; template <typename T> typename RemoveReference<T>::type&& Move(T&& t) { return t; } class remote_integer { public: remote_integer() { cout << "Default constructor." << endl; m_p = NULL; } explicit remote_integer(const int n) { cout << "Unary constructor." << endl; m_p = new int(n); } remote_integer(const remote_integer& other) { cout << "Copy constructor." << endl; m_p = NULL; *this = other; } #ifdef MOVABLE remote_integer(remote_integer&& other) { cout << "MOVE CONSTRUCTOR." << endl; m_p = NULL; *this = Move(other); // RIGHT } #endif // #ifdef MOVABLE remote_integer& operator=(const remote_integer& other) { cout << "Copy assignment operator." << endl; if (this != &other) { delete m_p; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } return *this; } #ifdef MOVABLE remote_integer& operator=(remote_integer&& other) { cout << "MOVE ASSIGNMENT OPERATOR." << endl; if (this != &other) { delete m_p; m_p = other.m_p; other.m_p = NULL; } return *this; } #endif // #ifdef MOVABLE ~remote_integer() { cout << "Destructor." << endl; delete m_p; } int get() const { return m_p ? *m_p : 0; } private: int * m_p; }; remote_integer frumple(const int n) { if (n == 1729) { return remote_integer(1729); } remote_integer ret(n * n); return ret; } int main() { remote_integer x = frumple(5); cout << x.get() << endl; remote_integer y = frumple(1729); cout << y.get() << endl; } C:\Temp>cl /EHsc /nologo /W4 /O2 /DMOVABLE unified_right.cpp unified_right.cpp C:\Temp>unified_right Unary constructor. MOVE CONSTRUCTOR. MOVE ASSIGNMENT OPERATOR. Destructor. 25 Unary constructor. 1729 Destructor. Destructor.
(之後我用到
std::move()
或是我自己的 Move()
的時候,會彼此交替使用,反正他們在實作上一模一樣。)到底 std::move()
背後是怎麼做到這件事情的?此刻我只能跟你說這是「魔法」。(後面會有一個詳盡的解釋,其實這東西不複雜,但是牽扯到樣板引數推導跟 reference collapse(譯註:一個 reference 的 reference,還是 reference。C++98/03 的時候,並不允許我們對一個 reference 作 reference)。我們在講「完美轉發」的時候,還會看到這東西)跳過玄學的部份,我也可以用實際的例子來說明。當我們有一個 string
的 lvalue,比方說上面示範多載的時候提到的「up
」,std::move(up)
會呼叫 string&& std::move(string&)
這個版本,然後傳回一個不具名的 rvalue reference,當然,是一個 rvalue。那當我們有一個 string
的 rvalue,比方說上面提到的 strange()
,std::move(strange())
會呼叫 string&& std::move(string&&)
這個版本,還是傳回一個不具名的 rvalue reference,當然,也是一個 rvalue,所以不管你塞什麼東西進去,你都會拿到一個 rvalue reference,是 rvalue。除了讓你可以用 move 指派運算子來實作 move 建構子,
std::move()
在別的地方也很有用。不管什麼時候,只要你手上有一個 lvalue,但是你知道他反正馬上就要蒙主寵招了,你都可以把你的 lvalue 運算式加上 std::move()
來啟動 move 語意。move semantics: movable members
C++0x 的標準類別(像是
vector
,string
,或是 regex
)都有 move 建構子跟 move 指派運算子。而且我們已經看到要怎麼幫我們自己設計的類別加上 move 語意(剛剛那個 remote_integer
的範例)。但是如果我們設計的類別裡面,有 move 語意的類別怎麼辦(像是 vector
,string
,或是 regex
)?編譯器不會自動幫我們產生 move 建構子跟 move 指派運算子,所以我們必需要自己來。但是我們很幸運,有了 std::move()
,這件事情變的超簡單。C:\Temp>type point.cpp #include <stddef.h> #include <iostream> #include <ostream> using namespace std; template <typename T> struct RemoveReference { typedef T type; }; template <typename T> struct RemoveReference<T&> { typedef T type; }; template <typename T> struct RemoveReference<T&&> { typedef T type; }; template <typename T> typename RemoveReference<T>::type&& Move(T&& t) { return t; } class remote_integer { public: remote_integer() { cout << "Default constructor." << endl; m_p = NULL; } explicit remote_integer(const int n) { cout << "Unary constructor." << endl; m_p = new int(n); } remote_integer(const remote_integer& other) { cout << "Copy constructor." << endl; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } remote_integer(remote_integer&& other) { cout << "MOVE CONSTRUCTOR." << endl; m_p = other.m_p; other.m_p = NULL; } remote_integer& operator=(const remote_integer& other) { cout << "Copy assignment operator." << endl; if (this != &other) { delete m_p; if (other.m_p) { m_p = new int(*other.m_p); } else { m_p = NULL; } } return *this; } remote_integer& operator=(remote_integer&& other) { cout << "MOVE ASSIGNMENT OPERATOR." << endl; if (this != &other) { delete m_p; m_p = other.m_p; other.m_p = NULL; } return *this; } ~remote_integer() { cout << "Destructor." << endl; delete m_p; } int get() const { return m_p ? *m_p : 0; } private: int * m_p; }; class remote_point { public: remote_point(const int x_arg, const int y_arg) : m_x(x_arg), m_y(y_arg) { } remote_point(remote_point&& other) : m_x(Move(other.m_x)), m_y(Move(other.m_y)) { } remote_point& operator=(remote_point&& other) { m_x = Move(other.m_x); m_y = Move(other.m_y); return *this; } int x() const { return m_x.get(); } int y() const { return m_y.get(); } private: remote_integer m_x; remote_integer m_y; }; remote_point five_by_five() { return remote_point(5, 5); } remote_point taxicab(const int n) { if (n == 0) { return remote_point(1, 1728); } remote_point ret(729, 1000); return ret; } int main() { remote_point p = taxicab(43112609); cout << "(" << p.x() << ", " << p.y() << ")" << endl; p = five_by_five(); cout << "(" << p.x() << ", " << p.y() << ")" << endl; } C:\Temp>cl /EHsc /nologo /W4 /O2 point.cpp point.cpp C:\Temp>point Unary constructor. Unary constructor. MOVE CONSTRUCTOR. MOVE CONSTRUCTOR. Destructor. Destructor. (729, 1000) Unary constructor. Unary constructor. MOVE ASSIGNMENT OPERATOR. MOVE ASSIGNMENT OPERATOR. Destructor. Destructor. (5, 5) Destructor. Destructor.
現在你看到啦,資料成員的 move 語意很容易做到。注意
remote_point
的 move 指派運算子不需要作自我賦值檢查,因為 remote_integer
已經檢查過了。也注意到 remote_point
預設的拷貝建構子,指派運算子,還有解構子都正常的運作。你現在應該跟 move 語意已經熟爛了(希望不是你的腦袋炸爛了)。為了測試一下你新獲得的這個能力,你就寫一個
remote_integer
的 operator+()
來當作回家作業吧。最後的叮嚀:只要你自己寫得類別支援拷貝語意,你都該盡量幫他加上 move 語意的建構子跟指派運算子,因為編譯器不會自動幫你作這件事。因為不是只有你平常寫的程式碼可以從 move 語意獲利,當你使用 STL 的容器跟演算法的時候,你都可以省下很多的昂貴的複製成本。
the forwarding problem
C++98/03 的 lvalue,rvalue,reference,還有 template 看起來很完美,但是當程式設計師想要寫出高度泛化的函數的時候,就發現有問題了。假設你想要寫一個究極的泛型函數
outer()
,這個函數存在的人生目標就是把他拿到的所有的參數轉發給另外一個叫做 inner()
的函數,不管這些引數的型別是什麼,數量有多少,我們都希望他能夠完美的做好這件事情。這種行為有很多例子,比方說 factory
函數 make_shard<T>(args)
,要把 args
轉給 T
的建構子,然後傳回一個 shared_ptr<T>
。(這樣我們就可以把 T
型別用來管理 reference counting 的程式碼統統集中到一個地方,而效能就跟你使用的是侵入式 reference counting 一樣好。)像 function<Ret (Args)>
這樣包裝類別,把參數轉發給自己內部儲存的 functor
,也是一個例子。在這篇文章我們只專注在 outer()
函數把參數轉給 inner()
函數的部份。至於 outer()
的傳回值那是另外一回事(有的狀況很簡單,比方說 make_shared<T>(args)
永遠就是傳回 shared_ptr<T>
,但是如果你想要將傳回值完美的泛型化,就必須要用到 C++0x 的 decltype
)。如果函數沒有參數,那這個問題不存在,但是如果是有一個參數的函數呢?讓我們試試看這樣設計
outer()
:template <typename T> void outer(T& t) { inner(t); }
問題來了,如果參數是一個 non-const rvale,這個
outer()
沒辦法被呼叫(違反常數性)。又如果 inner()
接收的是 const int&
,那 inner(5)
編譯會過,但是 outer(5)
就過不了。因為 5
是一個 int
,所以 T
會被推導為 int
,但是很可惜你沒辦法把 int&
繫結到 5
。好吧,那不然我們試試看這樣:
template <typename T> void outer(const T& t) { inner(t); }
如果
inner()
接收的是 int&
,那編譯根本不會過,因為違反常數性。現在我們可以試試看多載
outer()
的兩個版本,outer(const T& t)
跟 outer(T& t)
,這個方法的確有用。當你呼叫 outer()
的時候,就像是你直接呼叫 inner()
一樣。可惜,這個方法在多參數的時候就吃癟了。以兩個參數的例子,你就必須要多載
(T1&, T2&)
,(const T1&, T2&)
,(T1&, const T2&)
,(const T1&, const T2&)
四個版本。當你的參數一多的時候,你要多載函數數目就會以指數成長。(VC9 的 tr1::bind()
光作前五個參數就已經讓人對這個世界絕望透頂,有 63 個多載函數。但是我們不做的話,我們就很難跟使用者解釋說為什麼連 f(1729)
這樣的東西編譯都不會過。為了生出這些多載函數,我們用了非常噁心的 preprocessor 機制,噁心到你連聽都不會想聽,真的。)在 C++98/03,轉發是很嚴重的問題,而且本質上無解(用上那些噁心的 preprocessor 技巧又讓編譯明顯變慢,而且那程式碼幾乎不是人看的)。總算,rvalue reference 優雅的解決了這個問題。
(我剛剛是先解釋多載決議跟 move 語意的觀念,然後才講範例程式。現在我要反過來,我們要先看怎麼用 rvalue reference 做到完美轉發的範例程式,然後我才會說明參數推導跟 reference collapsing 的規則,因為這樣會比較容易懂)
perfect forwarding: the pattern
完美轉發讓你可以只寫一個函數就轉發所有的引數,不管你有幾個引數,也不管這些引數是什麼型別。引數的 const/non-const 跟 lvalue/rvalue 特性都會被完整的保留,讓你的
outer()
跟你的 inner()
用起一模一樣,跟 move 語意一起用的話就更棒啦。(C++0x 的「variadic template」解決了「任意型別數目」的問題,所以我們要做的事情可以推廣到任意多個型別引數。)乍看之下很神奇,實際上很簡單:C:\Temp>type perfect.cpp #include <iostream> #include <ostream> using namespace std; template <typename T> struct Identity { typedef T type; }; template <typename T> T&& Forward(typename Identity<T>::type&& t) { return t; } void inner(int&, int&) { cout << "inner(int&, int&)" << endl; } void inner(int&, const int&) { cout << "inner(int&, const int&)" << endl; } void inner(const int&, int&) { cout << "inner(const int&, int&)" << endl; } void inner(const int&, const int&) { cout << "inner(const int&, const int&)" << endl; } template <typename T1, typename T2> void outer(T1&& t1, T2&& t2) { inner(Forward<T1>(t1), Forward<T2>(t2)); } int main() { int a = 1; const int b = 2; cout << "Directly calling inner()." << endl; inner(a, a); inner(b, b); inner(3, 3); inner(a, b); inner(b, a); inner(a, 3); inner(3, a); inner(b, 3); inner(3, b); cout << endl << "Calling outer()." << endl; outer(a, a); outer(b, b); outer(3, 3); outer(a, b); outer(b, a); outer(a, 3); outer(3, a); outer(b, 3); outer(3, b); } C:\Temp>cl /EHsc /nologo /W4 perfect.cpp perfect.cpp C:\Temp>perfect Directly calling inner(). inner(int&, int&) inner(const int&, const int&) inner(const int&, const int&) inner(int&, const int&) inner(const int&, int&) inner(int&, const int&) inner(const int&, int&) inner(const int&, const int&) inner(const int&, const int&) Calling outer(). inner(int&, int&) inner(const int&, const int&) inner(const int&, const int&) inner(int&, const int&) inner(const int&, int&) inner(int&, const int&) inner(const int&, int&) inner(const int&, const int&) inner(const int&, const int&)
兩行!只用了兩行就做到完美轉發!太妙了!
這個例子示範了怎麼把
t1
跟 t2
透明的轉發給 inner()
,inner()
可以知道 t1
跟 t2
的 lvalue/rvalue 跟 const/non-const 特性,就好像他是直接被呼叫一樣。跟
std::move()
一樣,std::identity()
跟 std::forward()
都在 C++0x 的 <utility> 裡面定義(VC10 會有,不過 CTP 沒有)。我正在示範怎麼實作這兩個東西。(一樣的,我會把 std::identity()
還有 std::forward()
跟我自己的 Identity()
還有 Forward()
混用,反正他們是一模一樣的東西。)現在讓我們看看這被後到底是幹甚麼吃的。其實他靠的就是樣板引數推導跟 reference collapse 這兩個東西。
rvalue references: template argument deduction and reference collapsing
Rvalue reference 跟樣板是以很特別的方式互動,這邊是一個範例。
C:\Temp>type collapse.cpp #include <iostream> #include <ostream> #include <string> using namespace std; template <typename T> struct Name; template <> struct Name<string> { static const char * get() { return "string"; } }; template <> struct Name<const string> { static const char * get() { return "const string"; } }; template <> struct Name<string&> { static const char * get() { return "string&"; } }; template <> struct Name<const string&> { static const char * get() { return "const string&"; } }; template <> struct Name<string&&> { static const char * get() { return "string&&"; } }; template <> struct Name<const string&&> { static const char * get() { return "const string&&"; } }; template <typename T> void quark(T&& t) { cout << "t: " << t << endl; cout << "T: " << Name<T>::get() << endl; cout << "T&&: " << Name<T&&>::get() << endl; cout << endl; } string strange() { return "strange()"; } const string charm() { return "charm()"; } int main() { string up("up"); const string down("down"); quark(up); quark(down); quark(strange()); quark(charm()); } C:\Temp>cl /EHsc /nologo /W4 collapse.cpp collapse.cpp C:\Temp>collapse t: up T: string& T&&: string& t: down T: const string& T&&: const string& t: strange() T: string T&&: string&& t: charm() T: const string T&&: const string&&
(譯註:這邊講一下「參數(parameter)」跟「引數(argument)」的差別。參數指的是你宣告函數的時候,寫在參數串列的東西,比方說
int min(int a, int b);
的 a
跟 b
就是參數。引數指的是你在呼叫這個函數的時候,實際傳給函數的那個東西,比方說 int c = min(4, 7);
的 4
跟 7
就是引數。很多人有時候會用參數來指引數,其實這篇文章的原文裡面也會這樣,我翻譯的時候也會這樣,看狀況決定使用哪一個名詞。但是接下來的部份因為要仔細的區分這兩者,原文就寫得很小心,所以我也翻得很小心,當我說「參數」的時候就是很明確的「parameter」,不會是「argument」,當我說「引數」的時候就是「argument」,那你們也要看得很小心,不然會覺得很奇怪 yoco 不知道在說三小。)藉由顯示指定 Name 的型別來印出我們
T
的型別。當呼叫
quark(up)
的時候,會進行型別引數推導。quark()
有一個型別參數,但是我們沒有顯示指定他的型別(比方像 quark<X>(up)
)。所以 up
就會被拿來和宣告 quark()
時所使用的 T&&
進行比較,以進行型別引數推導。C++0x 會把函數的參數跟引數都轉型,來進行比對動作。
首先會先轉換函數引數的型別。有一條特殊規則(N2798 14.8.2.1 [demp.deduct.call]/3):「when the function parameter type is of the form
T&&
where T
is a template parameter, and the function argument is an lvalue of type A
, the type A&
is used for template argument deduction.」(當參數型別是 T&&
,且引數是一個型別 A
的 lvalue 的時候,引數型別會推導為 A&
。)(但是這條特殊規則在引數型別是 T&
或是 const T&
的時候不會作用,這樣跟 C++98/03 的行為一樣,另外在引數型別是 const T&&
的時候也不會作用。)在 quark(up)
這個例子,我們會把 string
轉成 string&
。然後會轉換函數參數的型別。不管是 C++98/03 還是 C++0x 都一樣,會卸除 reference(不管是 lvalue 還是 rvalue 的 reference 在 C++0x 都會被卸除掉)在這個例子呢,代表
T&&
會變成 T
。於是
T
就是你傳進來的引數的型別,這就是為什麼,quark(up) 會印出「T: string&
」然後 quark(down)
會印出「T: const string&
」。up
跟 down
都是 lvalue,所以他們會啟動那個特殊規則。strange()
跟 charm()
是 rvalue,所以他們用的就是本來舊有的規則,所以 quark(strange())
印出「T: string
」然後 quark(charm())
印出「T: const string
」。做完引數型別推導之後,會進行替換動作。每一個樣板引數
T
都會被替換成他經過推導之後的型別。以 quark(strange())
來說,T
是 string
,所以 T&&
就會是 string&&
。同樣的,quark(charm())
時,T
是 const string
,所以 T&&
就會是 const string&&
。但是 up
跟 down
不是這樣,他們的情況有另外一條特殊規則來處理。看看
quark(up)
,T
是 string&
,T&&
經過替換以後就變成 string& &&
,在 C++0x 裡面,reference 的 reference 會被折疊(reference collapse),折疊的規則是:「lvalue reference 有傳染性」。X& &
,X& &&
還有 X&& &
都會變成 X&
,只有 X&& &&
會變成 X&&
。所以 string& &&
會被折疊成 string&
。所以說在 template 的世界裡面,看起來像是 rvalue reference 的東西其實不一定是 rvalue reference。Name<T&&>::get()
就是一個例子。同樣的,quark(down) 會具現化 quark<const string&>()
,因為 T&&
會被替換成 string&
。在 C++98/03,你可能已經用過常數性來遮蔽樣板參數的參數型別(比方說一個樣板函數接受一個 T&
當作參數,但是當你傳一個 const Foo
物件給他的時候,T*
就會是 const Foo*
)。在 C++0x,lvalue 的特性也會遮蔽樣板的參數型別。好,那這兩條特殊規則對我們有什麼影響?在
quark()
的參數列我們使用 T&&
,T&&
會跟 quark()
接到的引數有一模一樣的型別,包含了 lvalue/rvalue 還有 const/non-const 的所有特性都全部保留了。這就是為什麼可以用 rvalue reference 來做到完美轉發。perfect forwarding: how std::forward() and std::identity work
回頭再看一次
outer()
。template <typename T1, typename T2> void outer(T1&& t1, T2&& t2) { inner(Forward<T1>(t1), Forward<T2>(t2)); }
現在我們知道為什麼
outer()
要收 T1&&
跟 T2&&
了,因為這樣可以保留完整的型別資訊。但是為什麼要呼叫 Forward<T1>()
跟 Forward<T2>()
?回憶一下,不管是具名的 lvalue reference 還是具名的 rvalue reference 都是 lvalue。如果 outer()
直接呼叫 inner(t1, t2)
,那 inner()
就會拿到兩個 lvalue,這樣就打斷了完美轉發。幸運啦,不具名的 lvalue reference 是 lvalue,然後不具名 rvalue reference 是 rvalue。所以為了要把
t1
跟 t2
轉發給 inner()
,我們必需要透過一個函數來幫忙保留他們的型別,但是可以去掉他們的名字。這就是 std::forward()
的功用。template <typename T> struct Identity { typedef T type; }; template <typename T> T&& Forward(typename Identity<T>::type&& t) { return t; }
當呼叫
Forward<T1>(t1)
的時候,Identify
並沒有改變 T1
(我們馬上就會看到他是幹甚麼吃的)。所以 Forward<T1>(t1)
吃了一個 T1&&
又吐了一個 T1&&
。這樣 t1
的型別就保持不變(不管他吃什麼東西都一樣,string&
,const string&
,string&&
,const string&&
都一樣),但是名字不見啦!inner()
只會看到 Forward<T1>(t1)
,而這東西跟 t1
有一模一樣的型別,不管 lvalue/rvalue,const/non-const 都會跟 outer()
當初拿到的一模一樣。完美轉發就是這樣。你大概好奇如果你不小心寫了
forward<T1&&>(t1)
會怎樣。(這個錯誤還蠻誘人的,因為 outer()
接的是 T1&& t1
。)很幸運,沒什麼不好的事情會發生。Forward<T1&&>()
會拿到一個 T1&& &&
,並且傳回一個 T1&& &&
,然後會被折疊成 T1&&
。因此,Forward<T1>(t1)
跟 Forward<T1&&>(t1)
是一模一樣,但是我們偏好前面那一個,因為程式碼比較短。那
Identity
是幹啥吃的?為什麼不能直接這樣寫?template <typename T> T&& Forward(T&& t) { // BROKEN return t; }
如果這樣寫,那就是隱式呼叫。這樣樣板引數推導就會作用,我們已經知道引數推導會幹嘛了,當你傳進來的值是一個 lvalue 的時候,他會把
T&&
改成 T&
,也就是 lvalue。但是我們一直想要解決的問題,不就是 outer()
裡面不管你收到的是 lvalue 還是 rvalue 結果都會是一個 lvalue 嗎。在上面那個錯誤實作範例,Forward<T1>(t1)
會對,但是 Forward(t1)
就錯啦,他會直接把 t1
的型別傳給 inner()
,這是一個非常誘惑人的陷阱,因為他會毫髮無傷的通過編譯,然後會帶來一連串的苦難,所以 Identity
是用來阻止樣板引數型別的自動推導。在 Identity<T>::type
裡面的那兩個冒號是一層絕緣體,樣板引數型別推導的作用沒辦法穿越他,很有經驗的程式設計師應該對這些東西很熟,因為這點不管在 C++98/03 還是 C++0x 都一樣。(不過詳細的原因又是另外一件事了。)move semantics: how std::move() works
現在會了樣板引數推導的特殊規則以後,讓我們回頭看
std::move()
:template <typename T> struct RemoveReference { typedef T type; }; template <typename T> struct RemoveReference<T&> { typedef T type; }; template <typename T> struct RemoveReference<T&&> { typedef T type; }; template <typename T> typename RemoveReference<T>::type&& Move(T&& t) { return t; }
RemoveReference 的機制跟 C++0x 的 <type_traits> 裡面的
std::remove_reference
完全一樣。舉例來說 RemoveReference<string>::type
,RemoveReference<string&>::type
跟 RemoveReference<string&&>::type
都一樣是 string
。同樣的,
Move()
跟 C++0x <utility> 的 std::move()
一模一樣。- 當呼叫
Move(string)
,string
是一個 lvalue 的時候,T
會被推導成string&
,所以Move()
會拿到string&
,經過RemoveReference
處理完會變成string
,最後會傳回一個string&&
。 - 當呼叫
Move(string)
,string
是一個 const lvalue 的時候,T
會被推導成const string&
,所以Move()
拿到string&
傳給RemoveReference
之後,會傳回一個const string&&
。 - 當呼叫
Move(string)
,string
是一個 rvalue 的時候,T
會被推導成string
,Move()
拿到一個string&&
,傳回一個string&&
。 - 當呼叫
Move(string)
,string
是一個 const rvalue 的時候,T
會被推導成const string
,Move()
拿到一個const string&&
,傳回一個const string&&
。
這就是
Move()
之所以可以保留型別跟常數性,但是可以把 lvalue 變成 rvalue 的原理。the past
如果想要對 rvalue referece 了解的更多,可以看他們的提案文件。不過要提一下的是那些提案所寫的東西跟現在決定的可能已經不一樣了。Rvalue reference 已經被整合到 C++0x 標準的工作文件,但是很多提案文件也許已經過時了,或是不太對,或是沒有被採納,但是如論如何那些提案還是有很多有用的資訊。
N1377,N1385,還有 N1690 是主要的提案。N2118 含括了在提入標準前的最後版本。N1784,N1821,N2377,還有 N2439 是「Extending Move Semantics To
*this
(把 move 語意擴充到 *this
)」的演變過程,這已經被納入 C++0x 了,但是 VC10 還沒有實作。the future
N2812「A safety Problem With Rvalue Reference (and what to do about it)」提出了一個初始化規則的變動,可以防止 rvalue reference 被繫結到 lvalue 上面。裡面提到這項改變並不會影響 move 語意跟完美轉發,所以你現在學到的東西都還是有用(但是
std::move()
跟 std::forward()
的實作會改變)。Stephan T. Lavavej
Visual C++ Libraries Developer
沒有留言:
張貼留言