程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 從4行代碼看右值引用,4行代碼右值引用

從4行代碼看右值引用,4行代碼右值引用

編輯:C++入門知識

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

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved