探索C++0x: 3. 右值引用(rvalue reference)

簡介

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對象到底是左值還是右值,重要的是看表達式。

左值和右值的定義和判斷:

如果一個語句結束的時候,該表達式代表的對象立刻被銷燬,則爲右值,否則就是左值。也就是說右值代表的是臨時對象或者字面值,而左值則不是臨時對象。引申出來的另一個判斷方法是:具名的表達式意味着是左值,非具名的則爲右值(非具名左值引用是個例外,它是左值)。示例代碼如下:

  1. int add(int v1, int v2)  
  2. {  
  3.     return v1 + v2;  
  4. }  
  5.   
  6. int& g()  
  7. {  
  8.     static int i = 100;  
  9.     return i;  
  10. }  
  11.   
  12. void example()  
  13. {  
  14.     int x = add(1, 2);  // x是左值,add(1, 2)的返回值是右值,x拷貝了它。  
  15.                         // x是持久的,具名的;  
  16.                         // add(1, 2)的返回值是臨時的,非具名的。  
  17.                         // 另外這裏字面值1和2也都是右值,臨時的,非具名的。  
  18.     ++x;                // ++x是左值,它表示的x本身,是持久的,具名的  
  19.     x++;                // x++是右值,它表示x的原值,是臨時的,非具名的  
  20.     g();                // g()是左值,雖然返回值是非具名的,但是左值引用  
  21. }  

 

現行C++標準對右值的限制:

由於臨時對象在語句結束後被立刻銷燬,因此在語句結束後還使用它是不安全的。所以現行C++標準中,規定右值是不能被具名引用的,因爲一旦被引用了就可能被使用。但令人不爽的是,由於函數在傳遞參數時,又需要讓臨時對象可以被作爲實參傳遞,C++標準只好又規定右值可以被具名引用,但只能被常量引用,而不能被被非常量引用,且被常量引用時,如果該常量引用是具名的(也就是左值),則該臨時對象的生命週期延長到和該常量引用相同。其實這個規定挺搞笑的,完全可以不用區分是否是常量,同樣規定右值也可以被非常量引用,不過標準既然這麼說了,那編譯器就只好這麼辦(其實VC的某些版本沒這麼幹)。這裏要注意的是,左值一旦被具名引用,則變成了右值。示例代碼如下:

  1. int add(int v1, int v2)  
  2. {  
  3.     return v1 + v2;  
  4. }  
  5.   
  6. void example()  
  7. {  
  8.     int const& x1 = add(1, 2);  // 正確,按照C++現行標準,右值可以被綁定到常量引用  
  9.                                 // 不過一旦綁定到具名引用,則成爲左值,這裏x1就是左值  
  10. #if _SHOW_ERROR_CASE  
  11.     int& y1 = add(3, 4);        // 錯誤,按照C++現行標準,右值不能被綁定到非常量引用  
  12. #endif  
  13. }  

 

爲什麼要引入右值引用?

瞭解臨時對象造成的性能問題,瞭解RVO

