《C++0x漫谈》系列之:右值引用
Move語意
返回值效率問題——返回值優化((N)RVO)——mojo設施——workaround——問題定義——Move語意——語言支持
大猴子Howard Hinnant寫了一篇挺棒的tutorial(a.k.a. 提案N2027),此外最初的關于rvalue-reference的若干篇提案的可讀性也相當強。因此要想了解rvalue-reference的話,或者去看C++標準委員會網站上的系列提案(見文章末尾的參考文獻)。或者閱讀本文。
源起
《大史記》總看過吧?
故事,素介個樣子滴…一天,小嗖風風的吹著,在一個伸手不見黑夜的五指…
我用const引用來接受參數,卻把臨時變量一并吞掉了。我用非const引用來接受參數,卻把const左值落下了。于是乎,我就在標準的每個角落尋找解決方案,我靠!我被8.5.3打敗了!…
設想這樣一段代碼(既然大同小異,就直接從Andrei那篇著名的文章里面拿來了):
| std::vector<int> v = readFile(); |
readFile()的定義是這樣的:
| std::vector<int> readFile() { std::vector<int> retv; … // fill retv return retv; } |
這段代碼低效的地方在于那個返回的臨時對象。一整個vector得被拷貝一遍,僅僅是為了傳遞其中的一組int,當v被構造完畢之后,這個臨時對象便煙消云散。
這完全是公然的浪費!
更糟糕的是,原則上講,這里有兩份浪費。一,retv(retv在readFile()結束之后便煙消云散)。二,返回的臨時對象(返回的臨時變量在v拷貝構造完畢之后也隨即香消玉殞)。不過呢,對于上面的簡單代碼來說,大部分編譯器都已經能夠做到優化掉這兩個對象,直接把那個retv創建到接受返回值的對象,即v中去。
實際上,臨時對象的效率問題一直是C++中的一個被廣為詬病的問題。這個問題是如此的著名,以至于標準不惜犧牲原本簡潔的拷貝語意,在標準的12.8節悍然下詔允許優化掉在函數返回過程中產生的拷貝(即便那個拷貝構造函數有副作用也在所不惜!)。這就是所謂的“Copy Elision”。
為什么(N)RVO((Named) Return Value Optimization)幾乎形同虛設
還是按照Andrei的說法,只要readFile()改成這樣:
| … readFile() { if(/* err condition */) return std::vector<int>(); if(/* yet another err condition */) return std::vector<int>(1, 0); std::vector<int> retv; … // fill retv return retv; } |
出現這種情況,編譯器一般都會乖乖放棄優化。
但對編譯器來說這還不是最郁悶的一種情況,最郁悶的是:
| std::vector<int> v; v = readFile(); // assignment, not copy construction |
這下由拷貝構造,變成了拷貝賦值。眼睛一眨,老母雞變鴨。編譯器只能繳械投降。因為標準只允許在拷貝構造的情況下進行(N)RVO。
為什么庫方案也不是生意經
C++鬼才Andrei Alexandrescu以對C++標準的深度挖掘和利用著名,早在03年的時候(當時所謂的臨時變量效率問題已經在新聞組上鬧了好一陣子了,相關的語言級別的解決方案也已經在02年9月份粉墨登場)就在現有標準(C++98)下硬是折騰出了一個能100%解決問題的方案來。
Andrei把這個框架叫做mojo,就像一層爽身粉一樣,把它往現有類上面一灑,嘿嘿…猜怎么著,不,不是“痱子去無蹤”:P,是該類型的臨時對象效率問題就迎刃而解了!
Mojo的唯一的問題就是使用方法過于復雜。這個復雜度,很大程度上來源于標準中的一個措辭問題(C++標準就是這樣,鬼知道哪個角落的一句話能夠帶出一個brilliant的解決方案來,同時,鬼知道哪個角落的一句話能夠抹殺一個原本簡潔的解決方案)。這個問題就是我前面提到過的8.5.3問題,目前已經由core language issue 391解決。
對于庫方案來說,解決問題固然是首要的。但一個侵入性的,外帶使用復雜性的方案必然是走不遠的。因此雖然大家都不否認mojo是一個天才的方案,但實際使用中難免舉步維艱。這也是為什么mojo并沒有被工業化的原因。
為什么改用引用傳參也等于癡人說夢
| void readFile(vector<int>& v){ … // fill v } |
這當然可以。
但是如果遇到操作符重載呢?
| string operator+(string const& s1, string const& s2); |
而且,就算是對于readFile,原先的返回vector的版本支持
| BOOST_FOREACH(int i, readFile()){ … // do sth. with i } |
改成引用傳參后,原本優雅的形式被破壞了,為了進行以上操作不得不引入一個新的名字,這個名字的存在只是為了應付被破壞的形式,一旦foreach操作結束它短暫的生命也隨之結束:
| vector<int> v; readFile(v); BOOST_FOREACH(int I, v){ } // v becomes useless here |
還有什么問題嗎?自己去發現吧。總之,利用引用傳參是一個解決方案,但其能力有限,而且,其自身也會帶來一些其它問題。終究不是一個優雅的辦法。
問題是什么
《你的燈亮著嗎?》里面漂亮地闡述了定義“問題是什么”的重要性。對于我們面臨的臨時對象的效率問題,這個問題同樣重要。
簡而言之,問題可以描述為:
C++沒有區分copy和move語意。
什么是move語意?記得auto_ptr嗎?auto_ptr在“拷貝”的時候其實并非嚴格意義上的拷貝。“拷貝”是要保留源對象不變,并基于它復制出一個新的對象出來。但auto_ptr的“拷貝”卻會將源對象“掏空”,只留一個空殼——一次資源所有權的轉移。
這就是move。
Move語意的作用——效率優化
舉個具體的例子,std::string的拷貝構造函數會做兩件事情:一,根據源std::string對象的大小分配一段大小適當的緩沖區。二,將源std::string中的字符串拷貝過來。
| // just for illustrating the idea, not the actual implementation string::string(const string& o) { this->buffer_ = new buffer[o.length() + 1]; copy(o.begin(), o.end(), buffer_); } |
但是假設我們知道o是一個臨時對象(比如是一個函數的返回值),即o不會再被其它地方用到,o的生命期會在它所處的full expression的結尾結束的話,我們便可以將o里面的資源偷過來:
| string::string(temporary string& o) { // since o is a temporary, we can safely steal its resources without causing any problem this->buffer_ = o.buffer_; o.buffer_ = 0; } |
這里的temporary是一個捏造的關鍵字,其作用是使該構造函數區分出臨時對象(即只有當參數是一個臨時的string對象時,該構造函數才被調用)。
想想看,如果存在這樣一個move constructor(搬移式構造函數)的話,所有源對象為臨時對象的拷貝構造行為都可以簡化為搬移式(move)構造。對于上面的string例子來說,move和copy construction之間的效率差是節省了一次O(n)的分配操作,一次O(n)的拷貝操作,一次O(1)的析構操作(被拷貝的那個臨時對象的析構)。這里的效率提升是顯而易見且顯著的。
最后,要實現這一點,只需要我們具有判斷左值右值的能力(比如前面設想的那個temporary關鍵字),從而針對源對象為臨時對象的情況進行“偷”資源的行動。
Move語意的作用——使能(enabling)
再舉一個例子,std::fstream。fstream是不可拷貝的(實際上,所有的標準流對象都是不可拷貝的),因而我們只能通過引用來訪問一開始建立的那個流對象。但是,這種辦法有一個問題,如果我們要從一個函數中返回一個流對象出來就不行了:
| // how do we make this happen? std::fstream createStream() { … } |
當然,你可以用auto_ptr來解決這個問題,但這就使代碼非常笨拙且難以維護。
但如果fstream是moveable的,以上代碼就是可行的了。所謂“moveable”即是指(當源對象是臨時對象時)在對象拷貝語法之下進行的實際動作是像auto_ptr那樣的資源所有權轉移:源對象被掏空,所有資源都被轉移到目標對象中——好比一次搬家(move)。move操作之后,源對象雖然還有名有姓地存在著,但實際上其“實質”(內部擁有的資源)已經消失了,或者說,源對象從語意上已經消失了。
對于moveable但并非copyable的fstream對象來說,當發生一次move時(比如在上面的代碼中,當一個局部的fstream對象被move出createStream()函數時),不會出現同一對象的兩個副本,取而代之的是,move的源對象的身份(Identity)消失了,這個身份由返回的臨時fstream對象重新持有。也就是說,fstream的唯一性(不可拷貝性——non-copyable)得到了尊重。
你可能會問,那么被搬空了的那個源對象如果再被使用的話豈不是會引發問題?沒錯。這就是為什么我們應該僅當需要且可以去move一個對象的時候去move它,比如在函數的最后一行(return)語句中將一個局部的vector對象move出來(return std::move(v)),由于這是最后一行語句,所以后面v不可能再被用到,對它來說所剩下的操作就是析構,因此被掏空從語意上是完全恰當的。
? 最初的例子——完美解決方案
在先前的那個例子中
| vector<int> v = readFile(); |
有了move語意的話,readFile就可以簡單的改成:
| std::vector<int> readFile() { std::vector<int> retv; … // fill retv return std::move(retv); // move retv out } |
std::move以后再介紹。目前你只要知道,std::move就可以把retv掏空,即搬移出去,而搬家的最終目的地是v。這樣的話,從內存分配的角度講,只有retv中進行的內存分配,在從retv到返回的臨時對象,再從后者到目的地v的“move”過程中,沒有任何的內存分配(我是指vector內的緩沖區分配),取而代之的是,先是retv內的緩沖區被“轉移”到返回值臨時對象中,然后再從臨時對象中轉移到v中。相比于以前的兩次拷貝而言,兩次move操作節省了多少工作量呢?節省了兩次new操作兩次delete操作,還有兩次O(n)的拷貝操作,這些操作整體的代價正比于retv這個vector的大小。難怪人們說臨時對象效率問題是C++的腫瘤(wart)之一,難怪C++標準都要不惜代價允許(N)RVO。
如何支持move語意
根據前面的介紹,你想必已經知道。實現move語意的最關鍵環節在于能夠在編譯期區分左值右值(也就是說識別出臨時對象)。
現在,回憶一下,在文章的開頭我曾經提到:
我用const引用來接受參數,卻把臨時變量一并吞掉了。我用非const引用來接受參數,卻把const左值落下了。于是乎,我就在標準的每個角落尋找解決方案,我靠!我被8.5.3打敗了!…
為什么這么說?
現行標準(C++03)下的方案
要想區分左值右值,只有通過重載:
| void foo(X const&); void foo(X&); |
這樣的重載顯然是行不通的。因為X const&會把non-const臨時對象一并吞掉。
這種做法的問題在于。X&是一個non-const引用,它只能接受non-const左值。然而,C++里面的值一共有四種組合:
const non-const
lvalue
rvalue
常量性(const-ness)與左值性(lvalue-ness)是正交的。
non-const引用只能綁定到其中的一個組合,即non-const lvalue。還剩下const左值,const右值,以及我們最關心的——non-const右值。而只有最后一種——non-const右值——才是可以move的。
剩下的問題便是如何設計重載函數來搞定const左值和const右值。使得最后只留下non-const右值。
所幸的是,我們可以借助強大的模板參數推導機制:
| // catch non-const lvalues void foo(X&); // catch const lvalues and const rvalues template<typename T> void foo(T&, enable_if_same<T, const X>::type* = 0); void foo( /* what goes here? */); |
注意,第二個重載負責接受const左值和const右值。經過第一第二個foo重載之后剩下來的便是non-const rvalue了。
問題是,我們怎么捕獲這些non-const rvalue呢?根據C++03,const-const rvalue只能綁定到const引用。但如果我們用const引用的話,就會越俎代庖把const左右值一并接受了(因為在模板函數(第二個重載)和非模板函數(第三個重載)之間編譯器總是會偏好非模板)。
那除了用const引用,難道還有什么辦法來接受一個non-const rvalue嗎?
有。
假設你的類型為X,那么只要在X里面加入一點料:
| struct ref_x { ref_x(X* p) : p_(p) {} X* p_; }; struct X { // original stuff … // added stuff, for move semantic operator ref_x() { return ref_x(this); } }; |
這樣,我們的第三個重載函數便可以寫成:
| void foo(ref_x rx); // accept non-const temporaries only! |
Bang! 我們成功地在C++03下識別出了moveable的non-const臨時對象。不過前提是必須得在moveable的類型里加入一些東西。這也正是該方案的最大弊病——它是侵入式的(姑且不說它利用了語言的陰暗角落,并且帶來了很大的編碼復雜度)。
C++09的方案
實際上,剛才講的這個利用重載的方案做成庫便是Andrei的mojo框架。mojo框架固然精巧,但復雜性太大,使用成本太高,不夠優雅直觀。所以語言級別的支持看來是必然選擇(后面你還會看到,為了支持move語意而引入的新的語言特性同時還支持了另一個廣泛的問題——完美轉發)。
C++03之所以讓人費神就是因為它沒有一個引用類型來綁定到右值,而是用const左值引用來替代,事實證明這個權宜之計并不是長遠之道,時隔10年,終歸還是要健全引用的左右值語意。
C++09加入一個新的引用類型——右值引用。右值引用的特點是優先綁定到右值。其語法是&&(注意,不讀作“引用的引用”,讀作“右值引用”)。有了右值引用,我們前面的方案便可以簡單的修改為:
| void foo(X const& x); void foo(X&& x); |
這樣一來,左值以及const右值都被綁定到了第一個重載版本。剩下的non-const右值被綁定到第二個重載版本。
對于你的moveable的類型X,則是這樣:
| struct X { X(); X(X const& o); // copy constructor X(X&& o); // move constructor }; X source(); X x = source(); // #1 |
在#1處,調用的將會是X::X(X&& o),即所謂的move constructor,因為source()返回的是一個臨時對象(non-const右值),重載決議會選中move constructor。
總結
以上是生活随笔為你收集整理的《C++0x漫谈》系列之:右值引用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关羽在韩国有块私家地?
- 下一篇: unity三维向量变化为角度_UNITY