詳解C++右值引用


C++0x標准出來很長時間了,引入了很多牛逼的特性[1]。其中一個便是右值引用,Thomas Becker的文章[2]很全面的介紹了這個特性,讀后有如醍醐灌頂,翻譯在此以便深入理解。

目錄

  1. 概述
  2. move語義
  3. 右值引用
  4. 強制move語義
  5. 右值引用是右值嗎?
  6. move語義與編譯器優化
  7. 完美轉發:問題
  8. 完美轉發:解決方案
  9. Rvalue References And Exceptions
  10. The Case of the Implicit Move
  11. Acknowledgments and Further Reading

概述

右值引用是由C++0x標准引入c++的一個令人難以捉摸的特性。

右值引用惡心的地方在於,當你看到它的時候根本不知道它的存在有什么意義,它是用來解決什么問題的。所以我不會馬上介紹什么是右值引用。更好的方式是從它將解決的問題入手,然后講述右值引用是如何解決這些問題的。這樣,右值引用的定義才會看起來合理和自然。

右值引用至少解決了這兩個問題:

  1. 實現move語義
  2. 完美轉發(Perfect forwarding)

如果你不懂這兩個問題,別擔心,后面會詳細地介紹。我們會從move語義開始,但在開始之前要首先讓你回憶起c++的左值和右值是什么。關於左值和右值我很難給出一個嚴密的定義,不過下面的解釋已經足以讓你明白什么是左值和右值。

在c語言發展的較早時期,左值和右值的定義是這樣的:左值是一個可以出現在賦值運算符的左邊或者右邊的表達式e,而右值則是只能出現在右邊的表達式。例如:

 1 int a = 42;                                                
 2 int b = 43;                                                
 3 
 4 // a與b都是左值                              
 5 a = b; // ok                                                
 6 b = a; // ok                                                
 7 a = a * b; // ok                                            
 8 
 9 // a * b是右值:                                      
10 int c = a * b; // ok, 右值在等號右邊
11 a * b = 42; // 錯誤,右值在等號左邊

在c++中,我們仍然可以用這個直觀的辦法來區分左值和右值。不過,c++中的用戶自定義類型引入了關於可變性和可賦值性的微妙變化,這會讓這個方法變的不那么地正確。我們沒有必要繼續深究下去,這里還有另外一種定義可以讓你很好的處理關於右值的問題:左值是一個指向某內存空間的表達式,並且我們可以用&操作符獲得該內存空間的地址。右值就是非左值的表達式。例如:

 1 // 左值:                                                        
 2 //                                                                
 3 int i = 42;                                                        
 4 i = 43; // ok, i是左值
 5 int* p = &i; // ok, i是左值
 6 int& foo();                                                        
 7 foo() = 42; // ok, foo()是左值
 8 int* p1 = &foo(); // ok, foo()是左值
 9 
10 // 右值:                                                        
11 //                                                                
12 int foobar();                                                      
13 int j = 0;                                                        
14 j = foobar(); // ok, foobar()是右值
15 int* p2 = &foobar(); // 錯誤,不能取右值的地址
16 j = 42; // ok, 42是右值

如果你對左值和右值的嚴密的定義有興趣的話,可以看下Mikael Kilpeläinen的文章[3]。

move語義

假設class X包含一個指向某資源的指針或句柄m_pResource。這里的資源指的是任何需要耗費一定的時間去構造、復制和銷毀的東西,比如說以動態數組的形式管理一系列的元素的std::vector。邏輯上而言X的賦值操作符應該像下面這樣:

1 X& X::operator=(X const & rhs)
2 {
3   // [...]
4   // 銷毀m_pResource指向的資源
5   // 復制rhs.m_pResource所指的資源,並使m_pResource指向它
6   // [...]
7 }

同樣X的拷貝構造函數也是這樣。假設我們這樣來用X:

1 X foo(); // foo是一個返回值為X的函數
2 X x;
3 x = foo();

最后一行有如下的操作:

  1. 銷毀x所持有的資源
  2. 復制foo返回的臨時對象所擁有的資源
  3. 銷毀臨時對象,釋放其資源

上面的過程是可行的,但是更有效率的辦法是直接交換x和臨時對象中的資源指針,然后讓臨時對象的析構函數去銷毀x原來擁有的資源。換句話說,當賦值操作符的右邊是右值的時候,我們希望賦值操作符被定義成下面這樣:

1 // [...]
2 // swap m_pResource and rhs.m_pResource
3 // [...]

這就是所謂的move語義。在之前的c++中,這樣的行為是很難實現的。雖然我也聽到有的人說他們可以用模版元編程來實現,但是我還從來沒有遇到過能給我解釋清楚如何具體實現的人。所以這一定是相當復雜的。C++0x通過重載的辦法來實現:

1 X& X::operator=(<mystery type> rhs)
2 {
3   // [...]
4   // swap this->m_pResource and rhs.m_pResource
5   // [...]  
6 }

既然我們是要重載賦值運算符,那么<mystery type>肯定是引用類型。另外我們希望<mystery type>具有這樣的行為:現在有兩種重載,一種參數是普通的引用,另一種參數是<mystery type>,那么當參數是個右值時就會選擇<mystery type>,當參數是左值是還是選擇普通的引用類型。

把上面的<mystery type>換成右值引用,我們終於看到了右值引用的定義。

右值引用

如果X是一種類型,那么X&&就叫做X的右值引用。為了更好的區分兩,普通引用現在被稱為左值引用。

右值引用和左值引用的行為差不多,但是有幾點不同,最重要的就是函數重載時左值使用左值引用的版本,右值使用右值引用的版本:

1 void foo(X& x); // 左值引用重載
2 void foo(X&& x); // 右值引用重載
3 
4 X x;
5 X foobar();
6 
7 foo(x); // 參數是左值,調用foo(X&)
8 foo(foobar()); // 參數是右值,調用foo(X&&)

重點在於:

右值引用允許函數在編譯期根據參數是左值還是右值來建立分支。

理論上確實可以用這種方式重載任何函數,但是絕大多數情況下這樣的重載只出現在拷貝構造函數和賦值運算符中,以用來實現move語義:

1 X& X::operator=(X const & rhs); // classical implementation
2 X& X::operator=(X&& rhs)
3 {
4   // Move semantics: exchange content between this and rhs
5   return *this;
6 }

實現針對右值引用重載的拷貝構造函數與上面類似。

如果你實現了void foo(X&);,但是沒有實現void foo(X&&);,那么和以前一樣foo的參數只能是左值。如果實現了void foo(X const &);,但是沒有實現void foo(X&&);,仍和以前一樣,foo的參數既可以是左值也可以是右值。唯一能夠區分左值和右值的辦法就是實現void foo(X&&);。最后,如果只實現了實現void foo(X&&);,但卻沒有實現void foo(X&);void foo(X const &);,那么foo的參數將只能是右值。

強制move語義

c++的第一版修正案里有這樣一句話:“C++標准委員會不應該制定一條阻止程序員拿起槍朝自己的腳丫子開火的規則。”嚴肅點說就是c++應該給程序員更多控制的權利,而不是擅自糾正他們的疏忽。於是,按照這種思想,C++0x中既可以在右值上使用move語義,也可以在左值上使用,標准程序庫中的函數swap就是一個很好的例子。這里假設X就是前面我們已經重載右值引用以實現move語義的那個類。

 1 template<class T>
 2 void swap(T& a, T& b)
 3 {
 4   T tmp(a);
 5   a = b;
 6   b = tmp;
 7 }
 8 
 9 X a, b;
10 swap(a, b);

上面的代碼中沒有右值,所以沒有使用move語義。但move語義用在這里最合適不過了:當一個變量(a)作為拷貝構造函數或者賦值的來源時,這個變量要么就是以后都不會再使用,要么就是作為賦值操作的目標(a = b)。

C++11中的標准庫函數std::move可以解決我們的問題。這個函數只會做一件事:把它的參數轉換為一個右值並且返回。C++11中的swap函數是這樣的:

 1 template<class T>
 2 void swap(T& a, T& b)
 3 {
 4   T tmp(std::move(a));
 5   a = std::move(b);
 6   b = std::move(tmp);
 7 }
 8 
 9 X a, b;
10 swap(a, b);

現在的swap使用了move語義。值得注意的是對那些沒有實現move語義的類型來說(沒有針對右值引用重載拷貝構造函數和賦值操作符),新的swap仍然和舊的一樣。

std::move是個很簡單的函數,不過現在我還不能將它的實現展現給你,后面再詳細說明。

像上面的swap函數一樣,盡可能的使用std::move會給我們帶來以下好處:

  • 對那些實現了move語義的類型來說,許多標准庫算法和操作會得到很大的性能上的提升。例如就地排序:就地排序算法基本上只是在交換容器內的對象,借助move語義的實現,交換操作會快很多。
  • stl通常對某種類型的可復制性有一定的要求,比如要放入容器的類型。其實仔細研究下,大多數情況下只要有可移動性就足夠了。所以我們可以在一些之前不可復制的類型不被允許的情況下,用一些不可復制但是可以移動的類型(unique_ptr)。這樣的類型是可以作為容器元素的。

右值引用是右值嗎?

假設有以下代碼:

1 void foo(X&& x)
2 {
3   X anotherX = x;
4   // ...
5 }

現在考慮一個有趣的問題:在foo函數內,哪個版本的X拷貝構造函數會被調用呢?這里的x是右值引用類型。把x也當作右值來處理看起來貌似是正確的,也就是調用這個拷貝構造函數:

1 X(X&& rhs);

有些人可能會認為一個右值引用本身就是右值。但右值引用的設計者們采用了一個更微妙的標准:

1 右值引用類型既可以被當作左值也可以被當作右值,判斷的標准是,如果它有名字,那就是左值,否則就是右值。

在上面的例子中,因為右值引用x是有名字的,所以x被當作左值來處理。

1 void foo(X&& x)
2 {
3   X anotherX = x; // 調用X(X const & rhs)
4 }

下面是一個沒有名字的右值引用被當作右值處理的例子:

1 X&& goo();
2 X x = goo(); // 調用X(X&& rhs),goo的返回值沒有名字

之所以采用這樣的判斷方法,是因為:如果允許悄悄地把move語義應用到有名字的東西(比如foo中的x)上面,代碼會變得容易出錯和讓人迷惑。

1 void foo(X&& x)
2 {
3   X anotherX = x;
4   // x仍然在作用域內
5 }

這里的x仍然是可以被后面的代碼所訪問到的,如果把x作為右值看待,那么經過X anotherX = x;后,x的內容已經發生變化。move語義的重點在於將其應用於那些不重要的東西上面,那些move之后會馬上銷毀而不會被再次用到的東西上面。所以就有了上面的准則:如果有名字,那么它就是左值。

那另外一半,“如果沒有名字,那它就是右值”又如何理解呢?上面goo()的例子中,理論上來說goo()所引用的對象也可能在X x = goo();后被訪問的到。但是回想一下,這種行為不正是我們想要的嗎?我們也想隨心所欲的在左值上面使用move語義。正是“如果沒有名字,那它就是右值”的規則讓我們能夠實現強制move語義。其實這就是std::move的原理。這里展示std::move的具體實現還是太早了點,不過我們離理解std::move更近了一步。它什么都沒做,只是把它的參數通過右值引用的形式傳遞下去。

std::move(x)的類型是右值引用,而且它也沒有名字,所以它是個右值。因此std::move(x)正是通過隱藏名字的方式把它的參數變為右值。

下面這個例子將展示記住“如果它有名字”的規則是多么重要。假設你寫了一個類Base,並且通過重載拷貝構造函數和賦值操作符實現了move語義:

1 Base(Base const & rhs); // non-move semantics
2 Base(Base&& rhs); // move semantics

然后又寫了一個繼承自Base的類Derived。為了保證Derived對象中的Base部分能夠正確實現move語義,必須也重載Derived類的拷貝構造函數和賦值操作符。先讓我們看下拷貝構造函數(賦值操作符的實現類似),左值版本的拷貝構造函數很直白:

1 Derived(Derived const & rhs)
2   : Base(rhs)
3 {
4   // Derived-specific stuff
5 }

但右值版本的重載卻要仔細研究下,下面是某個不知道“如果它有名字”規則的程序員寫的:

1 Derived(Derived&& rhs)
2   : Base(rhs) // 錯誤:rhs是個左值
3 {
4   // ...
5 }

如果像上面這樣寫,調用的永遠是Base的非move語義的拷貝構造函數。因為rhs有名字,所以它是個左值。但我們想要調用的卻是move語義的拷貝構造函數,所以應該這么寫:

1 Derived(Derived&& rhs)
2   : Base(std::move(rhs)) // good, calls Base(Base&& rhs)
3 {
4   // Derived-specific stuff
5 }

move語義與編譯器優化

現在有這么一個函數:

1 X foo()
2 {
3   X x;
4   // perhaps do something to x
5   return x;
6 }

一看到這個函數,你可能會說,咦,這個函數里有一個復制的動作,不如讓它使用move語義:

1 X foo()
2 {
3   X x;
4   // perhaps do something to x
5   return std::move(x); // making it worse!
6 }

很不幸的是,這樣不但沒有幫助反而會讓它變的更糟。現在的編譯器基本上都會做返回值優化(return value optimization)。也就是說,編譯器會在函數返回的地方直接創建對象,而不是在函數中創建后再復制出來。很明顯,這比move語義還要好一點。

所以,為了更好的使用右值引用和move語義,你得很好的理解現在編譯器的一些特殊效果,比如return value optimization和copy elision。並且在運用右值引用和move語義時將其考慮在內。Dave Abrahams就這一主題寫了一系列的文章[4]。

完美轉發:問題

除了實現move語義之外,右值引用要解決的另一個問題就是完美轉發問題(perfect forwarding)。假設有下面這樣一個工廠函數:

1 template<typename T, typename Arg>
2 shared_ptr<T> factory(Arg arg)
3 {
4   return shared_ptr<T>(new T(arg));
5 }

很明顯,這個函數的意圖是想把參數arg轉發給T的構造函數。對參數arg而言,理想的情況是好像factory函數不存在一樣,直接調用構造函數,這就是所謂的“完美轉發”。但真實情況是這個函數是錯誤的,因為它引入了額外的通過值的函數調用,這將不適用於那些以引用為參數的構造函數。

最常見的解決方法,比如被boost::bind采用的,就是讓外面的函數以引用作為參數。

1 template<typename T, typename Arg>
2 shared_ptr<T> factory(Arg& arg)
3 {
4   return shared_ptr<T>(new T(arg));
5 }

這樣確實會好一點,但不是完美的。現在的問題是這個函數不能接受右值作為參數:

1 factory<X>(hoo()); // error if hoo returns by value
2 factory<X>(41); // error

這個問題可以通過一個接受const引用的重載解決:

1 template<typename T, typename Arg>
2 shared_ptr<T> factory(Arg const & arg)
3 {
4   return shared_ptr<T>(new T(arg));
5 }

這個辦法仍然有兩個問題。首先如果factory函數的參數不是一個而是多個,那就需要針對每個參數都要寫const引用和non-const引用的重載。代碼會變的出奇的長。

其次這種辦法也稱不上是完美轉發,因為它不能實現move語義。factory內的構造函數的參數是個左值(因為它有名字),所以即使構造函數本身已經支持,factory也無法實現move語義。

右值引用可以很好的解決上面這些問題。它使得不通過重載而實現真正的完美轉發成為可能。為了弄清楚是如何實現的,我們還需要再掌握兩個右值引用的規則。

完美轉發:解決方案

第一條右值引用的規則也會影響到左值引用。回想一下,在c++11標准之前,是不允許出現對某個引用的引用的:像A& &這樣的語句會導致編譯錯誤。不同的是,在c++11標准里面引入了引用疊加規則:

1 A& & => A&
2 A& && => A&
3 A&& & => A&
4 A&& && => A&&

另外一個是模版參數推導規則。這里的模版是接受一個右值引用作為模版參數的函數模版。

1 template<typename T>
2 void foo(T&&);

針對這樣的模版有如下的規則:

  1. 當函數foo的實參是一個A類型的左值時,T的類型是A&。再根據引用疊加規則判斷,最后參數的實際類型是A&。
  2. 當foo的實參是一個A類型的右值時,T的類型是A。根據引用疊加規則可以判斷,最后的類型是A&&。

有了上面這些規則,我們可以用右值引用來解決前面的完美轉發問題。下面是解決的辦法:

1 template<typename T, typename Arg>
2 shared_ptr<T> factory(Arg&& arg)
3 {
4   return shared_ptr<T>(new T(std::forward<Arg>(arg)));
5 }

std::forward的定義如下:

1 template<class S>
2 S&& forward(typename remove_reference<S>::type& a) noexcept
3 {
4   return static_cast<S&&>(a);
5 }

