右值參照(rvalue reference),是C++程式語言C++11標準提出的一類資料類型。用於實現移動語意(move semantic)與完美轉發(perfect forwarding)。[參1]

背景

編輯

在C++11提出右值參照之前,C++03及更早的C++標準中,表達式的「值分類」(value categories)屬性為左值或右值。[參2]左值是對應(refer to)主記憶體中有確定儲存地址的對象之表達式的值,而右值是所有不是左值之表達式的值。因而,右值可以是字面量[註1]、臨時對象[註2]等表達式。能否被賦值不是區分C++左值與右值的依據,C++的const左值是不可賦值的;而作為臨時對象的右值可能允許被賦值[註3]。左值與右值的根本區別在於是否允許取址運算子(&)獲得對應的主記憶體地址。[註4]C++03及以前的標準定義了在表達式中左值到右值的三類隱式自動轉換:

  • 左值轉化為右值;如整數變數i在表達式 (i+3)
  • 陣列名是常數左值,在表達式[註5]中轉化為陣列首元素的地址值
  • 函數名是常數左值,在表達式中轉化為函數的地址值

作為一種追求執行效率的語言,C++在用臨時對象或函數返回值給左值對象賦值時的深度拷貝(deep copy)一直受到詬病。考慮到臨時對象的生命期僅在表達式中持續,如果把臨時對象的內容直接移動(move)給被賦值的左值對象,效率改善將是顯著的。這就是移動語意的來源。

與傳統的拷貝設定運算子(copy assignment)成員函數、拷貝構造(copy ctor)成員函數對應,移動語意需要有移動賦值(move assignment)成員函數、移動構造(move ctor)成員函數的實現機制。可以通過函數多載來確定是呼叫拷貝語意還是移動語意的實現。

右值參照就是為了實現移動語意與完美轉發所需要而設計出來的新的資料類型。右值參照的實例對應於臨時對象;右值參照並區別於左值參照,用作形參時能多載辨識(overload resolution)是呼叫拷貝語意還是移動語意的函數。

無論是傳統的左值參照還是C++11引進的右值參照,從編譯後的反組譯層面上,都是對象的儲存地址[註6]與自動解參照(dereference,取址的相反)。因此,右值參照與左值參照的變數都不能懸空(dangling),也即定義時必須初始化從而繫結到一個對象上。右值參照變數繫結的對象,是編程者認為可以通過移動語意移走其內容的對象,對這種對象就需要定義為一種獨特的值分類,即C++11標準稱之為「臨終值」(eXpire Value)。臨終值對象既有儲存地址因此可以繫結到右值參照變數上,而且它又是一個即將停止使用的對象可以被移走內容。所以臨終值既不同於左值,也不同於傳統的右值(C++11稱之為純右值),不能取地址運算(&)。另一方面,臨終值兼有傳統的左值與右值的性質:既對應於一個(臨時)對象,稱之為有標識(identity);同時其內容可以移走,稱之為抽取式性(movability)。C++11標準把臨終值與左值合稱為廣義左值,即指向某個物理存在的對象;把臨終值與純右值(對應C++03時的右值概念)合稱為右值(C++11重新定義的概念),其內容可以移走(該右值生命期到此為止,此後將不再使用)。之所以稱為右值而不叫做廣義右值,是因為右值參照既可以與臨終值對象繫結,也可以與純右值對象繫結(這時往往自動生成一個臨時對象)。

C++語言在引入了右值參照之後,面臨着一個問題:如何讓編程者指出哪個對象具有臨終值?這有兩種顯式指定方法:如果函數(或運算子)的返回類型為右值參照,或者通過類型轉換如static_cast<Type&&>或者std::move()模板函數。

定義

編輯

設X為任何一種資料類型,則定義X&&是到資料類型X的右值參照(rvalue reference to X)。傳統的參照類型X&被稱為左值參照(lvalue reference to X)。例如:

int i;
int &j=i; //傳統的左值引用數據類型的變量並初始化
int &&k=std::move(i);  //定義一個右值引用數據類型的變量並初始化。std::move定義於<utility>

語意

編輯

右值參照是一種資料類型,既有右值性質,也有左值性質。[參1]在C++11中為右值參照專門定義了臨終值(eXpiring value)這一概念。[註7]右值參照很類似於傳統的左值參照。[參3]

例如:

int s=101;

int&& foo(){return static_cast<int&&>(s);} //返回值为右值引用

int main() {
 int i=foo();   //右值引用作为右值,在赋值运算符的右侧
 int&& j=foo(); //j是具名引用。因此运算符左侧的右值引用作为左值
 int* p=&j;     //取得j的内存地址
}

C++中,參照(reference)是指繫結到主記憶體中的相應對象上。左值參照是繫結到左值對象上;右值參照是繫結到臨時對象上。這裏的左值對象是指可以通過取地址&運算子得到該對象的主記憶體地址;而臨時對象是不能用取地址&運算子取得到對象的主記憶體地址。

C++11有如下參照繫結規則

  • 非常數左值參照(X& ):只能繫結到X類型的左值對象;
  • 常數左值參照(const X&):可以繫結到 X、const X類型的左值對象,或X、const X類型的右值;[註8]
  • 非常數右值參照(X&&):只能繫結到X類型的右值;
  • 常數右值參照(const X&&):可以繫結規定到X、const X類型的右值。

對上述參照繫結規則,值得注意的是:

  1. 常數左值參照、常數右值參照、非常數右值參照,在繫結到字面量(literal)時,實際上用字面量構造了一個對象,然後繫結到該對象上;
  2. 上述參照繫結規定所提到的右值,包含了純右值臨終值(xvalue)。即右值參照可以繫結到純右值(隱式自動構造的臨時對象),也可以繫結到臨終值對象。
  3. 繫結規則在呼叫多載函數,虛、實參數結合時起到決定作用,可以確定哪個版本的多載函數被呼叫。一些函數的形參為右值參照類型,因此可接受右值實參的繫結,如移動建構函式、移動設定運算子、正常的成員函數如std::vector::push_back;另一些函數的形參為左值參照類型,因此可接受左值實參的繫結。一些函數模板的形參是廣義參照(universal reference,詳見下文),即可以接受左值對象繫結,也可以接受臨時對象繫結,需要在模板推導時來決定形參是左值參照還是右值參照,這給了一套函數模板以極大地靈活性,可以同時處理兩種參照類型,可以把函數參數的參照類型「完美轉發」(詳見下文)給被呼叫的實現函數。
  4. C++0X曾經規定右值參照可以繫結到左值對象上,但在C++11中取消了這一許可。

由於右值參照主要針對移動語意用來修改被參照的對象的內容,所以常數右值參照(const X&&)較少用到。

函數返回值是右值資料類型還是右值參照類型,區別在於前者是「傳值」,後者是「傳參照」可以修改被參照的對象。例如:

#include <iostream>
#include <utility>

int i = 101, j = 101;

int foo(){ return i; }
int&& bar(){ return std::move(i); }
void set(int&& k){ k = 102; }
int main()
{
	set(foo());
	std::cout << i << std::endl;
	set(bar());
	std::cout << i << std::endl; 	 
}

上述例子中的函數set中可以對類型為右值參照的形參k賦值,這是因為C++標準規定,具名的右值參照被當作左值[註9]這一規定的意義在於,右值參照本來是用於實現移動語意,因而需要繫結一個對象的主記憶體地址,然後具有修改這一對象內容的權限,這些操作與左值繫結完全一樣。右值繫結與左值繫結的分別在於函數多載時的行為。對於移動構造成員函數與移動設定運算子成員函數,其形、實參數結合時是按照右值參照處理;而在這兩個成語函數體內部,由於形參都是具名的,因而都被當作左值,這就可以用該形參來修改傳入對象的內部狀態。另外,右值參照作為xvalue(臨終值)本來是用於移動語意中一次性搬空其內容。具名使其具有更為持久的生存期,這是危險的,因而規定具名後為左值參照,除非程式顯式指定其類型強制轉換為右值參照。

C++11標準給了用戶更大的決定權,例如把左值或臨終值,轉化為右值參照。這是通過定義在C++標準程式庫<utility>中的std::move實現的。[參4]std::move是個模板函數,把輸入的左值或右值轉換為右值參照類型的臨終值。其核心是強制類型轉換static_cast<Type&&>()陳述式。

