天天看點

從4行代碼看右值引用(轉載)概述

本文轉自部落格園:https://www.cnblogs.com/qicosmos/p/4283455.html

以下正文:

概述

  右值引用的概念有些讀者可能會感到陌生,其實他和C++98/03中的左值引用有些類似,例如,c++98/03中的左值引用是這樣的:

int i = 0;
int& j = i;
           

  這裡的int&是對左值進行綁定(但是int&卻不能綁定右值),相應的,對右值進行綁定的引用就是右值引用,他的文法是這樣的A&&,通過雙引号來表示綁定類型為A的右值。通過&&我們就可以很友善的綁定右值了,比如我們可以這樣綁定一個右值:

int&& i = 0;
           

  這裡我們綁定了一個右值0,關于右值的概念會在後面介紹。右值引用是C++11中新增加的一個很重要的特性,他主是要用來解決C++98/03中遇到的兩個問題,第一個問題就是臨時對象非必要的昂貴的拷貝操作,第二個問題是在模闆函數中如何按照參數的實際類型進行轉發。通過引入右值引用,很好的解決了這兩個問題,改進了程式性能,後面将會詳細介紹右值引用是如何解決這兩個問題的。

  和右值引用相關的概念比較多,比如:右值、純右值、将亡值、universal references、引用折疊、移動語義、move語義和完美轉發等等。很多都是新概念,對于剛學習C++11右值引用的初學者來說,可能會覺得右值引用過于複雜,概念之間的關系難以理清。

右值引用實際上并沒有那麼複雜,其實是關于4行代碼的故事,通過簡單的4行代碼我們就能清晰的了解右值引用相關的概念了。本文希望帶領讀者通過4行代碼來了解右值引用相關的概念,理清他們之間的關系,并最終能透徹地掌握C++11的新特性--右值引用。

四行代碼的故事

第1行代碼的故事

int i = getVar();
           

  上面的這行代碼很簡單,從getVar()函數擷取一個整形值,然而,這行代碼會産生幾種類型的值呢?答案是會産生兩種類型的值,一種是左值i,一種是函數getVar()傳回的臨時值,這個臨時值在表達式結束後就銷毀了,而左值i在表達式結束後仍然存在,這個臨時值就是右值,具體來說是一個純右值,右值是不具名的。區分左值和右值的一個簡單辦法是:看能不能對表達式取位址,如果能,則為左值,否則為右值。

  所有的具名變量或對象都是左值,而匿名變量則是右值,比如,簡單的指派語句:

int i = 0;
           

  在這條語句中,i 是左值,0 是字面量,就是右值。在上面的代碼中,i 可以被引用,0 就不可以了。具體來說上面的表達式中等号右邊的0是純右值(prvalue),在C++11中所有的值必屬于左值、将亡值、純右值三者之一。比如,非引用傳回的臨時變量、運算表達式産生的臨時變量、原始字面量和lambda表達式等都是純右值。而将亡值是C++11新增的、與右值引用相關的表達式,比如,将要被移動的對象、T&&函數傳回值、std::move傳回值和轉換為T&&的類型的轉換函數的傳回值等。關于将亡值我們會在後面介紹,先看下面的代碼:

int j = 5;

auto f = []{return 5;};
           

  上面的代碼中5是一個原始字面量, []{return 5;}是一個lambda表達式,都是屬于純右值,他們的特點是在表達式結束之後就銷毀了。

  通過地行代碼我們對右值有了一個初步的認識,知道了什麼是右值,接下來再來看看第二行代碼。

第2行代碼的故事

T&& k = getVar();
           

  第二行代碼和第一行代碼很像,隻是相比第一行代碼多了“&&”,他就是右值引用,我們知道左值引用是對左值的引用,那麼,對應的,對右值的引用就是右值引用,而且右值是匿名變量,我們也隻能通過引用的方式來擷取右值。雖然第二行代碼和第一行代碼看起來差别不大,但是實際上語義的差别很大,這裡,getVar()産生的臨時值不會像第一行代碼那樣,在表達式結束之後就銷毀了,而是會被“續命”,他的生命周期将會通過右值引用得以延續,和變量k的聲明周期一樣長。

右值引用的第一個特點

  通過右值引用的聲明,右值又“重獲新生”,其生命周期與右值引用類型變量的生命周期一樣長,隻要該變量還活着,該右值臨時量将會一直存活下去。讓我們通過一個簡單的例子來看看右值的生命周期。如代碼清單1-1所示。

