C++右值引用和移動(for beginners)

Attention:this blog is a translation of https://www.internalpointers.com/post/c-rvalue-references-and-move-semantics-beginners ,which is posted by @internalpoiners.

一、前言

在我的前一篇文章里,我解釋了右值背后的邏輯。核心的思想就是:在C++中你總會有一些臨時的、生命周期較短的值,這些值無論如何你都無法改變。
令人驚喜的是,現代C++(通常指C++0x或者更高的版本)引入了右值引用(rvalue reference)的概念:它是一個新的可以被綁定到臨時對象的類型,允許你改變他們。為什么呢?
讓我們先看看下面的代碼:

int x = 666;                    // (1)
int y = x + 5;                  // (2)

std::string s1 = "hello ";
std::string s2 = "world";
std::string s3 = s1 + s2;       // (3)

std::string getString() {
  return "hello world";
}
std::string s4 = getString();   // (4)

在(1)處,字面常量(literal constant)666是一個右值:它沒有具體的內存地址,除了程序運行時的一些臨時寄存器,它需要被存儲在左值x中留待使用。在(4)處也有著類似的情況,但是這里右值不是硬編碼(hard-coded)的,而是由函數getString()返回的。然而,與(1)處一樣,這個臨時值也需要被存儲在一個左值s4中,留待將來使用。
(2)和(3)處看上去更微妙一些:編譯器創建了一個臨時對象來存放+操作符的結果,作為一個臨時值,輸出毫無疑問是一個必須被存放在某處的右值。在這里,我分別將結果放入到ys3中。

二、右值引用的魔力

傳統的C++規則規定:只有存儲在const變量(immutable)中的右值才能獲取它的地址。從技術上來說,你可以將一個const lvalue綁定(bind)到一個rvalue上。看下面的代碼:

int& x = 666;       // Error
const int& x = 666; // OK

第一個操作是錯誤的,它是一個使用int類型的右值來初始化non-constint&類型的非法操作。第二個操作正確,當然,x是一個常量,你不能改變他。(譯者按:注意,常量引用只是規定無法通過該引用改變引用的對象,如果該對象有其他非常量引用或者指針,通過這些改變該對象仍然是合法的)
C++ 0x引入了一個新的類型——右值引用(rvalue reference),通過在類型名后放置&&來表示右值引用。這些右值引用讓你可以改變一個臨時對象的值,看上去好像他去掉了上面第二行中的const了一樣。
讓我們用這個新玩具來玩耍一番:

std::string   s1     = "Hello ";
std::string   s2     = "world";
std::string&& s_rref = s1 + s2;    // the result of s1 + s2 is an rvalue
  s_rref += ", my friend";           // I can change the temporary string!
std::cout << s_rref << '\n';       // prints "Hello world, my friend"

這里我們創建了兩個簡單的字符串s1s2,我將它們連接并把結果放入std::string&& s_rref中。現在s_rref是一個對于臨時對象的一個引用,或者稱之為右值引用。這個引用沒有const修飾,所以我可以根據需求隨意修改他而不需要付出任何代價。如果沒有右值引用和&&符號,想要完成這一步是不可能的。為了更好地區分右值引用和一般引用,我們將傳統的C++引用稱作左值引用(lvalue reference)。
乍一看右值引用毫無用處,然而它為移動語義(move semantics)的實現做了鋪墊,移動語義可以先出提升你的應用的表現。

三、移動語義——風景秀麗的路線

移動語義(以下簡稱move)是一個最佳移動資源的方法,它避免了不必要的歷史對象的拷貝,這些都是基于右值引用的。在我看來,理解什么是move最好的方法就是構建一個動態資源(即動態分配的指針)的包裝類(wrapper class)并且觀察該類的對象被移入移出函數時發生了什么。記住,move不只是用于類!
讓我們來看下面的例子:

class Holder
{
  public:

    Holder(int size)         // Constructor
    {
      m_data = new int[size];
      m_size = size;
    }

    ~Holder()                // Destructor
    {
      delete[] m_data;
    }

  private:

    int*   m_data;
    size_t m_size;
};

