C++中有這樣一種物件:它在程式碼中看不到,但是確實存在。它就是臨時物件—由編譯器定義的一個沒有命名的非堆物件(non-heap object)。為什麼研究臨時物件?主要是為了提高程式的效能以及效率,因為臨時物件的構造與析構對系統效能而言絕不是微小的影響,所以我們應該去了解它們,知道它們如何造成,從而儘可能去避免它們。
臨時物件通常產生於以下4種情況:
- 型別裝換
- 按值傳遞
- 按值返回
- 物件定義
下面我們逐一看看:
1、型別轉換:它通常是為了讓函式呼叫成功而產生臨時物件。發生於 “傳遞某物件給一個函式,而其型別與它即將繫結上去的引數型別不同” 的時候。
例如:
1 2 3 4 5 |
void test(const string& str); char buffer[] = "buffer"; test(buffer); // 此時發生型別轉換 |
此時,編譯器會幫你進行型別轉換:它產生一個型別為string的臨時物件,該物件以buffer為引數呼叫string constructor。當test函式返回時,此臨時物件會被自動銷燬。
注意:對於引用(reference)引數而言,只有當物件被傳遞給一個reference-to-const引數時,轉換才發生。如果物件傳遞給一個reference-to-non-const物件,不會發生轉換。
例如:
1 2 3 4 5 |
void upper(string& str); char lower[] = "lower"; upper(lower); // 此時不能轉換,編譯出錯 |
此時如果編譯器對reference-to-non-const物件進行了型別轉換,那麼將會允許臨時物件的值被修改。而這和程式設計師的期望是不一致的。試想,在上面的程式碼中,如果編譯器允許upper執行,將lower中的值轉換為大寫,但是這是對臨時物件而言的,char lower[]的值還是“lower”,這和你的期望一致嗎?
有時候,這種隱式型別轉換不是我們期望的,那麼我們可以通過宣告constructor為explicit來實現。explicit告訴編譯器,我們反對將constructor用於型別轉換。
例如:
1 |
explicit string(const char*); |
2、按值傳遞:這通常也是為了讓函式呼叫成功而產生臨時物件。當按值傳遞物件時,實參對形參的初始化與T formalArg = actualArg的形式等價。
例如:
1 2 3 4 |
void test(T formalArg); T actualArg; test(actualArg); |
此時編譯器產生的偽碼為:
1 2 3 4 5 |
T _temp; _temp.T::T(acutalArg); // 通過拷貝建構函式生成_temp g(_temp); // 按引用傳遞_temp _temp.T::~T(); // 析構_temp |
因為存在區域性引數formalArg,test()的呼叫棧中將存在formalArg的佔位符。編譯器必須複製物件actualArg的內容到formalArg的佔位符中。所以,此時編譯器生成了臨時物件。
3、按值返回:如果函式是按值返回的,那麼編譯器很可能為之產生臨時物件。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class Integer { public: friend Integer operator+(const Integer& a, const Integer& b); Integer(int val=0): value(val) { } Integer(const Integer& rhs): value(rhs.value) { } Integer& operator=(const Integer& rhs); ~Integer() { } private: int value; }; Integer operator+(const Integer& a, const Integer& b) { Integer retVal; retVal.value = a.value + b.value; return retVal; } Integer c1, c2, c3; c3 = c1 + c2; |
編譯器生成的虛擬碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct Integer _tempResult; // 表示佔位符,不呼叫建構函式 operator+(_tempResult, c1, c2); // 所有引數按引用傳遞 c3 = _tempResult; // operator=函式執行 Integer operator+(const Integer& _tempResult, const Integer& a, const Integer& b) { struct Integer retVal; retVal.Integer::Integer(); // Integer(int val=0)執行 retVal.value = a.value + b.value; _tempResult.Integer::Integer(retVal); // 拷貝建構函式Integer(const Integer& rhs)執行,生成臨時物件。 retVal.Integer::~Integer(); // 解構函式執行 return; } return retVal; } |
如果對operator+進行返回值優化(RVO:Return Value Optimization),那麼臨時物件將不會產生。
例如:
1 2 3 |
Integer operator+(const Integer& a, const Integer& b) { return Integer(a.value + b.value); } |
編譯器生成的虛擬碼:
1 2 3 4 5 6 |
Integer operator+(const Integer& _tempResult, const Integer& a, const Integer& b) { _tempResult.Integer::Integer(); // Integer(int val=0)執行 _tempResult.value = a.value + b.value; return; } |
對照上面的版本,我們可以看出臨時物件retVal消除了。
4、物件定義:
例如:
1 2 3 |
Integer i1(100); // 編譯器肯定不會生成臨時物件 Integer i2 = Integer(100); // 編譯器可能生成臨時物件 Integer i3 = 100; // 編譯器可能生成臨時物件 |
然而,實際上大多數的編譯器都會通過優化省去臨時物件,所以這裡的初始化形式基本上在效率上都是相同的。
備註:
臨時物件的生命期:按照C++標準的說法,臨時物件的摧毀,是對完整表示式求值過程中的最後一個步驟。該完整表示式照成了臨時物件的產生。
完整表示式通常是指包含臨時物件表示式的最外圍的那個。例如:
((objA >1024)&&(objB <1024) ) ? (objA – objB) :(objB-objA)
這個表示式中一共含有5個表示式,最外圍的表示式是?。任何一個子表示式所產生的任何一個臨時物件,都應該在完整表示式被求值完成後,才可以銷燬。
臨時物件的生命週期規則有2個例外:
1、在表示式被用來初始化一個object時。例如:
1 2 3 |
String progName("test"); String progVersion("ver-1.0"); String progNameVersion = progName + progVersion |
如果progName + progVersion產生的臨時物件在表示式求值結束後就析構,那麼progNameVersion就無法產生。所以,C++標準規定:含有表示式執行結果的臨時物件,應該保留到object的初始化操作完成為止。
小心這種情況:
1 |
const char* progNameVersion = progName + progVersion |
這個初始化操作是一定會失敗的。編譯器產生的偽碼為:
1 2 3 4 |
String _temp; operator+(_temp, progName, progVersion); progNameVersion = _temp.String::operator char*(); _temp.String::~String(); |
2、當一個臨時物件被一個reference繫結時。例如:
1 |
const String& name = "C++"; |
編譯器產生的偽碼為:
1 2 3 |
String _temp; temp.String::String("C++"); const String& name = _temp; |
針對這種情況,C++標準上是這樣說的:如果一個臨時物件被繫結於一個reference,物件將保留,直到被初始化的reference的生命結束,或直到臨時物件的生命範圍結束—–看哪種情況先到達而定。
參考書籍:
1、《深度探索:C++物件模型》
2、《提高C++效能的程式設計技術》
3、《Effective C++》
4、《more effective C++》
5、《C++語言的設計和演化》