在現行的C++標準中,如果函數返回一個對象,則該對象是一個臨時對象,也就是一個右值。這就帶來一個很頭痛的性能問題,就是該對象會被白白的拷貝好幾遍。這種純粹浪費性能的行爲完全違背了C++的設計哲學,因此現行C++標準中,不惜破壞原來簡單的拷貝語義,提出可以在拷貝構造的情況下省略掉多餘的拷貝,這就是著名的RVO(Return Value Optimization)。但是很多時候,RVO都不能完全奏效,性能浪費依然不能避免。示例代碼如下:

  1. // 一個簡單的整型數組類,僅供示例,並非最佳設計  
  2. class int_array  
  3. {  
  4. public:  
  5.   
  6.     ~int_array()    // 析構函數  
  7.     {  
  8.         cout << "  int_array::~int_array()" << endl;  
  9.         free_memory();  
  10.     }  
  11.   
  12.     int_array() // 默認構造函數  
  13.     {  
  14.         cout << "  int_array::int_array()" << endl;  
  15.         alloc_memory(0);  
  16.     }  
  17.   
  18.     int_array(int_array const& src) // 拷貝構造函數  
  19.     {  
  20.         cout << "  int_array::int_array(int_array const&)" << endl;  
  21.         alloc_memory(src.m_size);  
  22.         memcpy(m_buffer, src.m_buffer, m_size * sizeof(int));  
  23.     }  
  24.   
  25.     int_array(unsigned int size)   // 用來初始化爲0值的構造函數  
  26.     {  
  27.         cout << "  int_array::int_array(unsigned int)" << endl;  
  28.         alloc_memory(size);  
  29.         memset(m_buffer, 0, size * sizeof(int));  
  30.     }  
  31.   
  32.     int_array& operator=(int_array const& rhs)  // 賦值操作符  
  33.     {  
  34.         cout << "  int_array& int_array::operator=(int_array const&)" << endl;  
  35.         if (this != &rhs)  
  36.         {  
  37.             free_memory();  
  38.             alloc_memory(rhs.m_size);  
  39.             memcpy(m_buffer, rhs.m_buffer, m_size * sizeof(int));  
  40.         }  
  41.         return *this;  
  42.     }  
  43.   
  44.     unsigned int size() const   // 獲取大小  
  45.     {  
  46.         return m_size;  
  47.     }  
  48.   
  49.     int get_at(unsigned int offset) const   // 獲取指定位置的值  
  50.     {  
  51.         return m_buffer[offset];  
  52.     }  
  53.   
  54.     void set_at(unsigned int offset, int value) // 設置指定位置的值  
  55.     {  
  56.         m_buffer[offset] = value;  
  57.     }  
  58.   
  59. private:  
  60.   
  61.     void alloc_memory(unsigned int size)    // 分配內存  
  62.     {  
  63.         cout << "    int_array::alloc_memory(unsinged int)" << endl;  
  64.         m_buffer = new int[size];  
  65.         m_size = size;  
  66.     }  
  67.   
  68.     void free_memory()  // 釋放內存  
  69.     {  
  70.         cout << "    int_array::free_memory()" << endl;  
  71.         delete[] m_buffer;  
  72.     }  
  73.   
  74.     int* m_buffer;  
  75.     unsigned int m_size;  
  76. };  
  77.   
  78. // 生成一個有兩個元素的int_arry  
  79. int_array make_int_array2(int v1, int v2)  
  80. {  
  81.     int_array result(2);    // 調用構造函數  
  82.     result.set_at(0, v1);  
  83.     result.set_at(1, v2);  
  84.     return result;          // 調用拷貝構造函數,但可以被RVO  
  85. }  
  86.   
  87. // 生成一個有size個元素的int_arry  
  88. int_array make_int_array(unsigned int size, ...)  
  89. {  
  90.     if (size > 10000000)    // 如果太大則返回一個空的  
  91.         return int_array(); // 調用構造函數和拷貝構造函數  
  92.   
  93.     int_array result(size); // 調用構造函數  
  94.     va_list args;  
  95.     va_start(args, size);  
  96.     for (unsigned int i = 0; i < size; ++i)  
  97.     {  
  98.         int v = va_arg(args, int);  
  99.         result.set_at(i, v);  
  100.     }  
  101.     return result;          // 調用拷貝構造函數,  
  102.                             // 兩個不同的return導致RVO失敗  
  103. }  
  104.   
  105. void example()  
  106. {  
  107.     cout << endl << "enter" << endl;  
  108.   
  109.     cout << endl << "step1" << endl;  
  110.   
  111.     // 可以RVO,僅調用一次構造函數,否則按照原始語義,應該有三次構造  
  112.     int_array ia1 = make_int_array2(10, 20);  
  113.   
  114.     cout << endl << "step2" << endl;  
  115.   
  116.     // 無法完全RVO,調用一次構造函數,一次賦值操作,浪費一次內存分配和釋放  
  117.     ia1 = make_int_array2(100, 200);  
  118.   
  119.     cout << endl << "step3" << endl;  
  120.   
  121.     // 無法完全RVO,調用兩次構造函數,浪費一次內存分配和釋放  
  122.     int_array ia2 = make_int_array(5, 1, 2, 3, 4, 5);  
  123.   
  124.     cout << endl << "step4" << endl;  
  125.   
  126.     // 無法RVO,調用兩次次構造函數,一次賦值操作,浪費兩次內存分配和釋放  
  127.     ia2 = make_int_array(3, 1, 2, 3);  
  128.   
  129.     cout << endl << "exit" << endl;  
  130. }  

 

右值引用是救星:

爲了解決這個問題,有一個很直觀的方案,就是對臨時對象的內部的數據(如上例中的m_buffer成員)進行操作,將它們“移動”或者“交換”過來,從而取代拷貝行爲,因爲反正臨時對象馬上就要銷燬的,移動過來豈不正好?

但問題來了,如果要對臨時對象的內部數據進行修改,至少需要具備兩個條件:一個是需要引用臨時對象,否則怎麼有機會修改它呢;另一個是要識別對象是不是一個臨時對象,改錯了可就完蛋了。

如果簡單的規定右值可以被引用,而且可以被非常量的引用,貌似可以解決第一個問題,但第二個問題還是沒法解決,因爲你不知道一個非常量的引用到底是不是臨時對象。

因此C++0x標準中引入了右值引用,用兩個連續的“&”符號來表示,和左值引用以示區別。例如int&&就表示一個整型的右值引用,而int&則還是和原來一樣,表示整型的左值引用。C++0x標準進一步規定,除了原來規定的右值可以綁定到常量左值引用外,右值還可以綁定到右值引用,當然一旦被具名引用,右值還是會變成左值。而且遇到重載時,優先考慮將右值綁定到右值引用而不是左值引用(那當然,否則這玩意兒就廢了)。另外,左值不允許綁定到右值引用,除非強制類型轉換,這一點也很重要,以免無意中將非臨時對象的內部數據給移走了。示例代碼如下:

  1. int add(int v1, int v2)  
  2. {  
  3.     return v1 + v2;  
  4. }  
  5.   
  6. int const add_const(int v1, int v2)  
  7. {  
  8.     return v1 + v2;  
  9. }  
  10.   
  11. void func(int& i)  
  12. {  
  13.     cout << "func(int&)" << endl;  
  14. }  
  15.   
  16. void func(int const& i)  
  17. {  
  18.     cout << "func(int const&)" << endl;  
  19. }  
  20.   
  21. void func(int&& i)  
  22. {  
  23.     cout << "func(int&&)" << endl;  
  24. }  
  25.   
  26. void func(int const&& i)  
  27. {  
  28.     cout << "func(int const&&)" << endl;  
  29. }  
  30.   
  31. template <typename T>  
  32. void f1(T&&)  
  33. {  
  34. }  
  35.   
  36. void example()  
  37. {  
  38.     int x1 = 1;                     // 正確,右值可以拷貝到左值  
  39.     int& x2 = x1;                   // 正確,左值可以綁定到左值引用  
  40.     int const& x3 = x1;             // 正確,非常量左值可以綁定到常量左值引用  
  41.     int const& x4 = add(1, 2);      // 正確,右值可以被綁定到常量左值引用  
  42.     int&& x5 = add(1, 2);           // 正確,右值可以被綁定到右值引用  
  43.     int const&& x6 = add_const(1, 2); // 正確,常量右值可以被綁定到常量右值引用  
  44.     int const&& x7 = add(1, 2);     // 正確,非常量右值可以被綁定到常量右值引用  
  45.     int& x8 = x5;                   // 正確,左值可以綁定到左值引用,x5雖然是右值引用  
  46.                                     // 但由於已經是具名引用,因此變成了左值  
  47.   
  48.     func(x1);                       // 調用func(int&)  
  49.     func(x3);                       // 調用func(int const&)  
  50.     func(add(1, 2));                // 調用func(int&&)  
  51.     func(add_const(1, 2));          // 應調用func(int const&&),add_const是常量右值,  
  52.                                     // 但GCC4.5有bug,調用func(int&&),VC10是正確的  
  53.     func(1);                        // 調用func(int&&), 字面值1被當做非常量右值  
  54.     func(x5);                       // 調用func(int&),x5雖然是右值引用  
  55.                                     // 但由於已經是具名引用,因此變成了左值  
  56.     f1(x1);                         // 對於函數模板的推導,C++0x引入了reference collapsing,  
  57.                                     // 並對左值的推導進行了特別規定,是因爲要實現完美轉發  
  58.                                     // 因此這裏看起來好像是左值被綁定到右值引用,  
  59.                                     // 但實質上還是左值引用  
  60. #if _SHOW_ERROR_CASE  
  61.     int& y1 = add(1, 2);            // 錯誤,右值不能綁定到非常量左值引用  
  62.     int& y2 = x3;                   // 錯誤,常量左值不能綁定到非常量左值引用  
  63.     int&& y3 = x1;                  // 錯誤,左值不能綁定到右值引用  
  64.     int&& y4 = add_const(1, 2);     // 錯誤,常量右值不能綁定到非常量右值引用  
  65.     int const&& y5 = x1;            // 錯誤,左值不能綁定到右值引用,常量右值引用也不行  
  66.     int&& y6 = x5;                  // 錯誤,左值不能綁定到右值引用,x5雖然是右值引用,  
  67.                                     // 但由於已經是具名引用,因此變成了左值  
  68. #endif  
  69. }  

 