代碼清單1-1 

#include <iostream>
using namespace std;

int g_constructCount=0;
int g_copyConstructCount=0;
int g_destructCount=0;
struct A
{
    A(){
        cout<<"construct: "<<++g_constructCount<<endl;    
    }
    
    A(const A& a)
    {
        cout<<"copy construct: "<<++g_copyConstructCount <<endl;
    }
    ~A()
    {
        cout<<"destruct: "<<++g_destructCount<<endl;
    }
};

A GetA()
{
    return A();
}

int main() {
    A a = GetA();
    return 0;
}
           

  為了清楚的觀察臨時值,在編譯時設定編譯選項-fno-elide-constructors用來關閉傳回值優化效果。

  輸出結果:

construct: 1
copy construct: 1
destruct: 1
copy construct: 2
destruct: 2
destruct: 3
           

  從上面的例子中可以看到,在沒有傳回值優化的情況下,拷貝構造函數調用了兩次,一次是GetA()函數内部建立的對象傳回出來構造一個臨時對象産生的,另一次是在main函數中構造a對象産生的。第二次的destruct是因為臨時對象在構造a對象之後就銷毀了。如果開啟傳回值優化的話,輸出結果将是:

construct: 1

destruct: 1
           

  可以看到傳回值優化将會把臨時對象優化掉,但這不是c++标準,是各編譯器的優化規則。我們在回到之前提到的可以通過右值引用來延長臨時右值的生命周期,如果上面的代碼中我們通過右值引用來綁定函數傳回值的話,結果又會是什麼樣的呢?在編譯時設定編譯選項-fno-elide-constructors。

int main() {
    A&& a = GetA();
    return 0;
}
           

輸出結果:

construct: 1
copy construct: 1
destruct: 1
destruct: 2
           

  通過右值引用,比之前少了一次拷貝構造和一次析構,原因在于右值引用綁定了右值,讓臨時右值的生命周期延長了。我們可以利用這個特點做一些性能優化,即避免臨時對象的拷貝構造和析構,事實上,在c++98/03中,通過常量左值引用也經常用來做性能優化。上面的代碼改成:

const A& a = GetA();
           

  輸出的結果和右值引用一樣,因為常量左值引用是一個“萬能”的引用類型,可以接受左值、右值、常量左值和常量右值。需要注意的是普通的左值引用不能接受右值,比如這樣的寫法是不對的:

A& a = GetA();
           

  上面的代碼會報一個編譯錯誤,因為非常量左值引用隻能接受左值。

右值引用的第二個特點

  右值引用獨立于左值和右值。意思是右值引用類型的變量可能是左值也可能是右值。比如下面的例子:

int&& var1 = 1; 
           

  var1類型為右值引用,但var1本身是左值,因為具名變量都是左值。

  關于右值引用一個有意思的問題是:T&&是什麼,一定是右值嗎?讓我們來看看下面的例子:

template<typename T>
void f(T&& t){}

f(10); //t是右值

int x = 10;
f(x); //t是左值
           

  從上面的代碼中可以看到,T&&表示的值類型不确定,可能是左值又可能是右值,這一點看起來有點奇怪,這就是右值引用的一個特點。

右值引用的第三個特點

  T&& t在發生自動類型推斷的時候,它是未定的引用類型(universal references),如果被一個左值初始化,它就是一個左值;如果它被一個右值初始化,它就是一個右值,它是左值還是右值取決于它的初始化。

我們再回過頭看上面的代碼,對于函數template<typename T>void f(T&& t),當參數為右值10的時候,根據universal references的特點,t被一個右值初始化,那麼t就是右值;當參數為左值x時,t被一個左值引用初始化,那麼t就是一個左值。需要注意的是,僅僅是當發生自動類型推導(如函數模闆的類型自動推導,或auto關鍵字)的時候,T&&才是universal references。再看看下面的例子:

template<typename T>
void f(T&& param); 

template<typename T>
class Test {
    Test(Test&& rhs); 
};
           

  上面的例子中,param是universal reference,rhs是Test&&右值引用,因為模版函數f發生了類型推斷,而Test&&并沒有發生類型推導,因為Test&&是确定的類型了。

  正是因為右值引用可能是左值也可能是右值,依賴于初始化,并不是一下子就确定的特點,我們可以利用這一點做很多文章,比如後面要介紹的移動語義和完美轉發。

  這裡再提一下引用折疊,正是因為引入了右值引用,是以可能存在左值引用與右值引用和右值引用與右值引用的折疊,C++11确定了引用折疊的規則,規則是這樣的:

  • 所有的右值引用疊加到右值引用上仍然還是一個右值引用;
  • 所有的其他引用類型之間的疊加都将變成左值引用。

