鄭重聲明:本文是筆者網上翻譯原文,部分有做添加說明,所有權歸原文作者!
地址:http://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html
C++一直致力於生成快速的程序。不幸的是,直到C++11之前,這裡一直有一個降低C++程序速度的頑症:臨時變量的創建。有時這些臨時變量可以被編譯器優化(例如返回值優化),但是這並不總是可行的,通常這會導致高昂的對象復制成本。我說的是怎麼回事呢?
讓我們一起來看看下面的代碼:
1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 vector<int> doubleValues (const vector<int>& v) 6 { 7 vector<int> new_values( v.size() ); 8 for (auto itr = new_values.begin(), end_itr = new_values.end(); itr != end_itr; ++itr ) 9 { 10 new_values.push_back( 2 * *itr ); 11 } 12 return new_values; 13 } 14 15 int main() 16 { 17 vector<int> v; 18 for ( int i = 0; i < 2; i++ ) 19 { 20 v.push_back( i ); 21 } 22 v = doubleValues( v ); 23 }
(筆者注:代碼中的vector<int> doubleValues (const vector<int>& v)函數是對vector v中的值乘以2,存儲到另外一個vector中並返回。如果我們在第22行添加如下代碼輸出v中的值,會發現v中的值並沒有改變,都是0。
for (auto x : v) cout << x << endl;
應該改成這樣:
1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 vector<int> doubleValues (const vector<int>& v) 6 { 7 vector<int> new_values; 8 for (auto x : v) 9 new_values.push_back(2 * x); 10 return new_values; 11 } 12 13 int main() 14 { 15 vector<int> v; 16 for ( int i = 0; i < 2; i++ ) 17 { 18 v.push_back( i ); 19 } 20 v = doubleValues( v ); 21 }
另外,筆者不建議像原作者這樣使用vector,因為push_back會改變原來vector的內存分布和大小,會出現一些無法預料的錯誤,代碼也不健壯。)
如果你已經做了大量高性能優化工作,很抱歉這個頑症給你帶來的痛苦。如果你並未做此類優化工作,那好,讓我們一起來縷縷為什麼這樣的代碼在C++03是噩夢(接下來的部分是說明為什麼C++11在此方面更好)。該問題與復制變量有關,當doubleValues()函數被調用時,它會構造一個臨時的vector(即new_values),並填充數據。單獨這樣做效率並不高,但是若想保持原始vector的純淨,我們就需要另外一份拷貝。想想doubleValues()函數返回發生了什麼?
new_values中的所有數據必須被重新復制一遍!理論上,這裡可能最多有2次的復制操作:
1、發生在返回的臨時變量;
2、發生在v = doubleValues( v );這裡。
第一次的復制操作可能會被編譯器自動優化掉(即返回值優化),但是第二次在給將臨時變量復制給v時是無法避免的,因為這裡需要重新分配內存空間,並且需要迭代整個vector。
這裡的例子可能有些小題大做。當然,你可以通過其他方法避免這種問題,比如通過指針或者傳遞一個已經填充的vector。事實這兩種編程方法都是合情合理的。此外返回一個指針的方法至少需要一次的內存分配,避免內存分配也是C++設計目標之一。
最糟糕的是,在整個過程中函數doubleValues()返回的值是一個不再需要的臨時變量。當執行到v = doubleValues( v )這裡時,復制操作一旦完成,v = doubleValues( v )的結果就將被丟棄。理論上是可以避免整個復制過程,僅僅將臨時vector的指針保存到v中。實際上,我們為什麼不移動對象呢?在C++03中,無論對象是否為臨時的,我們都不得不在復制操作符=或復制構造函數中運行相同的代碼,不管該值來之哪裡,所以這裡”偷竊(pilfering)”是不可能的。在C++11這種行為是可以的!
這就是右值和move語義!當你在使用會被丟棄的臨時變量時,move語義能為你避免不必要的復制拷貝,並且這些來自臨時變量的資源能夠被用於其他地方。move語義是C++11新的特性,被稱為右值引用,你也想明白這能為程序員們帶來怎樣的好處。首先我們先來說說什麼是右值,然後說說什麼是右值引用,最後我們將回到move語義,並看看右值引用是如何實現的。
在C++中有左值和右值之分。左值就是一個可以獲取地址的表達式,即一個內存地址定位器地址-本質上,一個左值能夠提供一個半永久的內存。我們可以給左值賦值,例如:
1 int a; 2 a = 1; // here, a is an lvalue
也可以使左值不是變量,如:
1 int x; 2 int& getRef () 3 { 4 return x; 5 } 6 7 getRef() = 4;
這裡getRef()返回一個全局變量的引用,所以它的返回值是被存儲在內存中的永久位置處。你可以像使用普通的變量一樣來使用getRef()。
如果一個表達式返回一個臨時變量,則該表達式是右值。例如:
1 int x; 2 int getVal () 3 { 4 return x; 5 } 6 getVal();
這裡getVal()是右值,因為返回值x不是全局變量x的引用,僅僅是一個臨時變量。如果我們用對象而不是數字,這將有點意思,如:
1 string getName () 2 { 3 return "Alex"; 4 } 5 getName();
getName()返回一個在函數內部構造的string對象,你可以將其賦值給變量:
string name = getName();
此時你正在使用臨時變量,getName()是右值。
1 const string& name = getName(); // ok 2 string& name = getName(); // NOT ok
顯而易見這裡不能使用一個“可變(mutable)”引用,因為如果這麼做了,你將可以修改即將銷毀的對象,這是相當危險的。順便提醒一下,將臨時對象保存在const引用中可以確保該臨時對象不會被立刻銷毀。這一個好的C++編程習慣,但是它仍然是一個臨時對象,不能夠被修改。
然而在C++11中,引進了一種新的引用,即“右值引用”,允許綁定一個可變引用到一個右值,不是左值。換句話說,右值引用專注於檢測一個值是否為臨時對象。右值使用&&語法而不是&,可以是const和非const的,就像左值引用一樣,盡管你很少看到const左值引用。
1 const string&& name = getName(); // ok 2 string&& name = getName(); // also ok - praise be!
到目前為止一切都運行良好,但這是如何實現的?左值引用和右值引用最重要的區別,是用著函數參數的左值和右值。看看如下兩個函數:
1 printReference (const String& str) 2 { 3 cout << str; 4 } 5 6 printReference (String&& str) 7 { 8 cout << str; 9 }
這裡函數printReference()的行為就有意思了:printReference (const String& str)接受任何參數,左值和右值都可以,不管左值或右值是否為可變。printReference (String&& str)接受除可變右值引用的任何參數。換句話說,如下寫:
1 string me( "alex" ); 2 printReference( me ); // calls the first printReference function, taking an lvalue reference 3 printReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference
現在我們應該有一種方法來確定是否對臨時對象或非臨時對象使用引用。右值引用版本的方法就像進入俱樂部(無聊的俱樂部,我猜的)的秘密後門,如果是臨時對象,則只能進。既然我們有方法確定一個對象是否為臨時對象,哪我們該如何使用呢?
當你使用右值引用時,最常見的模式是創建move構造函數和move賦值操作符(遵循相同的原則)。move構造函數,跟拷貝構造函數一樣,以一個實例對象作為參數創建一個新的基於原始實例對象的實例。然後move構造函數可以避免內存分配,因為我們知道它已經提供了一個臨時對象,而不是復制整個對象,只是“移動”而已。假如我們有一個簡單的ArrayWrapper類,如下:
1 class ArrayWrapper 2 { 3 public: 4 ArrayWrapper (int n) 5 : _p_vals( new int[ n ] ) 6 , _size( n ) 7 {} 8 // copy constructor 9 ArrayWrapper (const ArrayWrapper& other) 10 : _p_vals( new int[ other._size ] ) 11 , _size( other._size ) 12 { 13 for ( int i = 0; i < _size; ++i ) 14 { 15 _p_vals[ i ] = other._p_vals[ i ]; 16 } 17 } 18 ~ArrayWrapper () 19 { 20 delete [] _p_vals; 21 } 22 private: 23 int *_p_vals; 24 int _size; 25 };
注意,這裡的復制拷貝構造函數每次都會分配內存和復制數組中的每個元素。對於復制操作是如此龐大的工作量,讓我們來添加move拷貝構造函數,獲得高效的性能。
1 class ArrayWrapper 2 { 3 public: 4 // default constructor produces a moderately sized array 5 ArrayWrapper () 6 : _p_vals( new int[ 64 ] ) 7 , _size( 64 ) 8 {} 9 10 ArrayWrapper (int n) 11 : _p_vals( new int[ n ] ) 12 , _size( n ) 13 {} 14 15 // move constructor 16 ArrayWrapper (ArrayWrapper&& other) 17 : _p_vals( other._p_vals ) 18 , _size( other._size ) 19 { 20 other._p_vals = NULL; 21 } 22 23 // copy constructor 24 ArrayWrapper (const ArrayWrapper& other) 25 : _p_vals( new int[ other._size ] ) 26 , _size( other._size ) 27 { 28 for ( int i = 0; i < _size; ++i ) 29 { 30 _p_vals[ i ] = other._p_vals[ i ]; 31 } 32 } 33 ~ArrayWrapper () 34 { 35 delete [] _p_vals; 36 } 37 38 private: 39 int *_p_vals; 40 int _size; 41 };
實際上move構造函數比copy構造函數更簡單,這是相當不錯的。主要注意以下兩點:
1、參數是非const的右值引用
2、other._p_vals應置為NULL
以上的第2點是對第1點的解釋,即如果我們使用const右值引用,則不能將other._p_vals置為NULL。但為什麼要將other._p_vals置為NULL呢?原因在於析構函數,當臨時對象離開其作用域,就像所有其他C++對象一樣,它們的析構函數都會被調用。當析構函數被調用後, _p_vals將被釋放。這裡我們只是復制了_p_vals,如果我們不將_p_vals置為NULL,move就不是真正的“移動”,而是復制,一旦我們使用已釋放的內存就會引發運行奔潰。move構造函數的意義在於,通過改變原始的臨時對象來避免復制操作。
再次重復,重載move構造函數是為了僅當為臨時對象時move構造函數才會被調用,只有臨時對象才能被修改。這意味著,如果函數的返回值是const對象,將調用copy構造函數,而不是move構造函數,所以不要像這樣寫:
1 const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!
有些情況如何在move構造函數中我們還沒有討論,如類中某個字段也是對象。觀察如下這個類:
1 class MetaData 2 { 3 public: 4 MetaData (int size, const std::string& name) 5 : _name( name ) 6 , _size( size ) 7 {} 8 9 // copy constructor 10 MetaData (const MetaData& other) 11 : _name( other._name ) 12 , _size( other._size ) 13 {} 14 15 // move constructor 16 MetaData (MetaData&& other) 17 : _name( other._name ) 18 , _size( other._size ) 19 {} 20 21 std::string getName () const { return _name; } 22 int getSize () const { return _size; } 23 private: 24 std::string _name; 25 int _size; 26 };
我們的數組有字段name和size,因此我們應該改變ArrayWrapper的定義,如下:
1 class ArrayWrapper 2 { 3 public: 4 // default constructor produces a moderately sized array 5 ArrayWrapper () 6 : _p_vals( new int[ 64 ] ) 7 , _metadata( 64, "ArrayWrapper" ) 8 {} 9 10 ArrayWrapper (int n) 11 : _p_vals( new int[ n ] ) 12 , _metadata( n, "ArrayWrapper" ) 13 {} 14 15 // move constructor 16 ArrayWrapper (ArrayWrapper&& other) 17 : _p_vals( other._p_vals ) 18 , _metadata( other._metadata ) 19 { 20 other._p_vals = NULL; 21 } 22 23 // copy constructor 24 ArrayWrapper (const ArrayWrapper& other) 25 : _p_vals( new int[ other._metadata.getSize() ] ) 26 , _metadata( other._metadata ) 27 { 28 for ( int i = 0; i < _metadata.getSize(); ++i ) 29 { 30 _p_vals[ i ] = other._p_vals[ i ]; 31 } 32 } 33 ~ArrayWrapper () 34 { 35 delete [] _p_vals; 36 } 37 private: 38 int *_p_vals; 39 MetaData _metadata; 40 };
這樣就可以了?僅僅在ArrayWrapper中調用MetaData的move構造函數就可以了,一切都很自然,不是麼?問題在於這樣做是不行的!原因很簡單:move構造函數中的other是右值引用。這裡應該是右值,而不是右值引用!如果是左值,則調用copy構造函數,而不是move構造函數。有些奇怪,有點繞,對吧-我知道。這裡有種方法可以區分:右值就是一個創建稍後會被銷毀的表達式。臨時對象即將被銷毀時,我們將其傳入move構造函數中,就相當於給了它第二次生命,在新的作用域仍然有效。文中右值出現的地方,都是這麼做的。在我們的構造函數裡,對象有一個name字段,它在函數內部一直有效。換句話說,我們可以在函數中使用它多次,函數內部定義的臨時變量在該函數內部一直有效。左值是可以被定位的,我們可以在內存某個位置訪問一個左值。實際上,在函數中我們可能想稍後再使用它。如果move構造被調用,這時我們就有一個右值引用對象,就可以使用“移動的”對象了。
1 // move constructor 2 ArrayWrapper (ArrayWrapper&& other) 3 : _p_vals( other._p_vals ) 4 , _metadata( other._metadata ) 5 { 6 // if _metadata( other._metadata ) calls the move constructor, using 7 // other._metadata here would be extremely dangerous! 8 other._p_vals = NULL; 9 }
最後一種情況:左值和右值引用都是左值表達式。不用之處在於,左值引用必須是const綁定到右值,然而右值引用總是可以綁定一個引用到右值上。類似於指針和指針所指向的內容的區別。使用的值來至於右值,但是當我們使用右值本身時,它又成為左值。
那麼有什麼技巧可以處理這樣的情況?我們可以使用std::move,包含在<utility>中。如果你想將左值轉換為右值,可以使用std::move,這裡std::move本身並不移動任何東西,它只是將左值轉換成右值而已,也可以調用move構造函數來實現。請看如下代碼:
1 #include <utility> // for std::move 2 // move constructor 3 ArrayWrapper (ArrayWrapper&& other) 4 : _p_vals( other._p_vals ) 5 , _metadata( std::move( other._metadata ) ) 6 { 7 other._p_vals = NULL; 8 }
同樣的,也應該修改MetaData:
1 MetaData (MetaData&& other) 2 : _name( std::move( other._name ) ) // oh, blissful efficiency 3 : _size( other._size ) 4 {}
如同move構造函數一樣,我們也應該有一個move賦值操作符,編寫方式跟move構造函數一樣。
正如你所知道的,在C++中只要你手動聲明了構造函數,編譯器就不會再為你產生默認的構造函數了。這裡也是如此:為類添加move構造函數要求你定義和聲明一個默認構造函數。另外,聲明move構造函數並不會阻止編譯器為你產生隱式的copy構造函數,聲明move賦值操作符也不會阻止編譯器創建標准的賦值操作符。
你或許會疑惑:如何編寫一個類似與std::move這樣的函數?右值引用轉換為左值引用是如何實現的?可能你已經猜到答案了,就是typecasting。std::move的實際聲明比較復雜,但其核心思想就是static_cast到右值引用。這就意味著,實際上你並不真的需要使用move——但你應該這樣做,這樣能夠更清楚表達你的意思。實際上轉換是必要,是件好事,這樣可以防止你意外地將左值轉換為右值,因為那樣將導致意外的move發生,是相當危險的。你必須顯示地使用std::move(或者一個轉換)將左值轉換為右值引用,右值引用不會綁定它自己的左值上。
什麼時候時候你應該寫一個返回一個右值引用的函數?函數返回右值引用意味著什麼呢?通過值返回對象的函數是不是就已經是右值了?
我們先回答第二個問題:返回顯式的右值引用與通過值(by value)返回對象是不同的。讓我們看看下面的例子:
1 int x; 2 3 int getInt () 4 { 5 return x; 6 } 7 8 int && getRvalueInt () 9 { 10 // notice that it's fine to move a primitive type--remember, std::move is just a cast 11 return std::move( x ); 12 }
明顯在第一種情況裡,盡管事實上getInt()是右值,但這裡仍然對x執行了copy操作。我們可以寫個輔助函數,看看:
1 void printAddress (const int& v) // const ref to allow binding to rvalues 2 { 3 cout << reinterpret_cast<const void*>( & v ) << endl; 4 } 5 6 printAddress( getInt() ); 7 printAddress( x );
運行發現,二者打印的x地址明顯不同。另一方面:
1 printAddress( getRvalueInt() ); 2 printAddress( x );
打印的x地址是相同的,這是因為getRvalueInt()顯式的返回了一個右值。
所以返回右值引用與不返回右值引用明顯是不同的。如果你返回已經存在的對象,而不是在函數內部創建的臨時對象(編譯器可能會為你做返回值優化,避免copy操作)時,這種不同表現得最為明顯。
現在的問題是,你是否需要這麼做。答案是:很可能不會。大多數情況下,你最有可能得到一個懸空的(dangling)右值(一種情況是:引用存在,但它引用的臨時對象已經銷毀了)。這種情況的危險程度類似於被引用的對象已經不存在的左值。右值引用並不總是可以保證對象有效。返回右值引用主要使這種特殊情況有意義:你有一個成員函數,該函數通過std::move返回類中的字段。
回到最開始的例子中,我們正在使用vector,但未控制類vector,也不知道vector是否move構造函數或move賦值操作符。幸運地是標准委員會已經將move語義添加到了標准庫中,這意味著你可以高效地返回vectors, maps, strings,以及你想要返回的任何標准庫對象,充分利用move語義吧。
事實上,標准庫做得更加地好了。如果你在你的對象中通過創建move構造函數和move賦值操作符來使用move語義,當你將這些對象存儲在STL容器中,STL將自動使用std::move,充分利用move語義為你避免效率底下的copy操作。