除了左值參照、右值參照,對於函數參數類型是到模板參數類型的右值參照的情形,稱之為廣義參照(universal reference)或轉發參照(forward reference)。[參4]例如:

 template<typename T> int foo(T&& param);

詳見完美轉發一節的討論。

完美轉發

編輯

完美轉發也是C++11標準引入右值參照這一概念所要實現的目標之一。

背景

編輯

在C++程式設計中,一個常見的類工廠函數,如下例:

template <typename T, typename Arg> 
shared_ptr<T> factory(Arg arg)
{
    return shared_ptr<T>( new T(arg) );
}

參數對象arg在上例中是傳值方式傳遞,這帶來了生成額外的臨時對象的代價。對於類工廠函數,完美的參數傳遞應該是參照方式傳遞。因而,在boost:bind中,參數是左值參照:

template <typename T, typename Arg> 
shared_ptr<T> factory(Arg& arg)
{
    return shared_ptr<T>( new T(arg) );
}

這種實現的問題是形參不能繫結右值實參。如factory<X>(102)將編譯報錯。進一步解決辦法是按常數參照方式傳遞參數,如下例:

template <typename T, typename Arg> 
shared_ptr<T> factory(const Arg& arg)
{
    return shared_ptr<T>( new T(arg) );
}

這種實現的問題是不能支援移動語意。

形參使用右值參照可以解決完美轉發問題。

參照摺疊規則

編輯

對於C++語言,不可以在源程式中直接對參照類型再施加參照。T& &將編譯報錯。C++11標準中仍然禁止上述顯式對參照類型再施加參照,但如果在上下文環境中(包括模板實例化、typedef、auto類型推斷等)如出現了對參照類型再施加參照,則施行參照塌縮規則(reference collapsing rule)[註10]

  • T& &變為T&
  • T& &&變為T&
  • T&& &變為T&
  • T&& &&變為T&&

模板參數類型推導

編輯

對函數模板template<typename T>void foo(T&&);,應用上述參照塌縮規則,可推導出如下結論:

  • 如果實參是類型A的左值,則模板參數T的類型為A&,形參類型為A&;
  • 如果實參是類型A的右值,則模板參數T的類型為A&&,形參類型為A&&。

這還適用於類別模板的成員函數模板的類型推導:

template <class T > class vector {
    public: 
    void push_back(T&& x); // T是类模板参数 ⇒ 该成员函数不需要类型推导;这里的函数参数类型就是T的右值引用
     template <class Args> void emplace_back(Args&& args); //  该成员函数是个函数模板,有自己的模板参数,需要类型推导
};

函數模板的形參必須是T&&形式,才需要模板參數類型推導。即使形參聲明為const T&&形式,就只能按字面意義使用,不需要模板參數類型推導。

template<typename T>void f(const T&& param); // 这里的“&&”不需要类型推导,意味着“常量类型T的右值引用”
template<typename T>void f(std::vector<T>&& param);  // 这里的“&&”不需要类型推导,意味着std::vector<T>的右值引用

完美轉發的解決方案

編輯
template <typename T, typename Arg> 
shared_ptr<T> factory(Arg&& arg)
{
    return shared_ptr<T>( new T(std::forward<Arg>(arg) ) );
}

其中std::forward是定義在C++標準程式庫<utility>中的模板函數:

template< class T > T&& forward( typename std::remove_reference<T>::type& t )
{
  return static_cast<T&&>(t);
}
template< class T > T&& forward( typename std::remove_reference<T>::type&& t )
{
  return static_cast<T&&>(t);
}

std::forward在呼叫時,應當顯示給出該函數模板參數類型。其用途是:如果函數forward的實參的資料類型是左值參照,則返回類型為左值參照;如果函數forward的實參的資料類型是右值參照,則返回類型為右值參照,返回值的分類屬於臨終值,從而把參數的資訊完整地傳遞給下一級被呼叫的函數。從上述std::forward的定義實現來看,實參必須是個為左值的參照對象,但是實參的資料類型有兩種可能:

  • 實參的資料類型S是左值參照類型,std::forward的返回類型S&&根據參照塌縮規則變為S&,即返回值仍為左值參照類型;
  • 實參的資料類型S是右值參照類型(這是因為右值參照類型的具名變數實際上表現為左值),std::forward的返回類型S&&根據參照塌縮規則變為S&&,即返回值為右值參照類型。