有了這樣一個規定,就簡單了,首先右值可以被引用了,其次爲了分辨對象是不是臨時的,我們可以做兩個重載的函數,其中一個的形參是左值引用,另一個的形參是右值引用,那麼該函數被調用時,如果參數是左值(非臨時對象)或者左值引用,編譯器會自動調用前一個重載的函數,如果參數是右值(臨時對象)或者右值引用,則會自動調用後一個重載的函數,這樣我們就可以準確的對左值和右值分開處理了。根據此原理,我們對前面例子中的int_array類進行修改,增加一個拷貝構造函數重載,和一個賦值操作符重載,用來接受右值引用,並修改使用的地方。示例代碼如下:

  1. // 拷貝構造函數重載,其實是轉移構造函數,接受非常量右值,用於轉移內部數據,  
  2. // 對於常量的右值,由於不能修改內容,還是需要拷貝,正好走傳統的拷貝構造函數  
  3. int_array(int_array&& src)  
  4. {  
  5.     cout << "  int_array::int_array(int_array&&)" << endl;  
  6.     // 直接轉移內部數據,避免了額外的拷貝  
  7.     m_buffer = src.m_buffer;  
  8.     m_size = src.m_size;  
  9.     src.m_buffer = 0;  
  10.     src.m_size = 0;  
  11. }  
  12.   
  13. // 賦值操作符重載,其實可以看做交換操作符,接受非常量右值,用於交換內部數據  
  14. // 對於常量右值,由於無法修改其內部數據,還是需要拷貝,正好走傳統的賦值操作符  
  15. int_array& operator=(int_array&& rhs)  
  16. {  
  17.     cout << "  int_array& int_array::operator=(int_array&&)" << endl;  
  18.     if (this != &rhs)  
  19.     {  
  20.         // 交換內部數據,這樣等下銷燬的就是這個對象現在的內部數據了,  
  21.         // 從而避免了不必要的拷貝  
  22.         swap(m_buffer, rhs.m_buffer);  
  23.         swap(m_size, rhs.m_size);  
  24.     }  
  25.     return *this;  
  26. }  
  27.   
  28. // 生成一個有兩個元素的int_arry,採用了轉移語義減少拷貝  
  29. int_array new_make_int_array2(int v1, int v2)  
  30. {  
  31.     int_array result(2);    // 調用構造函數  
  32.     result.set_at(0, v1);  
  33.     result.set_at(1, v2);  
  34.   
  35.     // 通過move函數將result對象從左值轉成右值,  
  36.     // 從而調用int_array的轉移構造函數,以減少不必要的拷貝  
  37.     return int_array(std::move(result));  
  38. }  
  39.   
  40. // 生成一個有size個元素的int_arry,採用了轉移語義減少拷貝  
  41. int_array new_make_int_array(unsigned int size, ...)  
  42. {  
  43.     if (size > 10000000)    // 如果太大則返回一個空的  
  44.         // 這裏由於int_array()本事就是一個無名對象,也就是右值,  
  45.         // 從而自動調用了int_array的轉移構造函數,沒有不必要的拷貝  
  46.         return int_array(); // 調用  
  47.   
  48.     int_array result(size); // 調用構造函數  
  49.     va_list args;  
  50.     va_start(args, size);  
  51.     for (unsigned int i = 0; i < size; ++i)  
  52.     {  
  53.         int v = va_arg(args, int);  
  54.         result.set_at(i, v);  
  55.     }  
  56.   
  57.     // 通過move函數將result對象從左值轉成右值,  
  58.     // 從而調用int_array的轉移構造函數,以減少不必要的拷貝  
  59.     return int_array(std::move(result));  
  60. }  
  61.   
  62. void new_example()  
  63. {  
  64.     cout << endl << "enter" << endl;  
  65.   
  66.     cout << endl << "step1" << endl;  
  67.   
  68.     // 如果沒有RVO,應該調用一次構造函數,兩次轉移構造函數,沒有不必要的拷貝  
  69.     // 由於RVO,實際上只調用了一次構造函數,一次轉移構造函數  
  70.     int_array ia1 = new_make_int_array2(10, 20);  
  71.   
  72.     cout << endl << "step2" << endl;  
  73.   
  74.     // 如果沒有RVO,應該調用一次構造函數,一次轉移構造函數,一次交換等於操作符,  
  75.     // 由於RVO,實際上只調用了一次構造函數,一次交換等於操作符,沒有不必要的拷貝  
  76.     ia1 = make_int_array2(100, 200);  
  77.   
  78.     cout << endl << "step3" << endl;  
  79.   
  80.     // 如果沒有RVO,應該調用一次構造函數,兩次轉移構造函數,沒有不必要的拷貝  
  81.     // 由於RVO,實際上只調用了一次構造函數,一次轉移構造函數  
  82.     int_array ia2 = make_int_array(5, 1, 2, 3, 4, 5);  
  83.   
  84.     cout << endl << "step4" << endl;  
  85.   
  86.     // 調用了一次構造函數,一次轉移構造函數,一次交換語義的等於操作符,  
  87.     // 沒有不必要的拷貝  
  88.     ia2 = make_int_array(3, 1, 2, 3);  
  89.   
  90.     cout << endl << "exit" << endl;  
  91. }  


這個示例代碼已經完全消除了不必要的拷貝,之前的性能問題得到了徹底地解決。我們發現代碼裏面用到了std::move()這個函數,接下來我們仔細講解這個函數。

 

瞭解std::move()

很多時候,我們需要把左值引用轉換成右值。原因通常有兩個:一個原因是我們明確知道該左值不久後將被銷燬,而我們需要轉移其中的內部數據到其它對象中,例如上例中new_make_int_array()函數中的用法;另一個原因是由於右值一旦被具名引用,哪怕是具名的右值引用,它也會變成左值,因此需要將它恢復成右值,這個描述很拗口,但的確就是這樣。

標準庫中提供了std::move()這個模板函數,用來幹這個轉換的差事。它接收一個引用,並強制類型轉換成右值引用,然後返回。由於函數返回值是不具名的右值引用,因此它還是右值。具體的實現代碼如下:

  1. template<typename _Tp>  
  2. inline typename std::remove_reference<_Tp>::type&& move(_Tp&& __t)  
  3. {  
  4.     return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);  
  5. }  

 

std::move()從名字上看,通常會產生錯誤的理解,以爲移動臨時對象內部數據的操作是由它完成的。看了代碼就知道了,根本不是那麼回事,它僅僅完成一個語義上的轉換,將左值變成右值而已,而且沒有任何性能損失。由於std::move()的語義是轉成右值,以便接下來被轉移,那麼被傳入到move函數的參數,在move調用過後,就最好不要再訪問了,以免訪問到錯誤的值。

完美轉發(perfect forwarding)

最後我們講一下完美轉發,仔細講起來會比較複雜。簡單來說,在編寫函數模板的過程中,可能需要把模板實參的左右值特性和常量性完美的保持下來,轉發給其它的函數。在C++現行標準中,需要爲每一個左右值特性和常量性作一個函數重載,如果函數只有一個形參,需要至少4個重載,如果函數模板有3個參數,則需要4的3次方=64個重載,因此很難做到統一的解決方案。C++0x通過右值引用,加上引用摺疊(reference collapsing),以及左值引用經過函數模板推導還是左值引用的特殊規定,較好的實現了完美轉發,就是std::forward()這個模板函數。其實現代碼如下:

 

  1. template<class _Ty>  
  2. _Ty&& forward(typename identity<_Ty>::type& _Arg)  
  3. {  
  4.    return ((_Ty&&)_Arg);  
  5. }  

 

引用摺疊(reference collapsing)

補充介紹一下引用摺疊,這是C++0x爲了實現移動語義(move semantics)和完美轉發(perfect forwarding)而增加的規定。簡單羅列一下規則,就很清楚了:
A& &等價於A&

A& &&等價於A&

A&& &等價於A&

A&& &&等價於A&&

總結

1. 右值引用的確帶來了性能提升的可能,也帶來了完美轉發。但性能的提升需要額外的編碼,用來實現轉移和交換行爲。

2. 左值和右值的概念,並不是指等號的左邊還是右邊,而是指該表達式代表的對象是否是持久的,持久的就是左值,反之是右值。我們也可以用是否具名來判斷,具名的是左值,反之是右值(非具名左值引用除外)。

3. std::move()函數用於語義上的轉移,將左值轉成右值,而不是實質上數據的轉移,實質性的轉移需要每個類單獨編碼實現。

4. std::forward()函數實現完美轉發,可用於模板函數中完美的傳遞參數。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章