這是一個處理動態內存塊的類,除了動態內存分配(allocation)部分之外沒什么特別的。當你選擇自己管理內存時你需要遵守所謂的rule of three。規則如下:如果你的類定義了下面所說的方法中的一個或者多個,它最好顯式定義所有的三個方法:

  • 析構函數(destructor)
  • 拷貝構造函數(copy constructor)
  • 拷貝復制運算符(copy assignment operator)

(如果你不定義這些函數)C++的編譯器會以默認的方式生成這些函數以及構造函數和其他我們現在沒有考慮的函數。不幸的是,默認的函數對于處理動態資源是完全不夠的。實際上,編譯器無法生成向上面那樣的構造函數,因為它不知道我們的類的邏輯。

1)實現拷貝構造函數

讓我們先依照Rule of Three并實現拷貝構造函數。正如你所知道的,拷貝構造函數從另外一個已經存在的對象來構造新的對象,例如:

Holder h1(10000); // regular constructor
Holder h2 = h1;   // copy constructor
Holder h3(h1);    // copy constructor (alternate syntax)

一個拷貝構造函數可能長成這樣:

Holder(const Holder& other)
{
  m_data = new int[other.m_size];  // (1)
  std::copy(other.m_data, other.m_data + other.m_size, m_data);  // (2)
  m_size = other.m_size;
}

這里我使用一個已經存在的對象other來初始化一個新的Holder對象,我創建了一個同樣大小的數組并且我將other里面m_data的數據拷貝到this.m_data中。

2)實現賦值運算符

現在我們來實現賦值運算符,它用于將一個已存在的對象替換為另一個已存在的對象。例如:

Holder h1(10000);  // regular constructor
Holder h2(60000);  // regular constructor
h1 = h2;           // assignment operator

一個賦值運算符的定義可能長這樣:

Holder& operator=(const Holder& other) 
{
  if(this == &other) return *this;  // (1)
  delete[] m_data;  // (2)
  m_data = new int[other.m_size];
  std::copy(other.m_data, other.m_data + other.m_size, m_data);
  m_size = other.m_size;
  return *this;  // (3)
}

首先(1)處避免了將自己賦值給自己(self-assignment),既然我們要用另一個對象來替換當前的對象,我們需要清除當前對象中所有的數據(2),剩下的就和拷貝構造函數中的一樣了。按照慣例,我們返回該對象的引用。
拷貝構造函數和賦值運算符的關鍵點就是它們都接受一個const的對象的引用作為參數并且生成了一個它們所屬類的一個副本。
輸入的對象時常量引用,當然無法改變!

四、現有類設計的限制

我們的類類很好,但是它缺少一些優化。考慮下面的函數:

Holder createHolder(int size)
{
  return Holder(size);
}

它用傳值的方式返回了一個Holder對象。我們知道,當函數返回一個值時,編譯器會創建一個臨時且完整的對象(右值)。現在,我們的Holder是一個重量級(heavy-weight)的對象,因為它有著內部的內存分配,這是一個相當費事的任務——以現有的類設計返回這些東西的值會導致多次內存分配,這并不是一個好主意。如何得出這個結論?讓我們看下面的代碼:

int main()
{
  Holder h = createHolder(1000);
}

createHolder()創建的臨時對象被傳入拷貝構造函數中,根據我們現有的設計,拷貝構造函數通過拷貝臨時對象的數據分配了它自己的m_data指針。這里有兩次內存分配:

  • 創建臨時對象
  • 拷貝構造函數調用

同樣的拷貝過程發生在賦值操作符中:

int main()
{
  Holder h = createHolder(1000); // Copy constructor
  h = createHolder(500);         // Assignment operator
}

我們的賦值運算符清除了對象的內存,然后通過從臨時對象中拷貝數據,為賦值的對象從頭開始分配新的內存。在這里也有兩次內存分配:

  • 臨時對象創建
  • 調用賦值運算符

