1. 程式人生 > >C++ 左值右值,用於移動(move)和轉發(forward)

C++ 左值右值,用於移動(move)和轉發(forward)

    新標準重新定義了lvalue和rvalue,並允許函式依照這兩種不同的型別進行過載。通過對於右值(rvalue)的重新定義,語言實現了移動語義(move semantic)和完美轉發(perfect forwarding),通過這種方法,C++實現了在保留原有的語法並不改動已存在的程式碼的基礎上提升程式碼效能的目的。作為一個C++0x標準的初學者,理解這些概念其實還有有一定的困難的,加上網上能夠找得到的中文資源有比較的少,少有的資源寫的也都不是那麼的通俗易懂,多少有點晦澀,這也為學習設定了一定的障礙。同樣作為初學者,我就花了不少時間研究這些概念,終於算是有所體悟。這裡就把我粗淺的理解記錄於此,希望能夠給後來接觸這些內容的同儕們以幫助,也供我日後參考。謬誤之處在所難免,還望不吝賜教。

移動語義解決了什麼問題

我們先看一段程式碼:

class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

這是一段常見的類的定義。在其中我們定義了一個int型別陣列arr,它一共有5000個元素。考慮到我們可以使用一個已有的Test物件來初始化一個新的Test物件,我們實現了複製建構函式(copy constructor)。

接下來,我們考慮一個這樣的應用場景:

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

我們建立了一個reusable變數並對其做了某一些操作,之後我們使用這個更改過的reusable變數初始化一個duplicated

變數,在對其進行初始化之後,我們依然需要對reusable做其他的操作。在這個情境下,reusableduplicated變數各自有自己的用處,沒有誰是為誰附帶產生的。所以我們看到,在這個情境下,我們的複製建構函式是合情合理的。

現在我們考慮另外一個場景:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

在這個場景當中,我們需要使用一個工廠函式來構造Test的例項。那麼在這個場景下,我們的複製建構函式被呼叫了2次。這兩次呼叫相當於複製了10000個元素,是一個不小的開銷。可是我們的這個開銷有意義嗎?我們知道,在工廠函式當中建立的Test

例項在函式返回時就會被析構,而用於返回值的Test的臨時例項也會在將值賦給main函式當中的t之後被析構。也就是說,這兩個臨時物件事實上並沒有什麼意義。由於構造他們而產生的複製的開銷其實完全沒有必要(事實上,編譯器一般會對這種情況進行(N)RVO,但不見得每次都能很好的優化)。所以我們就在考慮,有沒有可能我們可以將在工廠函式當中構造的成員變數的那塊記憶體“偷”過來,而不是重新開闢一塊記憶體,然後再將之前的內容複製過來呢?

移動語義(move semantic)

鐺鐺鐺鐺!移動語義登場了!移動語義就是為了解決上面的這種問題而產生的。通過移動語義,我們可以在沒有必要的時候避免複製。那麼在接下來,我們就重點來談一談移動建構函式(move constructor)。相信到這裡你已經意識到了,移動建構函式的出現就是為了解決複製建構函式的這個弊病。所以,其實移動建構函式應該和複製建構函式實現差不多的功能。那麼,它也應該是一種建構函式的過載(好廢的廢話……)。所以,我們可以想象出來,其實移動建構函式大概就會是這個樣子:

Test(<KEYWORD> t):arr(t.arr){t.arr = nullptr;}

這裡解釋一下,通過移動建構函式,事實上我們是做了一個淺拷貝(shallow copy)。至於要將之前的指標置為空的原因在於,我們的類會在析構的時候delete掉我們的陣列。那麼我們淺拷貝出來的這個物件的成員變數(arr指標)就變成了一個懸掛指標(dangling pointer)。

好了,現在的問題變成了,這個<KEYWORD>究竟是什麼?編譯器如何自動判斷到底應該呼叫複製建構函式(我突然想起來這個東西的翻譯貌似應該是拷貝建構函式,但是既然都已經寫了這麼多了,我就不改了)還是移動建構函式呢?

左值(lvalue)、右值(rvalue)、左值引用(lvalue-reference)和右值引用(rvalue-reference)

左值和右值

為了回答上面的這個問題,我們首先需要明確左值和右值的概念。C++定義了與C不相同的左值和右值的判斷方法,不過說起來非常簡單:凡是真正的存在記憶體當中,而不是暫存器當中的值就是左值,其餘的都是右值。其實更通俗一點的說法就是:凡是取地址(&)操作可以成功的都是左值,其餘都是右值。現在相信大家都已經知道左值和右值的關係了。我們來看幾個例子:
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue 
int* p = &i; // ok, i is an lvalue 
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues: 
int foobar(); 
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int k = j + 2; // ok, j+2 is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue 
j = 42; // ok, 42 is an rvalue

那麼,函式是不是就只可以作為右值呢?其實不是。考慮一個我們司空見慣的例子:

vector<int> vec = {1,2,3,4,5};
vec[1] = 99; // overloaded operator[]
我們看到,其實operator[]是一個函式,其返回值依然可以作為左值。

左值引用和右值引用

好了,在明確了左值和右值的關係之後,左值引用而右值引用的概念也就顯而易見了。對於左值的引用就是左值引用,而對於右值的引用就是右值引用。雖然這麼說,但是其實這個概念還並不是那麼好理解。

事實上,不好理解的原因是我們之前從來沒有真正的去區分過這兩個概念,因為我們曾經將左值引用直接稱為“引用”。也就是說,我們曾經一直用的int&事實上是對於int型別左值的引用。而對於右值呢?在新標準當中我們使用int&&來表示。我們不妨看看幾個例子:

void foo(const int & i) { cout << "const int & " << i << endl; }
void foo(int & i) { cout << "int & " << i << endl; }
void foo(int && i) { cout << "int && " << i << endl; }
void foo(const int && i) { cout << "const int && " << i << endl; } // 這是個奇葩,我一會說
我們在以往使用的時候大多會使用第一種形式。其實,第一種形式是一種神奇的形式,因為const int &既可以繫結左值,也可以繫結右值。所以在沒有後面三個過載函式的情況下,我們呼叫一下語句:
int i = 2;
foo(i);
foo(2);
他們的輸出都是const int & 2。而如果在只有第二個函式而沒有其他函式的時候,第三條語句是違法的。在只有第三個函式沒有其它函式的時候,第二條語句是違法的。所以我們總結一下:const reference可以繫結所有的值,而其他型別的引用只能繫結自己型別的值。在這四種函式都存在的情況下,每一種函式都會繫結與自己最接近的那個值。也就是說,在四個函式都存在的情況下,當我們再次執行上面的這段程式碼,輸出的結果就將變成:
int & 2
int && 2

所以,當我們執行下面的語句:

foo(i);
foo(j);
foo(2);
foo([]()->const int && {return 2;}());

我們得到的結果將會是:

int & 2
const int & 2
int && 2
const int && 2

這裡解釋一下第四個。第四條語句編譯的時候會有Warning,提示”Returning reference to local temporary object”。想想也確實是這麼個事情,不過它讓我通過了,而且結果沒錯誤。我覺得這個是不靠譜的。不過其實仔細考慮一下,常量右值引用其實不太能想出什麼應用場景。所以個人認為,這只是貫徹C++標準當中”不應當組織程式設計師拿起槍射自己的腳“的精神,到不一定有什麼實際意義,所以這個就不要糾結了。

相信現在大家已經能夠對於左值、右值、左值引用和右值引用有一個準確的認識了。

回到之前的問題

現在我們可以知道上面那個Test類當中的神奇的<KEYWORD>到底是什麼了。其實他就是Test &&。由於左值和右值是兩種不同的型別,所以可以依照這個型別進行過載。所以我們的Test類就變成了這樣:
class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    Test(Test && t): arr(t.arr) {
        cout << "move constructor" << endl;
        t.arr = nullptr;
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

所以,當我們再次考慮下面這個應用場景的時候:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

我們會發現,列印的結果變成了:

default constructor
move constructor
destructor
move constructor
destructor
destructor

也就是說,我們的Test例項在工廠函式當中被使用預設建構函式(default constructor)構造一次之後,呼叫的全部都是移動建構函式,因為我們發現其實所有的這些值都是右值。這極大地節省了開支。

這裡有一個編譯器的trick。gcc是一個喪心病狂的編譯器,他會強制進行(N)RVO。如果你不做任何設定直接用GCC編譯執行上面的程式碼,你將看到的是:

default constructor

這個時候不要懷疑我上面說的東西有問題或者你寫錯了。請直接在gcc後面新增編譯引數-fno-elide-constructors。所以整個的編譯語句應該是:

g++ -std=c++11 -fno-elide-constructors test.cpp # for instance

移動語義再多說幾句

現在我們再來看看一開始那個reusable的例子。

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

如果現在我們不想複製reusable了,我們也想在構造duplicated的時候使用轉移建構函式,那麼應該怎麼做呢?新標準給我們提供了一個解決方案:

