簡介
C++0x中引入了右值引用(rvalue reference)這個設施,形如T&&,用來實現移動語義(move semantics)和完美轉發(perfect forwarding)。此前C++中有一個著名的性能問題——複製臨時對象,由於右值引用的引入,該問題將得到極大的改善。
雖然右值引用的引入是一個很了不起的進步,也是一個明智的決定,但它並不那麼討人喜歡,至少我覺得如此。原因有二:首先是其概念本身就不容易理解,增加了一些智力負擔;另外如果想享受它帶來的性能好處,還必須增加一些編碼工作量。
什麼是左值和右值
我們首先需要熟悉一下現行C++98/03標準中,左值和右值的概念。網上現在充斥着很多對它們的解釋,長篇大論的有,隻言片語的也有,而且不一定清晰正確,可謂良莠不齊,這裏我希望能夠儘量簡單準確地進行說明。
這裏有兩個問題經常令人混淆,必須首先澄清一下:
1. lvalue(left value)和rvalue(right value)的概念經過了長時間的演化,早已名不副實,千萬不要把L和R當做真正的左右來理解。最早或許left value意味着在等號左邊,right value意味着在等號右邊,但對於現代C++這麼複雜的語法來說,早就不適用了。
2. 左值還是右值,是針對某個表達式而言的,而不是針對某個具體值或者對象本身而言。C++ 03 標準 3.10/1 節:“每一個表達式要麼是一個lvalue,要麼就是一個rvalue。”因此不要單純的考慮數字1是左值還是右值,或者存在地址0x100000上的那個string對象到底是左值還是右值,重要的是看表達式。
左值和右值的定義和判斷:
如果一個語句結束的時候,該表達式代表的對象立刻被銷燬,則爲右值,否則就是左值。也就是說右值代表的是臨時對象或者字面值,而左值則不是臨時對象。引申出來的另一個判斷方法是:具名的表達式意味着是左值,非具名的則爲右值(非具名左值引用是個例外,它是左值)。示例代碼如下:
- int add(int v1, int v2)
- {
- return v1 + v2;
- }
- int& g()
- {
- static int i = 100;
- return i;
- }
- void example()
- {
- int x = add(1, 2); // x是左值,add(1, 2)的返回值是右值,x拷貝了它。
- // x是持久的,具名的;
- // add(1, 2)的返回值是臨時的,非具名的。
- // 另外這裏字面值1和2也都是右值,臨時的,非具名的。
- ++x; // ++x是左值,它表示的x本身,是持久的,具名的
- x++; // x++是右值,它表示x的原值,是臨時的,非具名的
- g(); // g()是左值,雖然返回值是非具名的,但是左值引用
- }
現行C++標準對右值的限制:
由於臨時對象在語句結束後被立刻銷燬,因此在語句結束後還使用它是不安全的。所以現行C++標準中,規定右值是不能被具名引用的,因爲一旦被引用了就可能被使用。但令人不爽的是,由於函數在傳遞參數時,又需要讓臨時對象可以被作爲實參傳遞,C++標準只好又規定右值可以被具名引用,但只能被常量引用,而不能被被非常量引用,且被常量引用時,如果該常量引用是具名的(也就是左值),則該臨時對象的生命週期延長到和該常量引用相同。其實這個規定挺搞笑的,完全可以不用區分是否是常量,同樣規定右值也可以被非常量引用,不過標準既然這麼說了,那編譯器就只好這麼辦(其實VC的某些版本沒這麼幹)。這裏要注意的是,左值一旦被具名引用,則變成了右值。示例代碼如下:
- int add(int v1, int v2)
- {
- return v1 + v2;
- }
- void example()
- {
- int const& x1 = add(1, 2); // 正確,按照C++現行標準,右值可以被綁定到常量引用
- // 不過一旦綁定到具名引用,則成爲左值,這裏x1就是左值
- #if _SHOW_ERROR_CASE
- int& y1 = add(3, 4); // 錯誤,按照C++現行標準,右值不能被綁定到非常量引用
- #endif
- }
爲什麼要引入右值引用?
瞭解臨時對象造成的性能問題,瞭解RVO
在現行的C++標準中,如果函數返回一個對象,則該對象是一個臨時對象,也就是一個右值。這就帶來一個很頭痛的性能問題,就是該對象會被白白的拷貝好幾遍。這種純粹浪費性能的行爲完全違背了C++的設計哲學,因此現行C++標準中,不惜破壞原來簡單的拷貝語義,提出可以在拷貝構造的情況下省略掉多餘的拷貝,這就是著名的RVO(Return Value Optimization)。但是很多時候,RVO都不能完全奏效,性能浪費依然不能避免。示例代碼如下:
- // 一個簡單的整型數組類,僅供示例,並非最佳設計
- class int_array
- {
- public:
- ~int_array() // 析構函數
- {
- cout << " int_array::~int_array()" << endl;
- free_memory();
- }
- int_array() // 默認構造函數
- {
- cout << " int_array::int_array()" << endl;
- alloc_memory(0);
- }
- int_array(int_array const& src) // 拷貝構造函數
- {
- cout << " int_array::int_array(int_array const&)" << endl;
- alloc_memory(src.m_size);
- memcpy(m_buffer, src.m_buffer, m_size * sizeof(int));
- }
- int_array(unsigned int size) // 用來初始化爲0值的構造函數
- {
- cout << " int_array::int_array(unsigned int)" << endl;
- alloc_memory(size);
- memset(m_buffer, 0, size * sizeof(int));
- }
- int_array& operator=(int_array const& rhs) // 賦值操作符
- {
- cout << " int_array& int_array::operator=(int_array const&)" << endl;
- if (this != &rhs)
- {
- free_memory();
- alloc_memory(rhs.m_size);
- memcpy(m_buffer, rhs.m_buffer, m_size * sizeof(int));
- }
- return *this;
- }
- unsigned int size() const // 獲取大小
- {
- return m_size;
- }
- int get_at(unsigned int offset) const // 獲取指定位置的值
- {
- return m_buffer[offset];
- }
- void set_at(unsigned int offset, int value) // 設置指定位置的值
- {
- m_buffer[offset] = value;
- }
- private:
- void alloc_memory(unsigned int size) // 分配內存
- {
- cout << " int_array::alloc_memory(unsinged int)" << endl;
- m_buffer = new int[size];
- m_size = size;
- }
- void free_memory() // 釋放內存
- {
- cout << " int_array::free_memory()" << endl;
- delete[] m_buffer;
- }
- int* m_buffer;
- unsigned int m_size;
- };
- // 生成一個有兩個元素的int_arry
- int_array make_int_array2(int v1, int v2)
- {
- int_array result(2); // 調用構造函數
- result.set_at(0, v1);
- result.set_at(1, v2);
- return result; // 調用拷貝構造函數,但可以被RVO
- }
- // 生成一個有size個元素的int_arry
- int_array make_int_array(unsigned int size, ...)
- {
- if (size > 10000000) // 如果太大則返回一個空的
- return int_array(); // 調用構造函數和拷貝構造函數
- int_array result(size); // 調用構造函數
- va_list args;
- va_start(args, size);
- for (unsigned int i = 0; i < size; ++i)
- {
- int v = va_arg(args, int);
- result.set_at(i, v);
- }
- return result; // 調用拷貝構造函數,
- // 兩個不同的return導致RVO失敗
- }
- void example()
- {
- cout << endl << "enter" << endl;
- cout << endl << "step1" << endl;
- // 可以RVO,僅調用一次構造函數,否則按照原始語義,應該有三次構造
- int_array ia1 = make_int_array2(10, 20);
- cout << endl << "step2" << endl;
- // 無法完全RVO,調用一次構造函數,一次賦值操作,浪費一次內存分配和釋放
- ia1 = make_int_array2(100, 200);
- cout << endl << "step3" << endl;
- // 無法完全RVO,調用兩次構造函數,浪費一次內存分配和釋放
- int_array ia2 = make_int_array(5, 1, 2, 3, 4, 5);
- cout << endl << "step4" << endl;
- // 無法RVO,調用兩次次構造函數,一次賦值操作,浪費兩次內存分配和釋放
- ia2 = make_int_array(3, 1, 2, 3);
- cout << endl << "exit" << endl;
- }
右值引用是救星:
爲了解決這個問題,有一個很直觀的方案,就是對臨時對象的內部的數據(如上例中的m_buffer成員)進行操作,將它們“移動”或者“交換”過來,從而取代拷貝行爲,因爲反正臨時對象馬上就要銷燬的,移動過來豈不正好?
但問題來了,如果要對臨時對象的內部數據進行修改,至少需要具備兩個條件:一個是需要引用臨時對象,否則怎麼有機會修改它呢;另一個是要識別對象是不是一個臨時對象,改錯了可就完蛋了。
如果簡單的規定右值可以被引用,而且可以被非常量的引用,貌似可以解決第一個問題,但第二個問題還是沒法解決,因爲你不知道一個非常量的引用到底是不是臨時對象。
因此C++0x標準中引入了右值引用,用兩個連續的“&”符號來表示,和左值引用以示區別。例如int&&就表示一個整型的右值引用,而int&則還是和原來一樣,表示整型的左值引用。C++0x標準進一步規定,除了原來規定的右值可以綁定到常量左值引用外,右值還可以綁定到右值引用,當然一旦被具名引用,右值還是會變成左值。而且遇到重載時,優先考慮將右值綁定到右值引用而不是左值引用(那當然,否則這玩意兒就廢了)。另外,左值不允許綁定到右值引用,除非強制類型轉換,這一點也很重要,以免無意中將非臨時對象的內部數據給移走了。示例代碼如下:
- int add(int v1, int v2)
- {
- return v1 + v2;
- }
- int const add_const(int v1, int v2)
- {
- return v1 + v2;
- }
- void func(int& i)
- {
- cout << "func(int&)" << endl;
- }
- void func(int const& i)
- {
- cout << "func(int const&)" << endl;
- }
- void func(int&& i)
- {
- cout << "func(int&&)" << endl;
- }
- void func(int const&& i)
- {
- cout << "func(int const&&)" << endl;
- }
- template <typename T>
- void f1(T&&)
- {
- }
- void example()
- {
- int x1 = 1; // 正確,右值可以拷貝到左值
- int& x2 = x1; // 正確,左值可以綁定到左值引用
- int const& x3 = x1; // 正確,非常量左值可以綁定到常量左值引用
- int const& x4 = add(1, 2); // 正確,右值可以被綁定到常量左值引用
- int&& x5 = add(1, 2); // 正確,右值可以被綁定到右值引用
- int const&& x6 = add_const(1, 2); // 正確,常量右值可以被綁定到常量右值引用
- int const&& x7 = add(1, 2); // 正確,非常量右值可以被綁定到常量右值引用
- int& x8 = x5; // 正確,左值可以綁定到左值引用,x5雖然是右值引用
- // 但由於已經是具名引用,因此變成了左值
- func(x1); // 調用func(int&)
- func(x3); // 調用func(int const&)
- func(add(1, 2)); // 調用func(int&&)
- func(add_const(1, 2)); // 應調用func(int const&&),add_const是常量右值,
- // 但GCC4.5有bug,調用func(int&&),VC10是正確的
- func(1); // 調用func(int&&), 字面值1被當做非常量右值
- func(x5); // 調用func(int&),x5雖然是右值引用
- // 但由於已經是具名引用,因此變成了左值
- f1(x1); // 對於函數模板的推導,C++0x引入了reference collapsing,
- // 並對左值的推導進行了特別規定,是因爲要實現完美轉發
- // 因此這裏看起來好像是左值被綁定到右值引用,
- // 但實質上還是左值引用
- #if _SHOW_ERROR_CASE
- int& y1 = add(1, 2); // 錯誤,右值不能綁定到非常量左值引用
- int& y2 = x3; // 錯誤,常量左值不能綁定到非常量左值引用
- int&& y3 = x1; // 錯誤,左值不能綁定到右值引用
- int&& y4 = add_const(1, 2); // 錯誤,常量右值不能綁定到非常量右值引用
- int const&& y5 = x1; // 錯誤,左值不能綁定到右值引用,常量右值引用也不行
- int&& y6 = x5; // 錯誤,左值不能綁定到右值引用,x5雖然是右值引用,
- // 但由於已經是具名引用,因此變成了左值
- #endif
- }
有了這樣一個規定,就簡單了,首先右值可以被引用了,其次爲了分辨對象是不是臨時的,我們可以做兩個重載的函數,其中一個的形參是左值引用,另一個的形參是右值引用,那麼該函數被調用時,如果參數是左值(非臨時對象)或者左值引用,編譯器會自動調用前一個重載的函數,如果參數是右值(臨時對象)或者右值引用,則會自動調用後一個重載的函數,這樣我們就可以準確的對左值和右值分開處理了。根據此原理,我們對前面例子中的int_array類進行修改,增加一個拷貝構造函數重載,和一個賦值操作符重載,用來接受右值引用,並修改使用的地方。示例代碼如下:
- // 拷貝構造函數重載,其實是轉移構造函數,接受非常量右值,用於轉移內部數據,
- // 對於常量的右值,由於不能修改內容,還是需要拷貝,正好走傳統的拷貝構造函數
- int_array(int_array&& src)
- {
- cout << " int_array::int_array(int_array&&)" << endl;
- // 直接轉移內部數據,避免了額外的拷貝
- m_buffer = src.m_buffer;
- m_size = src.m_size;
- src.m_buffer = 0;
- src.m_size = 0;
- }
- // 賦值操作符重載,其實可以看做交換操作符,接受非常量右值,用於交換內部數據
- // 對於常量右值,由於無法修改其內部數據,還是需要拷貝,正好走傳統的賦值操作符
- int_array& operator=(int_array&& rhs)
- {
- cout << " int_array& int_array::operator=(int_array&&)" << endl;
- if (this != &rhs)
- {
- // 交換內部數據,這樣等下銷燬的就是這個對象現在的內部數據了,
- // 從而避免了不必要的拷貝
- swap(m_buffer, rhs.m_buffer);
- swap(m_size, rhs.m_size);
- }
- return *this;
- }
- // 生成一個有兩個元素的int_arry,採用了轉移語義減少拷貝
- int_array new_make_int_array2(int v1, int v2)
- {
- int_array result(2); // 調用構造函數
- result.set_at(0, v1);
- result.set_at(1, v2);
- // 通過move函數將result對象從左值轉成右值,
- // 從而調用int_array的轉移構造函數,以減少不必要的拷貝
- return int_array(std::move(result));
- }
- // 生成一個有size個元素的int_arry,採用了轉移語義減少拷貝
- int_array new_make_int_array(unsigned int size, ...)
- {
- if (size > 10000000) // 如果太大則返回一個空的
- // 這裏由於int_array()本事就是一個無名對象,也就是右值,
- // 從而自動調用了int_array的轉移構造函數,沒有不必要的拷貝
- return int_array(); // 調用
- int_array result(size); // 調用構造函數
- va_list args;
- va_start(args, size);
- for (unsigned int i = 0; i < size; ++i)
- {
- int v = va_arg(args, int);
- result.set_at(i, v);
- }
- // 通過move函數將result對象從左值轉成右值,
- // 從而調用int_array的轉移構造函數,以減少不必要的拷貝
- return int_array(std::move(result));
- }
- void new_example()
- {
- cout << endl << "enter" << endl;
- cout << endl << "step1" << endl;
- // 如果沒有RVO,應該調用一次構造函數,兩次轉移構造函數,沒有不必要的拷貝
- // 由於RVO,實際上只調用了一次構造函數,一次轉移構造函數
- int_array ia1 = new_make_int_array2(10, 20);
- cout << endl << "step2" << endl;
- // 如果沒有RVO,應該調用一次構造函數,一次轉移構造函數,一次交換等於操作符,
- // 由於RVO,實際上只調用了一次構造函數,一次交換等於操作符,沒有不必要的拷貝
- ia1 = make_int_array2(100, 200);
- cout << endl << "step3" << endl;
- // 如果沒有RVO,應該調用一次構造函數,兩次轉移構造函數,沒有不必要的拷貝
- // 由於RVO,實際上只調用了一次構造函數,一次轉移構造函數
- int_array ia2 = make_int_array(5, 1, 2, 3, 4, 5);
- cout << endl << "step4" << endl;
- // 調用了一次構造函數,一次轉移構造函數,一次交換語義的等於操作符,
- // 沒有不必要的拷貝
- ia2 = make_int_array(3, 1, 2, 3);
- cout << endl << "exit" << endl;
- }
這個示例代碼已經完全消除了不必要的拷貝,之前的性能問題得到了徹底地解決。我們發現代碼裏面用到了std::move()這個函數,接下來我們仔細講解這個函數。
瞭解std::move()
很多時候,我們需要把左值引用轉換成右值。原因通常有兩個:一個原因是我們明確知道該左值不久後將被銷燬,而我們需要轉移其中的內部數據到其它對象中,例如上例中new_make_int_array()函數中的用法;另一個原因是由於右值一旦被具名引用,哪怕是具名的右值引用,它也會變成左值,因此需要將它恢復成右值,這個描述很拗口,但的確就是這樣。
標準庫中提供了std::move()這個模板函數,用來幹這個轉換的差事。它接收一個引用,並強制類型轉換成右值引用,然後返回。由於函數返回值是不具名的右值引用,因此它還是右值。具體的實現代碼如下:
- template<typename _Tp>
- inline typename std::remove_reference<_Tp>::type&& move(_Tp&& __t)
- {
- return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
- }
std::move()從名字上看,通常會產生錯誤的理解,以爲移動臨時對象內部數據的操作是由它完成的。看了代碼就知道了,根本不是那麼回事,它僅僅完成一個語義上的轉換,將左值變成右值而已,而且沒有任何性能損失。由於std::move()的語義是轉成右值,以便接下來被轉移,那麼被傳入到move函數的參數,在move調用過後,就最好不要再訪問了,以免訪問到錯誤的值。
完美轉發(perfect forwarding)
最後我們講一下完美轉發,仔細講起來會比較複雜。簡單來說,在編寫函數模板的過程中,可能需要把模板實參的左右值特性和常量性完美的保持下來,轉發給其它的函數。在C++現行標準中,需要爲每一個左右值特性和常量性作一個函數重載,如果函數只有一個形參,需要至少4個重載,如果函數模板有3個參數,則需要4的3次方=64個重載,因此很難做到統一的解決方案。C++0x通過右值引用,加上引用摺疊(reference collapsing),以及左值引用經過函數模板推導還是左值引用的特殊規定,較好的實現了完美轉發,就是std::forward()這個模板函數。其實現代碼如下:
- template<class _Ty>
- _Ty&& forward(typename identity<_Ty>::type& _Arg)
- {
- return ((_Ty&&)_Arg);
- }
引用摺疊(reference collapsing)
補充介紹一下引用摺疊,這是C++0x爲了實現移動語義(move semantics)和完美轉發(perfect forwarding)而增加的規定。簡單羅列一下規則,就很清楚了:
A& &等價於A&
A& &&等價於A&
A&& &等價於A&
A&& &&等價於A&&
總結
1. 右值引用的確帶來了性能提升的可能,也帶來了完美轉發。但性能的提升需要額外的編碼,用來實現轉移和交換行爲。
2. 左值和右值的概念,並不是指等號的左邊還是右邊,而是指該表達式代表的對象是否是持久的,持久的就是左值,反之是右值。我們也可以用是否具名來判斷,具名的是左值,反之是右值(非具名左值引用除外)。
3. std::move()函數用於語義上的轉移,將左值轉成右值,而不是實質上數據的轉移,實質性的轉移需要每個類單獨編碼實現。
4. std::forward()函數實現完美轉發,可用於模板函數中完美的傳遞參數。