C++0x, rvalue reference, move semantics, RVO, NRVO — 我們到底要什麼

來源:互聯網
上載者:User

 

Visual C++ 2010 (VC10) 實現了一些頗有用處的 C++0x 新特性,其中就包括(萬眾期待的)rvalue reference

本文不打算詳述 rvalue reference 是什麼了,關於這方面的文章已經不少,讀者可以自己搜尋來看看。我要說的是,今天我做了一些非常簡單的關於
rvalue reference 的效能測試,其中有非常鼓舞人心的部分,也有 C++ 一以貫之的複雜和越來越複雜的部分。

好訊息:效能的極大提升

從原理上講,rvalue reference 使得 move semantics
成為可能,從而讓編譯器可以從rvalue對象中“偷走”資源,而不是拷貝資料,在很多情況下,這會帶來效能的極大提升。

測試代碼很簡單:比較 copy 和 move 一個 vector<string> 對象的時間:

#include <string><br />#include <vector><br />#include <iostream><br />#include <ctime><br />using namespace std;<br />vector<string> make_vector()<br />{<br /> vector<string> v(1, string("this is a string"));<br /> return v;<br />}<br />int main()<br />{<br /> vector<string> src(make_vector()), s(make_vector());<br /> clock_t start, end;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> vector<string> s = src;<br /> }<br /> end = clock();<br /> cout << "Vector copy ctor takes: " << end - start << endl;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> vector<string> s = move(src);<br /> }<br /> end = clock();<br /> cout << "Vector move ctor takes: " << end - start << endl;<br /> return 0;<br />}<br /> 

在我的相當老舊的筆記本上,Release 版本的輸入是這樣的

 

Vector copy ctor takes: 4562 
Vector move ctor
takes: 4


看來相當鼓舞人心不是?對於一個還不是太大太複雜的對象,move 比 copy 竟然能有千倍的效能提高!如果把 vector 的尺寸加大,move
版本的執行時間並不會有多大區別,而 copy 版本的執行時間則會隨對象增大而延長。我們以後寫一個函數來構造對象時,再也不需要使用醜陋的類似

 

void make_vector(vector<string>& out) 


之類的辦法來避免對象拷貝,我們只需要在返回點或者調用點加上 move !

好吧,如果夠細心,你會發現上面的代碼玩了個花招:它沒有在迴圈中調用 make_vector ,它只是把值儲存起來,然後採用 copy 和 move
。第一個原因是如果在迴圈中調用 make_vector ,我們測得的大多數時間就都在構造上了,copy 和 move
之間的區別無法顯示出來;第二個原因,後面會談到。

如果你一定要看看在迴圈中調用 make_vector 的結果,也就是說把測試代碼中的

        vector<string> s = src;

        vector<string> s =
move(src);

分別替換為

        vector<string> s =
make_vector();

        vector<string> s =
move(make_vector());

在我這裡運行結果是這樣的

 

Vector copy ctor takes: 7928 
Vector move ctor
takes: 3587


很明顯,兩個迴圈多執行的時間大致相同,那就是構造對象的時間了。

在 C++ 裡,凡事都有例外

如果我們把測試對象換成 string,在 string 的大小比較大的時候,結果大體相似,例如下面的程式測試 copy 和 move 大小為 20 的
string:

 

#include <string><br />#include <iostream><br />#include <ctime><br />using namespace std;<br />int main()<br />{<br /> string src(20, 'e');<br /> clock_t start, end;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> string s = src;<br /> }<br /> end = clock();<br /> cout << "String copy ctor takes: " << end - start << endl;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> string s = move(src);<br /> }<br /> end = clock();<br /> cout << "String move ctor takes: " << end - start << endl;<br /> return 0;<br />} 

在我這裡,輸出差不多是

 

String copy ctor takes: 1728
String move ctor
takes: 40


由於拷貝 string 是一個比較快的操作,所以差距沒有那麼大,但仍然相當明顯。

到這裡,你一定會說“好,從此我一定會在代碼裡讓 rvalue reference 和 move 滿天飛”

然而,如果你把 string src 的尺寸縮小一點,到達15的時候,情況變了,輸出差不多是

 

String copy ctor takes: 40

String move ctor
takes: 42


為什麼拷貝一個15個字元的 string 比拷貝20個字元快那麼多?讀讀 string 類就會發現,string 類會給自己預分配16位元組的
buffer,如果拷貝對象不超過15個字元,就不需要重新分配空間,只需要調用 memcpy 就可以,這是一個相當高效的操作。而 move
在這種情況下則選擇不進行指標交換,而是調用 memmove,這往往比 memcpy 要慢一些。

我們得出什麼結論呢?有幾個

  1. 拷貝,尤其是少量資料的拷貝,其實很高效
  2. 動態記憶體分配相當昂貴,從上面的結果可以大致推斷出,分配一片空間大概比拷貝20個位元組多花40倍的時間
  3. 小字串(15個字元以下)的拷貝已經足夠最佳化了

我還沒有打算到此打住,如果就這麼簡單,那就不是 C++ 了。如果仔細考察對象的 copy 和 move ,事情會更加複雜。

傳回值和 RVO

寫一個很簡單的類 Foo,它的作用是幫我們瞭解 copy 和 move 之間,到底發生了什麼事。