類似的,std::move也是利用上述模板函數類型推導規則,定義在C++標準程式庫<utility>中,無論輸入的實參是左值還是右值,均返回右值參照:

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

定義在C++標準程式庫<type_traits>std::remove_reference是個類別模板,其中定義的類型type是參照的基本類型:

template< class T > struct remove_reference      {typedef T type;};
template< class T > struct remove_reference<T&>  {typedef T type;};
template< class T > struct remove_reference<T&&> {typedef T type;};

移動語意與異常

編輯

移動語意只是把資源從一個右值對象搬移到被構造或者被賦值對象內部,因此保證不投擲異常是容易實現的。在std::vector這樣的容器內實現移動語意,必須顯式聲明容器元素類的移動構造成員函數、移動設定運算子成員函數不投擲異常。

右值參照的類型推導

編輯

C++中涉及到的右值參照的類型推導,除了上述模板參數類型推導,還有:

auto關鍵字的類型完美轉發

編輯

C++11使用auto聲明變數時,如:auto&& var=initValue;「auto&&」並不意味着這一定是右值參照類型的變數,而是類似於模板函數參數的類型推導,既可能是左值參照,也可能是右值參照。其目的是把初始化表達式的值分類情況,完美轉發給由auto聲明的變數。也即:

  • 如果初始化值(initializer)是類型A的左值,則聲明的變數類型為左值參照A&;
  • 如果初始化值是類型A的右值,則聲明的變數類型為右值參照A&&。
Type1&& var1=anotherType1Instance; // var1的类型是右值引用,但是作为左值
auto&& var2=var1;       //var2的类型是左值引用
std::vector<int> v;
auto&& val = v[0]; // std::vector::operator[]的返回值是元素左值,所以val的类型是左值引用

Widget makeWidget(); // 类工厂函数
Widget&& var1 = makeWidget() // var1的类型是右值引用,具有左值。
     
Widget var2 = static_cast<Widget&&>(var1); // var2在初始化时可以使用移动构造函数。

typedef的類型推導

編輯

typedef也可能會用到參照塌縮規則。例如:

template<typename T>    class Widget {
    typedef T& LvalueRefType;

};
Widget<int&&> w; // LvalueRefType的类型为int&
void f(Widget<int&>::LvalueRefType&& param); //param的类型为int&

decltype類型推導

編輯

decltype也可能會用到參照塌縮規則。例如:

int var;
decltype(var)&& v1=std::move(var); //类型是int&&

備註

編輯

    註:

  1. ^ 字面量可能會被編碼到機器指令的「立即數」中
  2. ^ 臨時對象可能會儲存在暫存器中
  3. ^ 典型應用是STL的容器釋放主記憶體時,與一個空的臨時容器對象swap內容,然後臨時容器對象出作用域而自動解構釋放。如: std::vector<int>().swap(myVector); //myVector已经分配了大量内存需要释放掉
  4. ^ 右值也可能通過對象的成員函數取得到地址值。因此必須注意:是否能用&運算子取地址,才是區分表達式是左值與右值的關鍵。
  5. ^ 不包含作為sizeof運算子的運算元、作為取地址&運算子的運算元等情況。這些情況下,表達式中的陣列名作為表示整個陣列的左值使用
  6. ^ C++語言標準並不規定參照是如何實現的,但g++與Visual C++都是把參照實現為自動解除參照的指標。例如,一個函數的參數是參照類型,用不同的對象作為實參呼叫該函數,在函數內部就可以通過形參訪問不同的對象。
  7. ^ C++03中的左值、右值分別對應於C++11的左值與右值
  8. ^ 因而常數左值參照類型的形參,可以與任何類型的實參結合。
  9. ^ 被稱作if-it-has-a-name rule.
  10. ^ 也譯作參照摺疊規則

參考文獻

編輯