Google C++每週貼士 #11: 返回策略
C++每週貼士 #11: 返回策略
Frodo: There’ll be none left for the return journey. Sam: I don’t think there will be a return journey, Mr. Frodo. – The Lord of the Rings: The Return of the King (novel by J.R.R. Tolkien, screenplay by Fran Walsh, Philippa Boyens, & Peter Jackson) (譯者注:文學水平太窪,起興這段保留原文)
注:這條貼士——雖然仍然有效——但寫它的時候還沒有C++11的移動語義。讀這條貼士時請同時參閱
很多老的C++程式碼庫使用了"害怕複製物件"的正規化。幸運的是,多虧了“返回值優化”(RVO),我們可以“複製”但不真的複製。
RVO特性存在已久,幾乎所有的C++編譯器都實現了它。考慮如下的C++98程式碼,有一個複製建構函式和一個賦值運算子。這些函式代價都很大,開發者讓它們在每次被呼叫時都列印一條訊息:
class SomeBigObject {
public:
SomeBigObject() { ... }
SomeBigObject(const SomeBigObject& s) {
printf("死貴的複製…\n", …);
…
}
SomeBigObject& operator=(const SomeBigObject& s) {
printf("死貴的賦值…\n", …);
…
return *this;
}
~SomeBigObject() { ... }
…
};
(注:此處故意避免討論移動操作。參閱TotW #77獲取更多資訊。)
你會被長成下面這樣的工廠方法嚇一跳嗎?
static SomeBigObject SomeBigObjectFactory(...) {
SomeBigObject local;
...
return local;
}
看著挺低效地,是不是?如果我們跑下面這段程式碼會發生什麼?
SomeBigObject obj = SomeBigObject::SomeBigObjectFactory(...);
簡單的答案:你也許會期望至少有兩個物件被構造:一個是函式返回的物件,另一個是函式呼叫處的(接收函式返回值的,譯者注)物件。兩次都是複製,所以程式列印兩條“死貴”的訊息。實踐的答案:沒有訊息被列印——因為複製建構函式和賦值運算子都沒有被呼叫!
這是怎麼回事?很多C++程式設計師為了寫“高效程式碼”,創造一個物件,然後把物件地址傳給函式,函式用指標或引用來操作原始物件。其實,在下面的情形中,編譯器能把那些“低效的複製”轉譯成如前的“高效程式碼”。
當編譯器在函式呼叫處看到一個變數(來接收函式返回值),在被呼叫的函式裡看到另一個(將要被返回的)變數,那編譯器就意識到它不需要兩個變數。在實現中,編譯器就會把函式呼叫處(函式外,譯者注)的變數的地址傳遞給被呼叫的函式(函式裡,譯者注)。
引用C++98標準原文的說法,“當一個臨時物件被複制建構函式複製時…(編譯器,譯者注)實現被(本標準,譯者注)允許將原始物件和副本物件看做指向同一物件的兩種方式,因此不需要執行復制操作——即使複製建構函式或解構函式有副作用(例如列印訊息,修改全域性計數器等,譯者注)。對於一個返回值為類的函式,如果返回語句中的表示式是區域性物件的名字…(編譯器,譯者注)實現被(本標準,譯者注)允許不必建立臨時物件來儲存函式返回值…”(C++98標準,第12.8節[class.copy],第15段。C++11標準在第12.8節,第31段有類似的描述,但是更復雜。)
擔心“被允許”不是個很強的承諾?幸運的是,所有現代C++編譯器預設都會執行RVO,即使是在除錯版本,即使是非行內函數。
你怎麼保證編譯器執行RVO?
被呼叫的函式應該定義唯一的返回值變數:
SomeBigObject SomeBigObject::SomeBigObjectFactory(...) {
SomeBigObject local;
…
return local;
}
函式呼叫處應該將返回值賦值給一個新的變數:
// 不會列印“死貴”操作的訊息:
SomeBigObject obj = SomeBigObject::SomeBigObjectFactory(...);
就這些!
如果函式呼叫處重用已經存在的變數來儲存返回值,那麼編譯器沒法執行RVO(雖然這種情況下定義了移動操作的型別會執行移動語義):
// RVO不會發生;列印詳細“死貴的賦值...”:
obj = SomeBigObject::SomeBigObjectFactory(s2);
如果被呼叫的函式使用多於一個變數作為返回值,那麼編譯器也沒法執行RVO:
// RVO不會執行:
static SomeBigObject NonRvoFactory(...) {
SomeBigObject object1, object2;
object1.DoSomethingWith(...);
object2.DoSomethingWith(...);
if (flag) {
return object1;
} else {
return object2;
}
}
但如果被呼叫的函式在多個地方返回同一個變數,編譯器會執行RVO:
// RVO會執行:
SomeBigObject local;
if (...) {
local.DoSomethingWith(...);
return local;
} else {
local.DoSomethingWith(...);
return local;
}
關於RVO,以上大概就是你需要了解的一切。
還有一件事:臨時變數
除了命名變數,RVO也對臨時變數執行。當被呼叫的函式返回臨時變數時,你仍然可以從RVO中獲益:
// RVO會執行:
SomeBigObject SomeBigObject::ReturnsTempFactory(...) {
return SomeBigObject::SomeBigObjectFactory(...);
}
當函式呼叫處(以臨時變數的形式)直接使用返回值時,你仍然可以從RVO中獲益:
// 不會列印“死貴”操作的訊息:
EXPECT_EQ(SomeBigObject::SomeBigObjectFactory(...).Name(), s);
最後的註解:如果你的程式碼需要執行復制,那就執行復制,不論這些複製會不會被優化掉。不要犧牲正確性來換取效率。