	Test duplicated(std::move(reusable));

這個std::move()的作用是將左值轉換為右值。不過這裡要注意的一點是,如果我們在這裡使用了move的話,那麼後面我們就不能再對reusable進行操作了。因為轉移建構函式已經將reusable的成員變數arr指標置為空了。

講解完了轉移建構函式,其實轉移賦值語句(move assignment)與之同理,各位就自己研究一下吧。由於STL已經預設對所有的程式碼進行了右值引用的改寫,所以現在當你執行你之前寫過的程式碼時,你不需要做任何的更改,就會發現似乎更快了一些。

進一步探討左值和右值

我們來考慮下面的情景:

void doWork(TYPE&& param) {
	// ops and expressions using std::move(param)
}

這個程式碼是從Scott Meyers的演講當中摘取的。現在的問題是:** param是右值嗎? **答案是:不!param是一個左值。

這裡牽扯到一個概念,即事實上左值和右值與型別是沒有關係的,即int既可以是左值,也可以是右值。區別左值和右值的唯一方法就是其定義,即能否取到地址。在這裡,我們明顯可以對param進行取地址操作,所以它是一個左值。也就是說,但凡有名字的“右值”,其實都是左值。這也就是為什麼上面的程式碼當中鼓勵大家對所有的變數使用std::move()轉成右值的原因。

完美轉發(perfect forward)又是在做什麼

我們依然考慮一個例子:

template <typename T>
void func(T t) {
    cout << "in func" << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay" << endl;
    func(t);
}

int main() {
    relay(Test());
}

在這個例子當中,我們的期待是,我們在main當中呼叫relayTest的臨時物件作為一個右值傳入relay,在relay當中又被轉發給了func,那這時候轉發給func的引數t也應當是一個右值。也就是說,我們希望:relay的引數是右值的時候,func的引數也是右值;當relay的引數是左值的時候,func的引數也是左值

那麼現在我們來執行一下這個程式,我們會看到,結果與我們預想的似乎並不相同:

default constructor
in relay
copy constructor
in func
destructor
destructor

我們看到,在relay當中轉發的時候,呼叫了複製建構函式,也就是說編譯器認為這個引數t並不是一個右值,而是左值。這個的原因已經在上一節將結果了,因為它有一個名字。那麼如果我們想要實現我們所說的,如果傳進來的引數是一個左值,則將它作為左值轉發給下一個函式;如果它是右值,則將其作為右值轉發給下一個函式,我們應該怎麼做呢?

這時,我們需要std::forward<T>()。與std::move()相區別的是,move()會無條件的將一個引數轉換成右值,而forward()則會保留引數的左右值型別。所以我們的程式碼應該是這樣:

template <typename T>
void func(T t) {
    cout << "in func " << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay " << endl;
    func(std::forward<T>(t));
}

現在執行的結果就成為了:

default constructor
in relay
move constructor
in func
destructor
destructor

而如果我們的呼叫方法變成:

int main() {
    Test t;
    relay(t);
}

那麼輸出就會變成:

default constructor
in relay
copy constructor
in func
destructor
destructor

完美地實現了我們所要的轉發效果。

通用引用(universal reference)

現在一定有同學感到奇怪了,既然我剛才講的完美轉發就是怎麼傳進來怎麼傳給別人,那麼也就是說在後面這個例子當中我們傳進來的這個引數t竟然是一個左值!可是我們的引數表裡不是寫著T&&,要求接受一個右值嗎?其實不是這樣的。這裡就牽扯到一個新的概念,叫做通用引用。

通用引用(universal reference)是Scott Meyers在C++ and Beyond 2012演講中自創的一個詞,用來特指一種引用的型別。構成通用引用有兩個條件:

  1. 必須滿足T&&這種形式
  2. 型別T必須是通過推斷得到的

所以,在我們完美轉發這個部分的例子當中,我們所使用的這種引用,其實是通用引用,而不是所謂的單純的右值引用。因為我們的函式是模板函式,T的型別是推斷出來的,而不是指定的。那麼相應的,如果有一段這樣的程式碼:

template <typename T>
class TestClass {
	public:
		void func(T&& t) {} //這個T&&是不是一個通用引用呢
}

上面的這個T是不是通用引用呢?答案是不是。因為當這個類初始化的時候這個T就已經被確定了,不需要推斷。

所以,可以構成通用引用的有如下幾種可能:

  1. 函式模板引數(function template parameters)

     template <typename T>
     void f(T&& param);
    
  2. auto宣告(auto declaration)

     auto && var = ...;
    
  3. typedef宣告(typedef declaration)
  4. decltype宣告(decltype declaration)

那麼,這個通用引用與其他的引用有什麼區別呢?其實最重要的一點就是引用型別合成(Reference Collapsing Rules)。規則很簡單:

  1. T& & => T&
  2. T&& & => T&
  3. T& && => T&
  4. T&& && => T&&

簡單一點說,就是傳進來的如果是左值引用那就是左值引用,如果是右值引用那就是右值引用。但是注意,這個合成規則使用者是不允許使用的,只有編譯器才能夠使用這種合成規則。這就是為什麼上面的通用引用當中有一條要求是型別必須可以自動推導。這個合成規則其實就是型別推倒的規則之一。

這樣,我們就可以知道為什麼Scott Meyers在演講中建議大家在通用引用的情境下,儘可能使用forward()了,因為這樣可以在不改變語義的情況下提升效能。

template <typename T>
void doWork(T && param) {
	// ops and expressions using std::forward<T>(param)
}

後記

C++0x通過引入許多新的語言特性來實現了語言效能的提升,使得本來就博大精深的一門語言變得更加的難以學習。但是一旦瞭解,就會被語言精妙的設計所折服。參考資料中給出了更多的關於左值、右值、左值引用、右值引用、移動語義和完美轉發的例子。我自己實在是沒有精力看完所有的這些資料了,各位有興趣的話可以參閱。

參考資料


相關推薦

C++ 用於移動move轉發forward

    新標準重新定義了lvalue和rvalue,並允許函式依照這兩種不同的型別進行過載。通過對於右值(rvalue)的重新定義,語言實現了移動語義(move semantic)和完美轉發(perfect forwarding),通過這種方法,C++實現了在保留原有的語法並

C++移動語義

最近看了很多相關部落格,自己總結一下,單純根據在等號左邊還是右邊明顯判斷太過粗糙,我的大致理解如下: 判斷object是左值還是右值(三種方法): (1)object能否被取地址,即&object是否合法; (2)objec

C++11的引用——lvalueprvalue將亡xvalue

基本概念 C++11之前只有左值和右值的概念:lvalue,rvalue。左值可以取地址,右值不能取地址。 但是C++11之後又劃分的更加詳細了,分為左值(lvalue),純右值(prvalue)還有將亡值(xvalue),關係如下: 之前是lva

C++11特性--引用移動語義強制移動move()

1.右值引用   *右值:不能對其應用地址運算子的值。   *將右值關聯到右值引用導致該右值被儲存到特定的位置,且可以獲取該位置的地址   *右值包括字面常量(C風格字串除外,它表示地址),諸如X+Y等表示式以及返回值得函式(條件是該函式返回的不是引用)   *引入右值引用的主要目的之一是實行移動語義   E

C++11之引用與移動構造

添加 oooo 返回對象 oat 值引用 apc 定義 tco pri ----------------------------右值引用--------------------------------- 右值定義:   通俗來講,賦值號左邊的就是左值,賦值號右邊的就

C++11:引用、移動語意與完美轉發

在C++11之前我們很少聽說左值、右值這個叫法,自從C++11支援了右值引用之後,大多數人會像我一樣疑惑:啥是右值? 準確的來說: 左值:擁有可辨識的記憶體地址的識別符號便是一個左值。 右值:非左值。 左值引用:左值識別符號的一個別名,簡稱引用

C++11

在C++11中所有的值必屬於左值、右值兩者之一。 C++98左值(lvalue),可以放在賦值運算子=左邊的變數或者表示式,有名字,可以取地址。右值(rvalue),臨時變數值(非引用返回的函式返回值、表示式等)或者不跟物件關聯的字面量值(注意:字串字面值是左值,唯一例外),沒有名字,不能取

C++基礎知識----邏輯表示式求優化--逗號運算子與表示式

一、C++左值右值概念   左值:c++將變數名代表的單元稱為左值,而將變數的值稱為右值,左值必須是記憶體中可以訪問且可以合法修改的物件,因此只能是變數名,而不能是常量或表示式。即左值可以定址。   右值:將變數的值稱為右值,由運算操作(加減乘除,函式呼叫返回值等)所產生的中間結果(沒有名字的結果)稱為右

C++基礎知識----邏輯表達式求優化--逗號運算符與表示式

-- 沒有 加減乘除 p s 能夠 表示 操作 逗號 因此 一、C++左值右值概念   左值:c++將變量名代表的單元稱為左值,而將變量的值稱為右值,左值必須是內存中可以訪問且可以合法修改的對象,因此只能是變量名,而不能是常量或表達式。即左值可以尋址。   右值:將變量的值

c++ 特性: 引用與移動語義

c++11中引入了右值引用。 左值與右值 先區分左值與右值,這裡參考c++ 右值引用中對左值和右值區分方法: 左值和右值都是針對表示式,左值是指表示式結束後依然存在的持久物件,右值是指表示式結束時就不再存在的臨時物件。 一個區分左值和右值的簡便方法是

C++引用—臨時變數、引用引數const引用引用

如果實參與引用引數不匹配,C++將生成臨時變數。如果引用引數是const,則編譯器在下面兩種情況下生成臨時變數:          實參型別是正確的,但不是左值          實參型別不正確,但可以轉換為正確的型別 Double refcube(const

表示式(C++學習)

左值右值是表示式的屬性,該屬性稱為 value category。按該屬性分類,每一個表示式屬於下列之一: lvalue left value,傳統意義上的左值 xvalue expiring value, x值,指通過“右值引用”產生的物件

C++11的引用

右值引用 語法 factor htm 類型 har start 移動 hand 右值引用是C++11 引入的新特性。它解決了兩類問題:實現移動語義和完美轉發。本文大絕大部分內容,來自於文章:http://kuring.me/post/cpp

java 比較兩個物件屬性變化情況用於記錄日誌使用

package com.cdc.console.controller; import java.beans.PropertyDescriptor; import java.lang.reflect.Field; import java.lang.reflect.Method; public cl

c/c++ 拷貝控制 與const引用

拷貝控制 右值與const引用 背景:當一個函式的返回值是自定義型別時,呼叫側用什麼型別接收?? 1,如果自定義型別的拷貝建構函式的引數用const修飾了:可以用下面的方式接收。 Test t2 = fun(t1); 2,如果自定義型別的拷貝建構函式的引數沒有用const修飾了:必須用下面的方式接收

C++ 0x之引用

C++ 0x標準出來有一段時間了,一直沒時間看,導致最近看一些程式碼完全不明白是什麼意思了,只好硬著頭皮來看了。這次先說一個簡單的,右值引用。關於引用,大家都很清楚了,只會做一標識,而不會拷貝物件,例如:int a = 0; int& b = a; 這個就是傳統的引用,如今也稱為左值引用,一般我們將引

C++ 11之 && 引用

最近在看cocos2dx的原始碼,發現了一個模板類有一個奇怪的語法&&: inline RefPtr(RefPtr<T> && other) {

C++11 vector 引用使用

常用的容器,比如vector  我們在儲存自定義物件的時候常常為了避免拷貝構造需要直接儲存指標,當然在不關心效率的場景那就隨各位了.現在C++11 有右值引用可避免這類問題,如下:struct GsFeedbackItem { GsFeedbackItem(GsFeedba

C++中的引用"&&"

在檢視STL原始碼過程中,看到有的函式的引數列表中,對引數前面有"&&"這樣的修飾符。甚為不解。 在網上查閱了相關的資訊,才發現,這是一種新的引數的引用方法,稱之為“右值引用”,區別於左值引用的“&”。 參見MSDN-右值引用。這是VisualStu

的區別以a++++a為例

左值(lvalue)和右值(rvalue)最先來源於編譯。在C語言中表示位於賦值運算子兩側的兩個值,左邊的就叫左值,右邊的就叫右值。 定義: 左值指的是如果一個表示式可以引用到某一個物件,並且這個物件是一塊記憶體空間且可以被檢查和儲存,那麼這個表示式就可以作為一個左值。 右值指的是引用了一個儲存在某個記憶