拷貝的次數太多了!我們已經有了一個完整的(fully-fledged)臨時對象,它由createHolder()函數創建。它是一個右值,如果在下一個指令前不被使用將會消失。所以為什么在構造或者復制時我們不使用move而是選擇重復的拷貝呢?
在上古C++中,我們沒辦法做這樣的優化,返回一個重量級對象的值是無用的。幸運的是,在C++11后,我們可以(并且鼓勵)使用move來優化我們的類。簡而言之,我們將從現有的對象處偷取他們的數據而不是做一些毫無意義的克隆。不要拷貝,總是使用move,因為移動的代價更加的低。

五、用右值引用實現move semantics

讓我們用move來為我們的類增光添彩!我們的想法就是增加新的版本的拷貝構造函數和賦值運算符,這樣我們就可以將臨時對象的數據直接偷過來。“偷”的意思是改變對象中數據的擁有者,我們怎么修改一個臨時變量呢?當然是使用右值引用!
在這里我們通常遵守另一個C++規則——Rule of Five。它是Rule of Three的擴展,額外聲明了一個規則:任何需要move的類都要聲明兩個額外的成員函數:

  • 移動構造函數(move constructor):通過從臨時對象偷取數據來構建一個新的對象
  • 移動賦值運算符(move assignment operator):通過從臨時對象偷取數據來替換已有對象的數據

1)實現移動構造函數

一個典型的移動構造函數:

Holder(Holder&& other)     // <-- rvalue reference in input
{
  m_data = other.m_data;   // (1)
  m_size = other.m_size;
  other.m_data = nullptr;  // (2)
  other.m_size = 0;
}

它使用一個右值引用來構造Holder對象,關鍵部分:作為一個右值引用,我們可以修改它,所以讓我們先偷他的數據(1),然后將它設置為nullptr(2)。這里沒有深層次的拷貝,我們僅僅移動了這些資源。將右值引用的數據設置為nullptr是很重要的,因為一旦臨時對象走出作用域,它就會調用析構函數中的delete[] m_data,記住了嗎?通常來說,為了讓代碼看上去更加的整潔,最好讓被偷取的對象的數據處于一個良好定義的狀態。

六、實現移動賦值運算符

移動賦值運算符有著同樣的邏輯:

Holder& operator=(Holder&& other)     // <-- rvalue reference in input  
{  
  if (this == &other) return *this;

  delete[] m_data;         // (1)

  m_data = other.m_data;   // (2)
  m_size = other.m_size;

  other.m_data = nullptr;  // (3)
  other.m_size = 0;

  return *this;
}

我們先清理已有對象的數據(1),再從其它對象處偷取數據(2)。別忘了把臨時對象的數據設置為正確的狀態!剩下的就是常規的賦值運算所做的操作。
既然我們有了新的方法,編譯器就會檢測你到底是在使用臨時對象(右值)創建一個對象還是使用常規的對象(左值),并且它會根據檢測的結果觸發更加合適的構造函數(或者運算符)。例如:

int main()
{
  Holder h1(1000);                // regular constructor
  Holder h2(h1);                  // copy constructor (lvalue in input)
  Holder h3 = createHolder(2000); // move constructor (rvalue in input) (1) 

  h2 = h3;                        // assignment operator (lvalue in input)
  h2 = createHolder(500);         // move assignment operator (rvalue in input)
}

七、何時、何處使用move semantics

move提供了一個更加智能的傳遞重量級對象的方法。你只需要創建你的重量級資源一次然后再任何需要的地方移動即可。就像我之前說的,move不只是用于類,只要在你需要改變一個資源的擁有者時都可以使用move。**記住,跟指針不一樣的是,move不會分享任何東西,如果對象A從對象B中偷去了數據,對象B中的數據就不再存在了,因此也就不再合法了。我們知道在處理臨時對象時這沒有問題,但是在從常規對象身上偷取數據時就需要慎重了。

1)我嘗試了你的代碼:移動構造函數從來沒有被調用!

你是對的,如果你運行上面的最后一個代碼,你會注意到移動構造函數在(1)處沒有被調用,常規的構造函數被調用了。這是因為一個被稱作Return Value Optimization(RVO)的技法。現代編譯器能夠檢測出你返回了一個對象的值,并且為此應用一種返回的快捷方式來避免無意義的拷貝。
你可以讓編譯器不使用這個優化。例如,GCC支持fno-elide-constructors標記,用這個標記來編譯程序將使得構造函數和析構函數的調用次數明顯提高。

2)為什么有了RVO我們還需要自己實現move semantics?

RVO僅僅針對返回值(輸出),不包括函數參數(輸入)。有許多地方你會將可移動的對象作為輸入參數傳入函數,這時候就是移動構造函數和移動賦值運算符發揮作用的時候了。標準庫(Standard Library)在升級到C++11后,所有的算法和容器都被擴展以支持move。所以如果你使用符合Rule of Five的類和標準庫,你將會獲得重要的優化提升。

八、我可以移動左值嗎?

是的,通過標準庫中的工具函數std::move,你可以移動左值。它被用來將左值轉化為右值,假設我們想要從一個左值盜取數據:

int main()
{
  Holder h1(1000);     // h1 is an lvalue
  Holder h2(h1);       // copy-constructor invoked (because of lvalue in input)
}

由于h2接收了一個左值,拷貝構造函數被調用。我們需要強制調用移動構造函數從而避免無意義的拷貝,所以我們這樣做:

int main()
{
  Holder h1(1000);           // h1 is an lvalue
  Holder h2(std::move(h1));  // move-constructor invoked (because of rvalue in input)
}

在這里,std::move將左值h1轉化為一個右值:編譯器看見輸入變成了右值,所以調用了移動構造函數。h2將會在構造時從h1處偷取數據。

九、最終的筆記和可能的提升

這篇文章很長但是我僅僅抓住了move的表象。下面列出的是我會在未來深入研究的額外概念。

1)在基礎的Holder類中我們使用了RAII

Resource Acquisition Is Initialization(RAII)是一個C++技術,你可以在資源(文件、socket、數據庫連接、分配的內存等)周圍包裝類。這些資源可以在類的構造函數中初始化并在類的析構函數中清除,這會避免資源泄露。

2)用noexcept標記你的移動構造函數和移動賦值運算符

C++11關鍵詞noexcept表示這個函數不會拋出異常。一些人認為移動構造函數和移動賦值運算符永遠不要拋出異常。這是合理的,因為除了復制數據和和設置nullptr之外(這些都是不會拋出異常的操作)不需要分配內存或者做其它工作。

3)使用copy-and-swap的更深入的優化和更好的異常安全性

Holder中所有的構造函數和賦值運算符都充滿了重復的操作,這不是很好。此外,如果在拷貝運算符中進行(內存)分配時,如果拋出了異常,那么源對象就會變成一個不好的狀態。copy-and-swap解決了這兩個問題,但是增加了一個新方法。

4)perfect forwarding

這項技術允許你在多個模板和非模板函數之間移動數據,而不需要強類型轉換。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,565評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,115評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,577評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,514評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,234評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,621評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,641評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,822評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,380評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,128評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,319評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,879評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,548評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,970評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,229評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,048評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,285評論 2 376

推薦閱讀更多精彩內容

  • 本文根據眾多互聯網博客內容整理后形成,引用內容的版權歸原始作者所有,僅限于學習研究使用,不得用于任何商業用途。 左...
    深紅的眼眸閱讀 11,312評論 1 12
  • 1. C++基礎 大多數編程語言通過兩種方式來進一步補充其基本特征1)賦予程序員自定義數據類型的權利,從而實現對語...
    王偵閱讀 767評論 0 3
  • 前言 把《C++ Primer》[https://book.douban.com/subject/25708312...
    尤汐Yogy閱讀 9,532評論 1 51
  • 右值引用是C++11中新增的一種新的引用類型,它可以通過減少內存的重復申請、拷貝和釋放,有效的提高C++程序的性能...
    georgeguo閱讀 2,011評論 0 4
  • 兒子在寫作業,陪他的空檔我來完成今天的文字,感覺蠻好的。 今天讀的女子是蕭紅,認識蕭紅是從小學課文《祖...
    王艷粉閱讀 388評論 0 0