2013年6月23日

[C++] rvalue reference 初入門

rvalue reference 是 C++11 裡的一項新功能,這項新功能允許程式設計師可以 reference rvalue,借此避免不必要的複製,因此可提高程式的效能。藉由 rvalue reference 也可比先前更加容易的撰寫 move semantic,是個相當不錯的特色。然而要好好利用這東西其實有點難度,其中一個很大的問題是:哪些東西是 rvalue 阿?




lvalue (左值)與 rvalue (右值)雖然最早開始似乎是代表二元運算子左右兩邊的運算元,不過在 C++ 裡面其意義已截然不同,現在要稱呼二元運算子左右兩端的運算元要用 lhs (left hand side) 跟 rhs (right hand side) 比較不會引起誤會,那原本的 lvalue 跟 rvalue 現在變成什麼意思咧? 粗略的解釋如下:

lvalue: 有名稱的、在記憶體中有實際位置可供程序員加以存取物件
rvalue: 沒有名稱的暫時物件,離開該敘述即被摧毀的物件,舉例來說:兩個 int 變數 a, b 相加的結果 (a+b) 就是 rvalue,因為這個結果 1. 他是沒有名稱的暫時物件 2. 離開這行敘述後這物件就不見了,你再也沒有其他方法可以存取到它。

以上,是 lvalue 與 rvalue 的一個很簡單扼要且不精確的說明。在以前 rvalue 是沒有辦法 reference 的,所以你想要保存 rvalue 的值只能把他 copy 到另一個物件裡去,若今天 rvalue 的型態是像上面的例子一樣是 primitive type 那其實沒什麼影響,但如果是個使用者用 struct 或 class 自訂的型別那問題就大了,因為這些型別的大小可能很大,所以複製時是很花時間的,舉個例子來說,今天我想要寫一個擁有大量向量運算的程式,所以我定義了一個向量運算類別叫 Vector 用來處理向量的各種運算。

例子中我定義的 Vector 是一個具有 N 個 element 的向量,並且多載了 operator+ 用以支援向量間的加法。實做 operator+ 的方法就是慣例先實做 operator+=,再藉由 operator+= 來實做 operator+,這邊我就不多說了,相關資料很好找。讓我們把重點放在 operator+ 上,因為習慣上加法不會修改到 lhs 的值,為此我們先創另一個暫時變數 temp 複製 lhs 的值,再讓 temp 去跟 rhs 相加,並回傳 temp,再來因為 temp 是一個只存在於多載的 operator+ 中的區域變數,所以回傳型態不能是 reference。從這個例子中我們可以發現,做一次加法我們需要 2 次的複製:1 次是把 lhs 複製到 temp,另一次是把 temp 的值複製到回傳值,因為一離開 operator+ 的 } 後 temp 馬上就會消失了,所以要先做一個備份當作回傳值。當然,這兩次複製都是必須的,所以還可以接受,難以接受的地方再下面:

考慮一個 expression: c = a + b,其中 a, b, c 的型態都是 Vector。從上面的分析我們可以知道做完 a + b 需要兩次的複製,但是 c = a + b 還需要第三次:那就是把 a + b 的回傳值複製給 c。
如果仔細想想就會發現其實第三次的複製根本是多餘的,但因為 a + b 的回傳值是個暫時物件,是個只要沒有馬上保存起來就會消失的 rvalue,所以只好花時間再多複製一次把它保留下來。不過現在的 compiler 有針對這點做最佳化,叫做 Return Value Optimization (RVO),其功能以上面的例子來說,可以想像成就是直接把 a + b 的回傳值改名叫做 c,藉此省掉一次複製時間,但壞消息是不見得每個 compiler 都會有 RVO,而且要讓 compiler 可以利用 RVO 幫你做最佳化其實是有許多細節要注意的,細節這邊不多談,因為不是這篇文章的重點。

上面那個例子其實可以變得更慘,考慮另一個 expression: d = a + b + c,一樣 a, b, c, d 的型態都是 Vector。以這個例子來說,做完 a + b 需要 2 次,假設其結果叫 t,往下做 t + c 又需要兩次,最後把結果複製到 d 又要一次,因此總共需要五次的複製。如果細部分解的話是這樣:

t = a + b
Vector temp1 (*this) --> 第一次複製
temp += rhs; 
return temp1 --> 第二次複製

d = t + c
Vector temp2 (*this) --> 第三次複製
temp += rhs; 
return temp2 --> 第四次複製

最後 t + c 的結果複製到 d 是第五次複製,這邊為了方便說明,把 temp 標上了 1 與 2 用以區別是做第幾次加法時用到的 temp。如果考慮使用 RVO 的話可以縮減到只要 2 次,細部分解如下:

t = a + b
Vector temp1 (*this) --> 第一次複製
temp += rhs; 
return temp1 --> 採用 RVO,不用複製

d = t + c
Vector temp2 (*this) --> 第二次複製,這邊的 *this 因為 RVO,其實就是 temp1
temp += rhs; 
return temp2 --> 採用 RVO,不用複製

