右值引用的意義!


文章來源:

知乎|作者:Tinro

右值引用是C++11中最重要的新特性之一,它解決了C++中大量的歷史遺留問題,使C++標準庫的實現在多種場景下消除了不必要的額外開銷(如std::vector, std::string),也使得另外一些標準庫(如std::unique_ptr, std::function)成為可能。即使你并不直接使用右值引用,也可以通過標準庫,間接從這一新特性中受益。為了更好的理解標準庫結合右值引用帶來的優化,我們有必要了解一下右值引用的重大意義。


右值引用的意義通常解釋為兩大作用:移動語義和完美轉發。本文主要討論移動語義。


------移動語義------


移動語義,簡單來說解決的是各種情形下對象的資源所有權轉移的問題。而在C++11之前,移動語義的缺失是C++飽受詬病的問題之一。


舉個栗子。


問題一:如何將大象放入冰箱?
答案是眾所周知的。首先你需要有一臺特殊的冰箱,這臺冰箱是為了裝下大象而制造的。你打開冰箱門,將大象放入冰箱,然后關上冰箱門。


問題二:如何將大象從一臺冰箱轉移到另一臺冰箱?
普通解答:打開冰箱門,取出大象,關上冰箱門,打開另一臺冰箱門,放進大象,關上冰箱門。


2B解答:在第二個冰箱中啟動量子復制系統,克隆一只完全相同的大象,然后啟動高能激光將第一個冰箱內的大象氣化消失。


等等,這個2B解答聽起來很耳熟,這不就是C++中要移動一個對象時所做的事情嗎?


“移動”,這是一個三歲小孩都明白的概念。將大象(資源)從一臺冰箱(對象)移動到另一臺冰箱,這個行為是如此自然,沒有任何人會采用先復制大象,再銷毀大象這樣匪夷所思的方法。C++通過拷貝構造函數和拷貝賦值操作符為類設計了拷貝/復制的概念,但為了實現對資源的移動操作,調用者必須使用先復制、再析構的方式。否則,就需要自己實現移動資源的接口。


為了實現移動語義,首先需要解決的問題是,如何標識對象的資源是可以被移動的呢?這種機制必須以一種最低開銷的方式實現,并且對所有的類都有效。C++的設計者們注意到,大多數情況下,右值所包含的對象都是可以安全的被移動的。

右值(相對應的還有左值)是從C語言設計時就有的概念,但因為其如此基礎,也是一個最常被忽略的概念。不嚴格的來說,左值對應變量的存儲位置,而右值對應變量的值本身。C++中右值可以被賦值給左值或者綁定到引用。類的右值是一個臨時對象,如果沒有被綁定到引用,在表達式結束時就會被廢棄。于是我們可以在右值被廢棄之前,移走它的資源進行廢物利用,從而避免無意義的復制。被移走資源的右值在廢棄時已經成為空殼,析構的開銷也會降低。


右值中的數據可以被安全移走這一特性使得右值被用來表達移動語義。以同類型的右值構造對象時,需要以引用形式傳入參數。右值引用顧名思義專門用來引用右值,左值引用和右值引用可以被分別重載,這樣確保左值和右值分別調用到拷貝和移動的兩種語義實現。對于左值,如果我們明確放棄對其資源的所有權,則可以通過std::move()來將其轉為右值引用。std::move()實際上是static_cast<T&&>()的簡單封裝。


右值引用至少可以解決以下場景中的移動語義缺失問題:


  • 按值傳入參數


按值傳參是最符合人類思維的方式。基本的思路是,如果傳入參數是為了將資源交給函數接受者,就應該按值傳參。同時,按值傳參可以兼容任何的cv-qualified左值、右值,是兼容性最好的方式。

class People {public:  People(string name) // 按值傳入字符串,可接收左值、右值。接收左值時為復制,接收右值時為移動  : name_(move(name)) // 顯式移動構造,將傳入的字符串移入成員變量  {  }  string name_;};
People a("Alice"); // 移動構造name
string bn = "Bob";People b(bn); // 拷貝構造name

構造a時,調用了一次字符串的構造函數和一次字符串的移動構造函數。如果使用const string& name接收參數,那么會有一次構造函數和一次拷貝構造,以及一次non-trivial的析構。盡管看起來很蛋疼,盡管編譯器還有優化,但從語義來說按值傳入參數是最優的方式。


如果你要在構造函數中接收std::shared_ptr<X>并且存入類的成員(這是非常常見的),那么按值傳入更是不二選擇。拷貝std::shared_ptr<X>需要線程同步,相比之下移動std::shared_ptr是非常輕松愉快的。


  • 按值返回


和接收輸入參數一樣,返回值按值返回也是最符合人類思維的方式。曾經有無數函數為了返回容器而不得不寫成這樣

void str_split(const string& s, vector<string>* vec); // 一個按值語義定義的字符串拆分函數。這里不考慮分隔符,假定分隔符是固定的。


這樣要求vec在外部被事先構造,此時尚無從得知vec的大小。即使函數內部有辦法預測vec的大小,因為函數并不負責構造vec,很可能仍需要resize。


對這樣的函數嵌套調用更是痛苦的事情,誰用誰知道啊。


有了移動語義,就可以寫成這樣

vector<string> str_split(const string& s) {  vector<string> v;  // ...  return v; // v是左值,但優先移動,不支持移動時仍可復制。}


如果函數按值返回,return語句又直接返回了一個棧上的左值對象(輸入參數除外)時,標準要求優先調用移動構造函數,如果不符再調用拷貝構造函數。盡管v是左值,仍然會優先采用移動語義,返回vector<string>從此變得云淡風輕。此外,無論移動或是拷貝,可能的情況下仍然適用編譯器優化,但語義不受影響。

對于std::unique_ptr來說,這簡直就是福音。

unique_ptr<SomeObj> create_obj(/*...*/) {  unique_ptr<SomeObj> ptr(new SomeObj(/*...*/));  ptr->foo(); // 一些可能的初始化  return ptr;}


當然還有更簡單的形式

unique_ptr<SomeObj> create_obj(/*...*/) {  return unique_ptr<SomeObj>(new SomeObj(/*...*/));}

在工廠類中,這樣的語義是非常常見的。返回unique_ptr能夠明確對所構造對象的所有權轉移,特別的,這樣的工廠類返回值可以被忽略而不會造成內存泄露。上面兩種形式分別返回棧上的左值和右值,但都適用移動語義(unique_ptr不支持拷貝)。


  • 接收右值表達式


沒有移動語義時,以表達式的值(例為函數調用)初始化對象或者給對象賦值是這樣的:

vector<string> str_split(const string& s);
vector<string> v = str_split("1,2,3"); // 返回的vector用以拷貝構造對象v。為v申請堆內存,復制數據,然后析構臨時對象(釋放堆內存)。vector<string> v2;v2 = str_split("1,2,3"); // 返回的vector被復制給對象v(拷貝賦值操作符)。需要先清理v2中原有數據,將臨時對象中的數據復制給v2,然后析構臨時對象。


注:v的拷貝構造調用有可能被優化掉,盡管如此在語義上仍然是有一次拷貝操作。


同樣的代碼,在支持移動語義的世界里就變得更美好了。

vector<string> str_split(const string& s);
vector<string> v = str_split("1,2,3"); // 返回的vector用以移動構造對象v。v直接取走臨時對象的堆上內存,無需新申請。之后臨時對象成為空殼,不再擁有任何資源,析構時也無需釋放堆內存。vector<string> v2;v2 = str_split("1,2,3"); // 返回的vector被移動給對象v(移動賦值操作符)。先釋放v2原有數據,然后直接從返回值中取走數據,然后返回值被析構。

注:v的移動構造調用有可能被優化掉,盡管如此在語義上仍然是有一次移動操作。


不用多說也知道上面的形式是多么常用和自然。而且這里完全沒有任何對右值引用的顯式使用,性能提升卻默默的實現了。


  • 對象存入容器


這個問題和前面的構造函數傳參是類似的。不同的是這里是按兩種引用分別傳參。參見std::vector的push_back函數。


void push_back( const T& value ); // (1)void push_back( T&& value ); // (2)

不用多說自然是左值調用1右值調用2。如果你要往容器內放入超大對象,那么版本2自然是不2選擇。

vector<vector<string>> vv;
vector<string> v = {"123", "456"};v.push_back("789"); // 臨時構造的string類型右值被移動進容器vvv.push_back(move(v)); // 顯式將v移動進vv

困擾多年的難言之隱是不是一洗了之了?


  • std::vector的增長


又一個隱蔽的優化。當vector的存儲容量需要增長時,通常會重新申請一塊內存,并把原來的內容一個個復制過去并刪除。對,復制并刪除,改用移動就夠了。


對于像vector<string>這樣的容器,如果頻繁插入造成存儲容量不可避免的增長時,移動語義可以帶來悄無聲息而且美好的優化。


  • std::unique_ptr放入容器


曾經,由于vector增長時會復制對象,像std::unique_ptr這樣不可復制的對象是無法放入容器的。但實際上vector并不復制對象,而只是“移動”對象。所以隨著移動語義的引入,std::unique_ptr放入std::vector成為理所當然的事情。


容器中存儲std::unique_ptr有太多好處。想必每個人都寫過這樣的代碼:

MyObj::MyObj() {  for (...) {    vec.push_back(new T());  }  // ...}
MyObj::~MyObj() { for (vector<T*>::iterator iter = vec.begin(); iter != vec.end(); ++iter) { if (*iter) delete *iter; } // ...}

繁瑣暫且不說,異常安全也是大問題。使用vector<unique_ptr<T>>,完全無需顯式析構,unqiue_ptr自會打理一切。完全不用寫析構函數的感覺,你造嗎?


unique_ptr是非常輕量的封裝,存儲空間等價于裸指針,但安全性強了一個世紀。實際中需要共享所有權的對象(指針)是比較少的,但需要轉移所有權是非常常見的情況。auto_ptr的失敗就在于其轉移所有權的繁瑣操作。unique_ptr配合移動語義即可輕松解決所有權傳遞的問題。


注:如果真的需要共享所有權,那么基于引用計數的shared_ptr是一個好的選擇。shared_ptr同樣可以移動。由于不需要線程同步,移動shared_ptr比復制更輕量。


  • std::thread的傳遞


thread也是一種典型的不可復制的資源,但可以通過移動來傳遞所有權。同樣std::future std::promise std::packaged_task等等這一票多線程類都是不可復制的,也都可以用移動的方式傳遞。


------完美轉發------


除了移動語義,右值引用還解決了C++03中引用語法無法轉發右值的問題,實現了完美轉發,才使得std::function能有一個優雅的實現。這部分不再展開了。


------總結------


移動語義絕不是語法糖,而是帶來了C++的深刻革新。移動語義不僅僅是針對庫作者的,任何一個程序員都有必要去了解它。盡管你可能不會去主動為自己的類實現移動語義,但卻時時刻刻都在享受移動語義帶來的受益。因此這絕不意味著這是一個可有可無的東西。


C++學習資料免費獲取方法:關注程序喵大人,后臺回復“程序喵”即可免費獲取40萬字C++進階獨家學習資料。





往期推薦


1、少寫點
if-else吧,它的效率有多低你知道嗎?
2、年度原創好文匯總
3、全網首發?。++20新特性全在這一張圖里了
4、
他來了,他來了,C+
+17新特性精華都在這了
5、一文讓你搞懂設計模式
6、C++11新特性,所有知識點都在這了!