第3行代碼的故事

T(T&& a) : m_val(val){ a.m_val=nullptr; }
           

  這行代碼實際上來自于一個類的構造函數,構造函數的一個參數是一個右值引用,為什麼将右值引用作為構造函數的參數呢?在解答這個問題之前我們先看一個例子。如代碼清單1-2所示。

代碼清單1-2

class A
{
public:
    A():m_ptr(new int(0)){cout << "construct" << endl;}
    A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷貝的拷貝構造函數
    {
        cout << "copy construct" << endl;
    }
    ~A(){ delete m_ptr;}
private:
    int* m_ptr;
};
int main() {
    A a = GetA();
    return 0;
}
           
輸出:
           
construct
copy construct
copy construct
           

  這個例子很簡單,一個帶有堆記憶體的類,必須提供一個深拷貝拷貝構造函數,因為預設的拷貝構造函數是淺拷貝,會發生“指針懸挂”的問題。如果不提供深拷貝的拷貝構造函數,上面的測試代碼将會發生錯誤(編譯選項-fno-elide-constructors),内部的m_ptr将會被删除兩次,一次是臨時右值析構的時候删除一次,第二次外面構造的a對象釋放時删除一次,而這兩個對象的m_ptr是同一個指針,這就是所謂的指針懸挂問題。提供深拷貝的拷貝構造函數雖然可以保證正确,但是在有些時候會造成額外的性能損耗,因為有時候這種深拷貝是不必要的。比如下面的代碼:

從4行代碼看右值引用(轉載)概述

  上面代碼中的GetA函數會傳回臨時變量,然後通過這個臨時變量拷貝構造了一個新的對象a,臨時變量在拷貝構造完成之後就銷毀了,如果堆記憶體很大的話,那麼,這個拷貝構造的代價會很大,帶來了額外的性能損失。每次都會産生臨時變量并造成額外的性能損失,有沒有辦法避免臨時變量造成的性能損失呢?答案是肯定的,C++11已經有了解決方法,看看下面的代碼。如代碼清單1-3所示。

代碼清單1-3

class A
{
public:
    A() :m_ptr(new int(0)){}
    A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷貝的拷貝構造函數
    {
        cout << "copy construct" << endl;
    }
    A(A&& a) :m_ptr(a.m_ptr)
    {
        a.m_ptr = nullptr;
        cout << "move construct" << endl;
    }
    ~A(){ delete m_ptr;}
private:
    int* m_ptr;
};
int main(){
    A a = Get(false); 
} 
           
輸出:
           
construct
move construct
move construct
           

  代碼清單1-3和1-2相比隻多了一個構造函數,輸出結果表明,并沒有調用拷貝構造函數,隻調用了move construct函數,讓我們來看看這個move construct函數:

A(A&& a) :m_ptr(a.m_ptr)
{
    a.m_ptr = nullptr;
    cout << "move construct" << endl;
}
           

  這個構造函數并沒有做深拷貝,僅僅是将指針的所有者轉移到了另外一個對象,同時,将參數對象a的指針置為空,這裡僅僅是做了淺拷貝,是以,這個構造函數避免了臨時變量的深拷貝問題。

  上面這個函數其實就是移動構造函數,他的參數是一個右值引用類型,這裡的A&&表示右值,為什麼?前面已經提到,這裡沒有發生類型推斷,是确定的右值引用類型。為什麼會比對到這個構造函數?因為這個構造函數隻能接受右值參數,而函數傳回值是右值,是以就會比對到這個構造函數。這裡的A&&可以看作是臨時值的辨別,對于臨時值我們僅僅需要做淺拷貝即可,無需再做深拷貝,進而解決了前面提到的臨時變量拷貝構造産生的性能損失的問題。這就是所謂的移動語義,右值引用的一個重要作用是用來支援移動語義的。

  需要注意的一個細節是,我們提供移動構造函數的同時也會提供一個拷貝構造函數,以防止移動不成功的時候還能拷貝構造,使我們的代碼更安全。

  我們知道移動語義是通過右值引用來比對臨時值的,那麼,普通的左值是否也能借助移動語義來優化性能呢,那該怎麼做呢?事實上C++11為了解決這個問題,提供了std::move方法來将左值轉換為右值,進而友善應用移動語義。move是将對象資源的所有權從一個對象轉移到另一個對象,隻是轉移,沒有記憶體的拷貝,這就是所謂的move語義。如圖1-1所示是深拷貝和move的差別。

從4行代碼看右值引用(轉載)概述

圖1-1 深拷貝和move的差別标題

  再看看下面的例子:

{
    std::list< std::string> tokens;
    //省略初始化...
    std::list< std::string> t = tokens; //這裡存在拷貝 
}
std::list< std::string> tokens;
std::list< std::string> t = std::move(tokens);  //這裡沒有拷貝 
           

  如果不用std::move,拷貝的代價很大,性能較低。使用move幾乎沒有任何代價,隻是轉換了資源的所有權。他實際上将左值變成右值引用,然後應用移動語義,調用移動構造函數,就避免了拷貝,提高了程式性能。如果一個對象内部有較大的對記憶體或者動态數組時,很有必要寫move語義的拷貝構造函數和指派函數,避免無謂的深拷貝,以提高性能。事實上,C++11中所有的容器都實作了移動語義,友善我們做性能優化。

  這裡也要注意對move語義的誤解,move實際上它并不能移動任何東西,它唯一的功能是将一個左值強制轉換為一個右值引用。如果是一些基本類型比如int和char[10]定長數組等類型,使用move的話仍然會發生拷貝(因為沒有對應的移動構造函數)。是以,move對于含資源(堆記憶體或句柄)的對象來說更有意義。

第4行代碼故事

template <typename T>void f(T&& val){ foo(std::forward<T>(val)); }
           

  C++11之前調用模闆函數時,存在一個比較頭疼的問題,如何正确的傳遞參數。比如: 

template <typename T>
void forwardValue(T& val)
{
    processValue(val); //右值參數會變成左值 
}
template <typename T>
void forwardValue(const T& val)
{
    processValue(val); //參數都變成常量左值引用了 
}
           

都不能按照參數的本來的類型進行轉發。

  C++11引入了完美轉發:在函數模闆中,完全依照模闆的參數的類型(即保持參數的左值、右值特征),将參數傳遞給函數模闆中調用的另外一個函數。C++11中的std::forward正是做這個事情的,他會按照參數的實際類型進行轉發。看下面的例子:

void processValue(int& a){ cout << "lvalue" << endl; }
void processValue(int&& a){ cout << "rvalue" << endl; }
template <typename T>
void forwardValue(T&& val)
{
    processValue(std::forward<T>(val)); //照參數本來的類型進行轉發。
}
void Testdelcl()
{
    int i = 0;
    forwardValue(i); //傳入左值 
    forwardValue(0);//傳入右值 
}
           
輸出:
           
lvaue 
rvalue
           

  右值引用T&&是一個universal references,可以接受左值或者右值,正是這個特性讓他适合作為一個參數的路由,然後再通過std::forward按照參數的實際類型去比對對應的重載函數,最終實作完美轉發。

  我們可以結合完美轉發和移動語義來實作一個泛型的工廠函數,這個工廠函數可以建立所有類型的對象。具體實作如下:

template<typename…  Args>
T* Instance(Args&&… args)
{
    return new T(std::forward<Args >(args)…);
}
           

  這個工廠函數的參數是右值引用類型,内部使用std::forward按照參數的實際類型進行轉發,如果參數的實際類型是右值,那麼建立的時候會自動比對移動構造,如果是左值則會比對拷貝構造。

總結

  通過4行代碼我們知道了什麼是右值和右值引用,以及右值引用的一些特點,利用這些特點我們才友善實作移動語義和完美轉發。C++11正是通過引入右值引用來優化性能,具體來說是通過移動語義來避免無謂拷貝的問題,通過move語義來将臨時生成的左值中的資源無代價的轉移到另外一個對象中去,通過完美轉發來解決不能按照參數實際類型來轉發的問題(同時,完美轉發獲得的一個好處是可以實作移動語義)。

本文曾發表于《程式員》2015年1月刊。轉載請注明出處。

後記:本文的内容主要來自于我在公司内部教育訓練的一次課程,因為很多人對C++11右值引用搞不清或者了解得不深入,是以我覺得有必要拿出來分享一下,讓更多的人看到,就整理了一下發到程式員雜志了,我相信讀者看完之後對右值引用會有全面深入的了解。

繼續閱讀