上面的程序是如何解決完美轉發的問題的?我們需要討論當factory的參數是左值或右值這兩種情況。假設A和X是兩種類型。先來看factory的參數是X類型的左值時的情況:

1 X x;
2 factory<A>(x);

根據上面的規則可以推導得到,factory的模版參數Arg變成了X&,於是編譯器會像下面這樣將模版實例化:

1 shared_ptr<A> factory(X& && arg)
2 {
3   return shared_ptr<A>(new A(std::forward<X&>(arg)));
4 }
5 
6 X& && forward(remove_reference<X&>::type& a) noexcept
7 {
8   return static_cast<X& &&>(a);
9 }

應用前面的引用疊加規則並且求得remove_reference的值后,上面的代碼又變成了這樣:

1 shared_ptr<A> factory(X& arg)
2 {
3   return shared_ptr<A>(new A(std::forward<X&>(arg)));
4 }
5 
6 X& std::forward(X& a)
7 {
8   return static_cast<X&>(a);
9 }

這對於左值來說當然是完美轉發:通過兩次中轉,參數arg被傳遞給了A的構造函數,這兩次中轉都是通過左值引用完成的。

現在再考慮參數是右值的情況:

1 X foo();
2 factory<A>(foo());

再次根據上面的規則推導得到:

1 shared_ptr<A> factory(X&& arg)
2 {
3   return shared_ptr<A>(new A(std::forward<X>(arg)));
4 }
5 
6 X&& forward(X& a) noexcept
7 {
8   return static_cast<X&&>(a);
9 }

對右值來說,這也是完美轉發:參數通過兩次中轉被傳遞給A的構造函數。另外對A的構造函數來說,它的參數是個被聲明為右值引用類型的表達式,並且它還沒有名字。那么根據第5節中的規則可以判斷,它就是個右值。這意味着這樣的轉發完好的保留了move語義,就像factory函數並不存在一樣。

事實上std::forward的真正目的在於保留move語義。如果沒有std::forward,一切都是正常的,但有一點除外:A的構造函數的參數是有名字的,那這個參數就只能是個左值。

如果你想再深入挖掘一點的話,不妨問下自己這個問題:為什么需要remove_reference?答案是其實根本不需要。如果把remove_reference<S>::type&換成S&,一樣可以得出和上面相同的結論。但是這一切的前提是我們指定Arg作為std::forward的模版參數。remove_reference存在的原因就是強迫我們去這樣做。

已經講的差不多了,剩下的就是std::move的實現了。記住,std::move的用意在於將它的參數傳遞下去,將它轉換成右值。

1 template<class T>
2 typename remove_reference<T>::type&&
3 std::move(T&& a) noexcept
4 {
5   typedef typename remove_reference<T>::type&& RvalRef;
6   return static_cast<RvalRef>(a);
7 }

下面假設我們針對一個X類型的左值調用std::move。

1 X x;
2 std::move(x);

根據前面的模版參數推導規則,模版參數T變成了X&,於是:

1 typename remove_reference<X&>::type&&
2 std::move(X& && a) noexcept
3 {
4   typedef typename remove_reference<X&>::type&& RvalRef;
5   return static_cast<RvalRef>(a);
6 }

然后求得remove_reference的值,並應用引用疊加規則,得到:

1 X&& std::move(X& a) noexcept
2 {
3   return static_cast<X&&>(a);
4 }

這就可以了,x變成了沒有名字的右值引用。

參數是右值的情況由你來自己推導。不過你可能馬上就想跳過去了,為什么會有人把std::move用在右值上呢?它的功能不就是把參數變成右值么。另外你可能也注意到了,我們完全可以用static_cast<X&&>(x)來代替std::move(x),不過大多數情況下還是用std::move(x)比較好。

參考

  1. C++11 from wikipedia
  2. C++ Rvalue References Explained
  3. Lvalues and Rvalues
  4. RValue References: Moving Forward»
  5. A Brief Introduction to Rvalue References
  6. C++11 標准新特性: 右值引用與轉移語義
  7. C++11 完美轉發
  8. 《C++0x漫談》系列之:右值引用(或“move語意與完美轉發”)(下)

 

from:http://blog.csdn.net/renwotao2009/article/details/46335859


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM