關於C/C++ 表達式求值順序
經典例子
下面是一個經典例子,被 ISO c99/ C++98 /03 三大標準明確提到:他的結果是不確定(unspecified) 的。
i = ++i + 1; // The behavior is unspecified
在介紹概念之前,我們先解釋一下它的結果。這個表達式( expression )包含3個子表達式( subexpression ):
e1 = ++i
e2 = e1 + 1
i = e2
這三個子表達式都沒有順序點( sequence point ),而 ++ i 和 i = e3 都是有副作用( side effect )的表達式。由於沒有順序點,語言不保證這兩個副作用的順序。
更加可怕的是,如果i 是一個內建類型,並在下一個順序點之前被改寫超過一次,那么結果是未定義(undefined)的!比如本例中如果有:
int i = 0x1000fffe;
i = ++i + 1; // The result is undefined!!
你也許會認為他的結果是加1 或者加2,其實更糟糕 —— 結果可能是 0x1001ffff 。他的高位元組接受了一個副作用的內容,而低位元組則接受了另一個副作用的內容! 如果i 是指針,那么將很容易造成程式崩潰。
為什麼要這么做呢?因為對於編譯器提供商來說,未確定的順序對最佳化有相當重要的作用。比如,一個常見的最佳化策略是“減少暫存器占用和臨時對象”。編譯器可以重新組織表達式的求值,以便儘量不使用額外的暫存器以及臨時變數。 更加嚴格的說,即使是編譯器提供商也無法完全徹底序列化指令(比如無法嚴格規定讀和寫的順序),因為CPU本身有權利修改指令順序,以便達到更高的速度。
下面的術語以 ISO C99 和 C++03為準。譯名為參考並附帶原術語對照,如有解釋不當或者錯誤望指正。
--------------------------------------------------------------------------------
表達式有兩種功能。每個表達式都產生一個值( value ),同時可能包含副作用( side effect ),比如可能修改某些值。
規則的核心在於 順序點( sequence point ) [ C99 6.5 Expressions 條款2 ] [ C++03 5 Expressions 概述 條款4 ]。 這是一個結算點,語言要求這一側的求值和副作用(除了臨時對象的銷毀以外)全部完成,才能進入下面的部分。 C/C++中大部分表達式都沒有順序點,只有下面五種表達式有:
1 函式。函式調用之前有一個求值順序點。
2 && || 和 ?: 這三個包含邏輯的表達式。其左側邏輯完成後有一個求值順序點。
3 逗號表達式。逗號左側有一個求值順序點。
注意,他們都只有一個求值順序點,2和3的右側運算結束後並沒有求值順序點。
在兩個順序點之間,子表達式求值和副作用的順序是不確定的。假如代碼的結果與求值和副作用發生順序相關,我們稱這樣的代碼有不確定的行為(unspecified behavior)。 而且,假如期間對一個內建類型執行一次以上的寫操作,則是未定義行為(undefined behavior)——我們知道,未定義行為帶來最好的後果是讓你的程式立即崩掉。
n = n++; // 兩個副作用,對於內建對象產生是未定義行為
幾乎所有表達式,求值順序都不確定。比如,下面的加法, f1 f2 f3的調用順序是任意的:
n = f1() + f2() + f3(); // f1 f2 f3 調用順序任意
而函式也只在實際調用前有一個求值順序點。所以,常見於早期 C 語言教材的這類題目,是錯題:
printf("%d",--a+b,--b+a); // --a + b 和 --b + a 這兩個子表達式,求值順序不確定
天啊,甚至可能出現未定義行為?那么堅決不寫與實現相關的代碼是最好的對策。即使是不確定行為(比如函式調用時) 只要沒有順序點編譯器怎么做方便就怎么做。 有些人認為函式調用參數求值與入棧順序相關,這是一種誤導。這個東西要解釋,無異於事後諸葛亮:
void f( int i1, int i2, int i3, int i4 ){
cout<< i1 << ' ' << i2 << ' ' << i3 << ' ' << i4 << endl;
}
int main(){
int i = 0;
f( i++, i++, i++, i++ );
}
這個有四個表達式求值,同時每個表達式都有負作用。這八個操作順序是任意的,那么結果如何?未定義。
請用 VC7.1 Debug和 Release 分別測試這同一份代碼,結果是不同的:
0 0 0 0 [release]
3 2 1 0 [debug]
事實上,鑒於前面的討論,如果換一些其他初始值,這裡甚至會出現錯位而得到千奇百怪的詭異結果。
再看看C/C++標準中的其他經典例子:
[C99] 6.5.2.2 Function call
條款12 EXAMPLE 在下面的函式調用中:
(*pf[f1()]) ( f2(), f3() + f4() )
函式 f1 f2 f3 和f4 可能以任何順序被調用。 但是,所有副作用都必須在那個 pf[ f1() ] 返回的函式指針產生的調用前完成。
[C++03] 5 Expressions 概論4
i = v[i++]; // the behavior is unspecified
i = 7, i++, i++; // i becomes 9 ( 譯註: 賦值表達式比逗號表達式優先權高 )
i = ++i + 1; // the behavior is unspecified
i = i + 1; // the value of i is incremented
--------------------------------------------------------------------------------
More Effective C++ 告誡我們, 千萬不要重載 &&, || 和, 操作符[ MEC ,條款7 ]。為什麼?
以逗號操作符為例,每個逗號左側有一個求值順序點。假如ar是一個普通的對象,下面的做法是無歧義的:
ar[ i ], ++i ;
但是,如果ar[ i ] 返回一個 class A 對象或引用,而它重載了 operator, 那么結果不妙了。那么,上面的語句實際上是一個函式調用:
ar[ i ].operator, ( ++i );
C/C++ 中,函式只在調用前有一個求值順序點。所以 ar 和 ++i 的求值、以及 ++i 副作用的順序是任意的。這會引起混亂。
更可怕的是,重載 && 和 || 。 大家已經習慣了其速死算法: 如果左側求值已經決定了最終結果,則右側不會被求值。而且大家很依賴這個行為,比如是C風格字元串拷貝常常這樣寫:
while( p && *p )
*pd++ = *p++;
假如p 為 0, 那么 *p 的行為是未定義的,可能令程式崩潰。 而 && 的求值順序避免了這一點。 但是,如果我們重載 && 就等於下面的做法:
exp1 .operator && ( exp2 )
現在不僅僅是求值混亂了。無論exp1是什麼結果,exp2 必然會被求值。
--------------------------------------------------------------------------------
模糊語義
最後這裡有篇更深入的帖子:C++中的求值|副作用|序列點所導致的模糊語義,推薦看看。
作者在google討論組上發了篇帖子,看了簡直會讓你對求值順序和副作用發瘋。比如,下面這個表達式結果居然是不確定的?
cout << "x = " << itoa(42,buf,10) << ", y = " << itoa(43,buf,10);
大家要小心別中招阿... 另外有一個更加難以察覺的例子:
void f ( auto_ptr<int> );
void f_2( auto_ptr<int>, int );
f( auto_ptr<int>( new int ) ); // exception save
f( auto_ptr<int>( new int ), g() ); // !! not exception save
第一個例子是異常安全的。new int 返回一個指針 —— 如果他失敗那么記憶體根本不會分配。 auto_ptr<> 如果失敗(如果的話),那么它有能力刪除這個指針。
第二個例子則不然,如果g()拋出異常,那么他有可能導致 memleak —— 你看,若求值碰巧以這個順序進行:
1 int* temp = new int
2 g()
3 auto_ptr( temp )
那么當 g() 拋出異常的時候,temp 是一個裸指針,不會有任何機制幫助它釋放其記憶體!