#include <iostream><br />using namespace std;<br />struct Foo<br />{<br /> Foo() { cout << "Foo ctor" << endl; }<br /> Foo(const Foo&) { cout << "Foo copy ctor" << endl; }<br /> void operator=(const Foo&) { cout << "Foo operator=" << endl; }<br /> Foo(Foo&&) { cout << "Foo move ctor" << endl; }<br /> ~Foo() { cout << "Foo dtor" << endl; }<br /> void bar() {}<br />};<br />Foo make_foo()<br />{<br /> return Foo();<br />}<br />int main()<br />{<br /> cout << "Copy from rvalue: " << endl;<br /> Foo f1 = make_foo();<br /> cout << "-----------------------" << endl;<br /> cout << "Move from rvalue: " << endl;<br /> Foo f2 = move(make_foo());<br /> cout << "-----------------------" << endl;<br /> return 0;<br />} 

輸出是什麼呢?

 

Copy from rvalue:
Foo ctor

-----------------------
Move from rvalue:
Foo ctor
Foo move ctor

Foo dtor
-----------------------
Foo dtor
Foo dtor


怎麼回事?當我們 copy 的時候,僅僅只調用了一個 constructor,甚至沒有調用 copy constructor ,而我們 move
的時候,卻需要調用一個 constructor,一個 move constructor 和一個 destructor。

Move 的情況比較容易理解,分為三步:

  1. 調用 constructor 構造一個臨時對象
  2. 從這個臨時對象進行 move constructing
  3. 銷毀這個臨時對象

而 copy 為什麼這麼省事?因為編譯器會使用 RVO(return value optimization),在傳回值是一個 rvalue
的時候,這個對象會直接構造在接收傳回值的對象空間中,從而減少了拷貝。而相反 move 則會阻礙編譯器進行 RVO,反而增加了兩個函數調用,如果
destructor 涉及動態空間的釋放以及一些耗時的操作,那可是偷雞不成蝕把米。

那我們又得到什麼結論呢?

  1. RVO 是個好東西
  2. 在有 RVO 的時候,move semantics 未必比較快

我還沒有打算住手,好戲在後面:

NRVO

如果把函數 make_foo 改成這個模樣:

Foo make_foo()<br />{<br /> Foo f;<br /> return f;<br />} 

在 Debug 模式運行一下,結果就更加有趣了:

 

Copy from rvalue:
Foo ctor
Foo move ctor

Foo dtor
-----------------------
Move from rvalue:
Foo ctor

Foo move ctor
Foo dtor
Foo move ctor
Foo dtor

-----------------------
Foo dtor
Foo dtor


為什嗎?為什麼我們明明打算 copy ,卻調到了 move constructor;而 move 的時候,卻調用了兩個 move constructor
?我們一條條的分析。

copy

首先,無論 copy 還是 move,函數 make_foo 中 的 Foo f 都會導致一個 constructor 。

在這裡 f 是一個 lvalue,所以在 copy 時,編譯器沒法對它進行 RVO,而在 Debug 模式下其它的最佳化又關掉了,於是只好用傳回值構造對象
f1。

這裡有新東西出現了:新的 C++ 標準要求,在構造返回的臨時對象時,如果不使用 RVO,而類定義了 move
constructor,優先使用 move constructor。所以我們看到的 move constructor
調用,是用來初始化臨時對象的。

而有了這個臨時對象,編譯器倒是可以直接把它扔給 f1,從而節省一道從臨時對象到 f1 的拷貝。

之後局部對象 f 超出作用範圍,被銷毀。

move

構造對象 f。

和 copy 一樣,用 move constructor 構造臨時對象。

這裡問題來了:加入 move() 調用使得編譯器無法最佳化掉臨時對象到 f2 的拷貝,於是編譯器退而求其次,用 move constructor 來初始化
f2。

局部對象 f 被銷毀。

臨時對象被銷毀。

好的,我們看到 move() 又杯具了,如果是 Release 模式,會如何呢?結果是這樣的:

 

Copy from rvalue:
Foo
ctor
-----------------------
Move from rvalue:
Foo ctor
Foo move
ctor
Foo dtor
-----------------------
Foo dtor
Foo dtor


這裡 copy 和 move 雙方各減少了一個對象產生。是哪一個呢?答案是臨時對象。這要歸功於編譯器的 NRVO (Named Return Value
Optimization),這種最佳化讓編譯器能夠在返回一個 lvalue 的情況下,也減少一個對象 copy(或 move),但是這並沒能最佳化掉對於 f2
的構造。

結論

Rvalue reference, move semantics 都是好東西,std::move() 也是好東西,但是用得不對可能會適得其反。

事實上,在有了 move semantics 之後,最高效的的返回正是我們熟悉的形式:

Foo make_foo()<br />{<br /> Foo f;<br /> return f;<br />}<br />……<br />Foo f1 = make_foo(); 

因為編譯器會儘可能的使用 RVO 和 NRVO,而在無法使用這些最佳化時,由於 make_foo 返回一個 rvalue ,編譯器仍會儘力調用 move
constructor,而只有這些都失敗了,編譯器才會採取我們熟悉的 copy constructor --- 總之,不會比這個更壞了。

 

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.