[譯]詳解C++右值引用

 

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

目錄

  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++的一個使人難以捉摸的特性。我曾偶爾聽到過有c++領域的大牛這麼說:html

每次我想抓住右值引用的時候,它總能從我手裏跑掉。c++

想把右值引用裝進腦殼實在太難了。程序員

我不得不教別人右值引用,這太可怕了。算法

右值引用噁心的地方在於,當你看到它的時候根本不知道它的存在有什麼意義,它是用來解決什麼問題的。因此我不會立刻介紹什麼是右值引用。更好的方式是從它將解決的問題入手,而後講述右值引用是如何解決這些問題的。這樣,右值引用的定義纔會看起來合理和天然。編程

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

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

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

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

int a = 42;                                                 
int b = 43;                                                 
                                                            
// a與b都是左值                               
a = b; // ok                                                
b = a; // ok                                                
a = a * b; // ok                                            
                                                            
// a * b是右值:                                      
int c = a * b; // ok, 右值在等號右邊
a * b = 42; // 錯誤,右值在等號左邊優化

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

// 左值:                                                        
//                                                                 
int i = 42;                                                        
i = 43; // ok, i是左值
int* p = &i; // ok, i是左值
int& foo();                                                        
foo() = 42; // ok, foo()是左值
int* p1 = &foo(); // ok, foo()是左值
                                                                   
// 右值:                                                        
//                                                                 
int foobar();                                                      
int j = 0;                                                         
j = foobar(); // ok, foobar()是右值
int* p2 = &foobar(); // 錯誤,不能取右值的地址
j = 42; // ok, 42是右值

若是你對左值和右值的嚴密的定義有興趣的話,能夠看下Mikael Kilpeläinen的文章[3]

move語義

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

X& X::operator=(X const & rhs)
{
  // [...]
  // 銷燬m_pResource指向的資源
  // 複製rhs.m_pResource所指的資源,並使m_pResource指向它
  // [...]
}

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

X foo(); // foo是一個返回值爲X的函數
X x;
x = foo();

最後一行有以下的操做:

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

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

// [...]
// swap m_pResource and rhs.m_pResource
// [...]

這就是所謂的move語義。在以前的c++中,這樣的行爲是很難實現的。雖然我也聽到有的人說他們能夠用模版元編程來實現,可是我還歷來沒有遇到過能給我解釋清楚如何具體實現的人。因此這必定是至關複雜的。C++0x經過重載的辦法來實現:

X& X::operator=(<mystery type> rhs)
{
  // [...]
  // swap this->m_pResource and rhs.m_pResource
  // [...]  
}

既然咱們是要重載賦值運算符,那麼<mystery type>確定是引用類型。另外咱們但願<mystery type>具備這樣的行爲:如今有兩種重載,一種參數是普通的引用,另外一種參數是<mystery type>,那麼當參數是個右值時就會選擇<mystery type>,當參數是左值是仍是選擇普通的引用類型。

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

右值引用

若是X是一種類型,那麼X&&就叫作X的右值引用。爲了更好的區分兩,普通引用如今被稱爲左值引用。

右值引用和左值引用的行爲差很少,可是有幾點不一樣,最重要的就是函數重載時左值使用左值引用的版本,右值使用右值引用的版本:

void foo(X& x); // 左值引用重載
void foo(X&& x); // 右值引用重載

X x;
X foobar();

foo(x); // 參數是左值,調用foo(X&)
foo(foobar()); // 參數是右值,調用foo(X&&)

重點在於:

右值引用容許函數在編譯期根據參數是左值仍是右值來創建分支。

 

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

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

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

若是你實現了void foo(X&);,可是沒有實現void foo(X&&);,那麼和之前同樣foo的參數只能是左值。若是實現了void foo(X const &);,可是沒有實現voidfoo(X&&);,仍和之前同樣,foo的參數既能夠是左值也能夠是右值。惟一可以區分左值和右值的辦法就是實現void foo(X&&);。最後,若是隻實現了實現voidfoo(X&&);,但卻沒有實現void foo(X&);void foo(X const &);,那麼foo的參數將只能是右值。

強制move語義

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

template<class T>
void swap(T& a, T& b) 

  T tmp(a);
  a = b; 
  b = tmp; 


X a, b;
swap(a, b);

上面的代碼中沒有右值,因此沒有使用move語義。但move語義用在這裏最合適不過了:當一個變量(a)做爲拷貝構造函數或者賦值的來源時,這個變量要麼就是之後都不會再使用,要麼就是做爲賦值操做的目標(a = b)。

C++11中的標準庫函數std::move能夠解決咱們的問題。這個函數只會作一件事:把它的參數轉換爲一個右值而且返回。C++11中的swap函數是這樣的:

template<class T> 
void swap(T& a, T& b) 

  T tmp(std::move(a));
  a = std::move(b); 
  b = std::move(tmp);


X a, b;
swap(a, b);

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

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

像上面的swap函數同樣,儘量的使用std::move會給咱們帶來如下好處:

  • 對那些實現了move語義的類型來講,許多標準庫算法和操做會獲得很大的性能上的提高。例如就地排序:就地排序算法基本上只是在交換容器內的對象,藉助move語義的實現,交換操做會快不少。
  • stl一般對某種類型的可複製性有必定的要求,好比要放入容器的類型。其實仔細研究下,大多數狀況下只要有可移動性就足夠了。因此咱們能夠在一些以前不可複製的類型不被容許的狀況下,用一些不可複製可是能夠移動的類型(unique_ptr)。這樣的類型是能夠做爲容器元素的。

右值引用是右值嗎?

假設有如下代碼:

void foo(X&& x)
{
  X anotherX = x;
  // ...
}

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

X(X&& rhs);

有些人可能會認爲一個右值引用自己就是右值。但右值引用的設計者們採用了一個更微妙的標準:

右值引用類型既能夠被看成左值也能夠被看成右值,判斷的標準是,若是它有名字,那就是左值,不然就是右值。

 

在上面的例子中,由於右值引用x是有名字的,因此x被看成左值來處理。

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

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

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

之因此採用這樣的判斷方法,是由於:若是容許悄悄地把move語義應用到有名字的東西(好比foo中的x)上面,代碼會變得容易出錯和讓人迷惑。

void foo(X&& x)
{
  X anotherX = x;
  // x仍然在做用域內
}

這裏的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語義:

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

而後又寫了一個繼承自Base的類Derived。爲了保證Derived對象中的Base部分可以正確實現move語義,必須也重載Derived類的拷貝構造函數和賦值操做符。先讓咱們看下拷貝構造函數(賦值操做符的實現相似),左值版本的拷貝構造函數很直白:

Derived(Derived const & rhs) 
  : Base(rhs)
{
  // Derived-specific stuff
}

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

Derived(Derived&& rhs) 
  : Base(rhs) // 錯誤:rhs是個左值
{
  // ...
}

若是像上面這樣寫,調用的永遠是Base的非move語義的拷貝構造函數。由於rhs有名字,因此它是個左值。但咱們想要調用的倒是move語義的拷貝構造函數,因此應該這麼寫:

Derived(Derived&& rhs) 
  : Base(std::move(rhs)) // good, calls Base(Base&& rhs)
{
  // Derived-specific stuff
}

move語義與編譯器優化

如今有這麼一個函數:

X foo()
{
  X x;
  // perhaps do something to x
  return x;
}

一看到這個函數,你可能會說,咦,這個函數裏有一個複製的動做,不如讓它使用move語義:

X foo()
{
  X x;
  // perhaps do something to x
  return std::move(x); // making it worse!
}

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

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

完美轉發:問題

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

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg arg)

  return shared_ptr<T>(new T(arg));
}

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

最多見的解決方法,好比被boost::bind採用的,就是讓外面的函數以引用做爲參數。

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg& arg)

  return shared_ptr<T>(new T(arg));
}

這樣確實會好一點,但不是完美的。如今的問題是這個函數不能接受右值做爲參數:

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

這個問題能夠經過一個接受const引用的重載解決:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg const & arg)

  return shared_ptr<T>(new T(arg));
}

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

其次這種辦法也稱不上是完美轉發,由於它不能實現move語義。factory內的構造函數的參數是個左值(由於它有名字),因此即便構造函數自己已經支持,factory也沒法實現move語義。

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

完美轉發:解決方案

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

A& & => A&
A& && => A&
A&& & => A&
A&& && => A&&

另一個是模版參數推導規則。這裏的模版是接受一個右值引用做爲模版參數的函數模版。

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

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

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

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

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg&& arg)

  return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}

std::forward的定義以下:

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

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

X x;
factory<A>(x);

根據上面的規則能夠推導獲得,factory的模版參數Arg變成了X&,因而編譯器會像下面這樣將模版實例化:

shared_ptr<A> factory(X& && arg)

  return shared_ptr<A>(new A(std::forward<X&>(arg)));


X& && forward(remove_reference<X&>::type& a) noexcept
{
  return static_cast<X& &&>(a);
}

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

shared_ptr<A> factory(X& arg)

  return shared_ptr<A>(new A(std::forward<X&>(arg)));


X& std::forward(X& a) 
{
  return static_cast<X&>(a);
}

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

如今再考慮參數是右值的狀況:

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

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

shared_ptr<A> factory(X&& arg)

  return shared_ptr<A>(new A(std::forward<X>(arg)));


X&& forward(X& a) noexcept
{
  return static_cast<X&&>(a);
}

對右值來講,這也是完美轉發:參數經過兩次中轉被傳遞給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的用意在於將它的參數傳遞下去,將它轉換成右值。

template<class T> 
typename remove_reference<T>::type&&
std::move(T&& a) noexcept
{
  typedef typename remove_reference<T>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

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

X x;
std::move(x);

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

typename remove_reference<X&>::type&&
std::move(X& && a) noexcept
{
  typedef typename remove_reference<X&>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

而後求得remove_reference的值,並應用引用疊加規則,獲得:

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

這就能夠了,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
相關文章
相關標籤/搜索