最後 t + c 的結果因為 RVO,直接把 temp2 變成 d,也不用複製,所以總共有兩次複製。不過如果仔細看會發現,其實第二次的複製是多餘的,因為 t 是 rvalue,就算他被修改了也沒差,根本沒有必要做那次複製。然而問題在於先前的 C++ 因為沒辦法在程式中區別到底是 lvalue 還是 rvalue,因此保險起見都是要當作 lvalue,也因此第二次的複製是跑不掉的。所以所謂的 rvalue reference 簡單來就是讓我們可以直接把 rvalue 抓來用。

舉個例子,今天我們設計另一個 class 叫 VectorWithRvalue,讓我們直接看到第 60 ~ 64 行這個新加的 function。首先可以發現 lhs 的型態是 VectorWithRvalue&&,&& 在這邊所代表的就是 rvalue reference,也就是說它是 reference 到一個 rvalue,新增這個 function 的效果讓我們細部分解來看一下:

t = a + b
Vector temp1 (*this) --> 第一次複製
temp += rhs; 
return temp1 --> 採用 RVO,不用複製

這邊不會變,因為 a 跟 b 是個具有實際記憶體位置的物件,並非 rvalue,重點在第二次加法:執行第二次加法時因為 t 是個 rvalue,根據 Overload Resolution 要選出最匹配的重載函式,此時會選到我們新增的那個 global function,也就是第一個參數型態是 VectorWithRvalue&&,原因在於這型態是 rvalue reference,而 t 也是個 rvalue。其效果如下:

d = t + c
lhs += rhs;
return std::move(lhs);

紅色標起來的那行就是重點所在,因為此時 lhs 是  rvalue,我們不用為它保留複本,因為被修改了也不會被發現,所以直接抓來做加法即可,也因此我們就少做了一次複製。當今天物件的大小很大時其影響相當大,以這裡的例子來說,Vector 跟 VectorWithRvalue 是個具有 256 個 element 的物件,少一次複製意味著我們少了 1 次建構成本、1 次解構成本,還有 256 個 element 的複製時間,當今天這種情形很多時差異就很大了。下面是我用 ideone 分別跑出來的結果:尚未使用 rvalue reference 以及 使用了 rvalue reference

這邊另一個值得注意的地方在這行:std::move(lhs),他的用途很簡單,因為 lhs 在這邊是個 lvalue,我們利用了 std::move 把他轉成 rvalue 後在回傳,這樣這個 rvalue 就可以繼續利用,換言之如果今天有個 expression 是:f = a + b + c + d + e,細部分解來看的話會變成:

t1 = a + b
Vector temp1 (*this) --> 第一次複製
temp += rhs; 
return temp1 --> 採用 RVO,不用複製

t2 = t1 + c
lhs += rhs;
return std::move(lhs);

t3 = t2 + d
lhs += rhs;
return std::move(lhs);

f = t3 + e
lhs += rhs;
return std::move(lhs);

其中 t3 是 t2 的 reference,t2 又是 t1 的 reference,也就是說相比於以往的做法,利用 rvalue reference 可以讓我們省下 3 次的複製時間,其效果是很驚人的。

rvalue reference 其用法當然不只是如此,考慮最原始的 c = a + b,一樣我們令 a + b 的結果叫做 t。但因為 VectorWithRvalue 的 member data 其實就只是一個 pointer,再加上 t 是 rvalue,馬上就會消失的,那為何我們不直接把 t 裡的 pointer 偷過來給 c 就就好了? 這樣我們不但少了一次複製的時間,也少了刪除舊空間、配置新空間的時間呢! 而所需要的成本就僅僅只是複製一個 pointer 所需時間。 rvalue reference 一樣的讓我們可以輕而易舉的做到這件事:在 VectorWithRvalue 裡我新增了一個 constructor 叫 move constructor,其作法很簡單,就是把現在這個物件的 pointer 指向 rhs 目前所指到的空間,並且為了不讓 rhs 這個 rvalue 在消失的同時把空間給釋放了,所以要把他原本的指標改成 null,這樣就完成了我們目的。

上面提到的這個其實在以往的 C++ 也有辦法做到,細節可以自行蒐尋 move semantic,不過在沒有 rvalue reference 的時候實做起來其實頗麻煩且難以維護。不過 rvalue reference 雖然擁有這些好處,最大的優點在於 performance,但是實際撰寫時並不容易,舉例來說,到底哪些東西是 rvalue? 再來另一個問題是你怎麼確保 compiler 真的會依你所想的去呼叫你所撰寫的那個使用了 rvalue reference 的程式碼? 甚至是說你怎麼知道那邊要使用 rvalue reference 來改善以及更實際的問題:要怎麼使用? 所以雖然這東西很方便,不過使用門檻我覺得其實很高,不容易利用。

以上,關於 rvalue reference 的一點小說明,不過我現在也只有抓到大致上的概念,裡面有寫錯的可能性很高 Orz,有錯請指正。


Reference:
http://stackoverflow.com/questions/3106110/what-are-move-semantics
這篇我覺得寫的還不錯,可以參考看看。只是說 move semantic 會利用到 rvalue referece,但並不是說裡面就有完整的 rvalue reference 的說明。就我所之目前 rvalue reference 的資料依然瑣碎,必須要自己整理

3 則留言: