1. 程式人生 > >從4行程式碼看右值引用 [轉載]

從4行程式碼看右值引用 [轉載]

從4行程式碼看右值引用

概述

  右值引用的概念有些讀者可能會感到陌生,其實他和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是同一個指標,這就是所謂的指標懸掛問題。提供深拷貝的拷貝建構函式雖然可以保證正確,但是在有些時候會造成額外的效能損耗,因為有時候這種深拷貝是不必要的。比如下面的程式碼:

  上面程式碼中的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的區別。

圖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右值引用搞不清或者理解得不深入,所以我覺得有必要拿出來分享一下,讓更多的人看到,就整理了一下發到程式設計師雜誌了,我相信讀者看完之後對右值引用會有全面深入的瞭解。

 

轉載地址:https://www.cnblogs.com/qicosmos/p/4283455.html