推薦有關右值的一系列文章,共六篇。
第一篇:想要快?就傳值
原文來自:http://cpp-next.com/archive/2009/08/want-speed-pass-by-value/
實話實說,你對以下這段代碼有何感覺?
std::vector<std::string> get_names();…std::vector<std::string> const names = get_names();
坦白的說,雖然我知道沒那麼糟,但是我還是感覺不妙。原則上,當 get_names() 返回時,我們必須複製一個含有多個 string 的 vector。然後,我們在初始化 names 的時候還要再一次複製它,最後我們還要銷毀第一份拷貝。如果在 vector 中有N個 string,那麼每次複製可能需要多至N+1次記憶體配置,而且 string 內容的複製會導致一系列緩衝失效的資料訪問。
為了消除這種顧慮,我通常會使用傳引用的方法來避免無用的複製:
get_names(std::vector<std::string>& out_param );…std::vector<std::string> names;get_names( names );
不幸的是,這種做法也很不理想。
- 代碼增長了150%。
- 我們必須去掉 const,因為我們要修改 names。
- 正如函數式編程的程式員經常提醒我們的,函數參數可被改寫會使代碼變得複雜,原因是它破壞了引用透明性和方程式推理。
- 對於 names,我們失去了嚴格的值語義。
難道真的必須這樣來寫代碼才可以提高效率嗎?幸好,答案是不必如此(特別是當你使用的是C++0x時)。我們有一系列文章探討右值以及它對於提高C++值語義效率的影響,本文是這個系列中的第一篇。
右值
右值是指建立匿名臨時對象的運算式。右值的名字來自這樣一個事實,內建類型的右值運算式只能出現在賦值操作符的右側。這一點和左值不同,不帶 const 的時候,左值是可以出現在賦值操作符的左側的,右值運算式產生的對象沒有任何持久的標識用來向它賦值。
不過,我們要討論的是匿名臨時對象的另一個重要特性,就是它們可以在運算式中只使用一次。你怎麼可能再一次提及這樣的一個對象呢?它沒有名字(即“匿名”);而且在整個運算式求值完畢後,對象即被銷毀(即“臨時”)!
如果你知道你是從一個右值進行複製的話,你就有可能從來源物件處將複製開銷較高的資源“偷過來”,在目標對象中使用它們而不會有任何人留意它。在前面的例子中,就是將源 vector 中動態分配的字串數組的所有權傳遞給目標 vector。如果我們可以在某種程度上讓編譯器來為我們執行這種“轉移”操作,那麼從以傳值方式返回的 vector 來初始化 names 的代價就非常低——幾乎為零。
以上是關於第二次複製的,那麼第一次複製呢?原則上,當 get_names 返回時,必須將函數的傳回值從函數的內部複製到外部。很好,傳回值具有與匿名臨時對象一樣的特性:它們馬上就會被銷毀,以後也不會再被用到。所以,我們可以用相同的方法來消除掉第一次複製,將資源從函數內部的傳回值處轉移給函數調用者可見的匿名臨時對象。
複製省略和RVO
前面提到複製的時候,我都寫上“原則上”,其原因是,實際上編譯器都允許基於我們已經討論過的那些原則來執行一些最佳化。這一類最佳化通常被稱為複製省略。例如在傳回值最佳化(RVO)中,調用者函數在其棧上分配空間,然後將這塊記憶體的地址傳給被調用函數。被調用函數可以在這塊記憶體上直接構造傳回值,以消除從函數內部至外部的複製。該複製被編譯器省略,或者說“消掉”。因此在以下代碼中,將不需要進行複製:
std::vector<std::string> names = get_names();
同樣,當一個函數參數以傳值方式傳遞時,雖然編譯器通常被要求建立一份拷貝(所以在函數內部修改該參數不會影響到調用者),但是當來源物件是右值時,也允許省略這個複製而直接使用來源物件本身。
std::vector<std::string> sorted(std::vector<std::string> names){ std::sort(names); return names;} // names is an lvalue; a copy is required so we don't modify namesstd::vector<std::string> sorted_names1 = sorted( names ); // get_names() is an rvalue expression; we can omit the copy!std::vector<std::string> sorted_names2 = sorted( get_names() );
這真的很了不起。原則上,編譯器可以消除在第12行中所有令人擔心的複製,使得 sorted_names2 與 get_names() 中所建立的對象是同一個對象。但是在實踐中,這一原則不會走得象我們所想的那麼遠,其原因我稍後解釋。
啟示
雖然複製省略從未被標準要求實現,但是我已測試過的每一個編譯器的最新版本都已實現此種最佳化。即使你對於以傳值方式返回那些重量級對象感到不舒服,複製省略還是會改變你編寫代碼的方式。
我們來看一下前面那個組 sorted(...) 函數的以下寫法,它接受以 const 引用方式傳入的 names 並進行一次顯式的複製:
std::vector<std::string> sorted2(std::vector<std::string> const& names) // names passed by reference{ std::vector<std::string> r(names); // and explicitly copied std::sort(r); return r;}
雖然乍看起來 sorted 和 sorted2 是一樣的,但是如果編譯器實現了複製省略,它們會有巨大的效能差異。即便傳給 sorted2 的實參是右值,進行複製的來源物件 names 也是一個左值,因此複製不能被最佳化掉。從某種意義上說,複製省略是分離編譯模式的犧牲品:在 sorted2 函數體內部,沒有任何關於傳給函數的實參是否為右值的資訊;而在外部的調用點,也沒有跡象顯示該實參最終會被複製。
這一事實直接將我們引至出以下指引:
指引:不要複製你的函數參數。而應該以傳值的方式來傳遞它,讓編譯器來做複製。
最壞的情況下,如果你的編譯器不支援複製省略,效能也不會更壞。而最好的情況下,你會看到效能的極大提升。
你可以立即應用該指引的一個地方就是賦值操作符。規範的、易寫的、保證正確的、強異常保證的、複製並交換的賦值操作符通常會這樣寫:
T& T::operator=(T const& x) // x is a reference to the source{ T tmp(x); // copy construction of tmp does the hard work swap(*this, tmp); // trade our resources for tmp's return *this; // our (old) resources get destroyed with tmp }
但是通過以上對複製省略的討論,可以知道這種寫法顯然是低效的!顯而易見,現在正確編寫一個複製並交換的賦值操作應該是:
T& operator=(T x) // x is a copy of the source; hard work already done{ swap(*this, x); // trade our resources for x's return *this; // our (old) resources get destroyed with x}真的假不了
當然,天下沒有免費的午餐,所以我還有以下說明。
首先,當你以引用方式傳遞一個參數並在函數體內對其進行複製時,複製建構函式是從一個集中的地方被調用的。但是,當你以傳值方式傳遞一個參數時,編譯器為其產生的對複製建構函式的調用是位於每一次對左值進行傳遞的調用點。如果該函數在多個地方被調用,且代碼大小或局部性是你的應用程式的關鍵重點,這的確會是一個問題。
另一方面,也可以很容易地建立一個封裝函數,將複製局部化:
std::vector<std::string> sorted3(std::vector<std::string> const& names){ // copy is generated once, at the site of this call return sorted(names);}
由於反之並不成立——你不能通過封裝來取回已失去的複製省略的機會——所以我建議你還是要從前面的指引開始,然後僅在發現必須要做的時候才改變它。
其次,我還沒有發現有哪個編譯器可以在函數返回其參數時進行複製省略,正如我們的 sorted 實現。你可以想象一下如何進行這些複製省略:沒有某種形式的跨函數最佳化,sorted 的調用者無從知曉其參數(而不是其它對象)最終會被返回,所以編譯器必須在棧上分別為參數和傳回值分配不同的空間。
如果你要返回一個函數的參數,你還是可以獲得近似最優的效能,方法是與一個預設構造的傳回值進行交換(所提供的預設建構函式和交換函數必須該是低開銷的,通常也是如此):
std::vector<std::string> sorted(std::vector<std::string> names){ std::sort(names); std::vector<std::string> ret; swap(ret, names); return ret;}後續內容
我希望你現在已經不會再為以值方式傳遞或返回一個非平凡對象而感到焦慮。不過我們還沒結束:目前為止我們已經討論了右值、複製省略,以及RVO,我們已具備了所需的背景知識,可以繼續討論轉移語義、右值引用、完美轉寄,以及在本系列文章中將繼續的其它內容。回頭見!
鳴謝
Howard Hinnant is responsible for key insights that make this article series possible. Andrei Alexandrescu was posting on comp.lang.c++.moderated about how to leverage copy elision years before I took it seriously. Most of all, though, thanks in general to all readers and reviewers!