關於C/C++ 表達式求值順序

導語:表達式求值順序不同於運算結合性和優先級。下面是一個經典例子,被 ISO C99/ C++98 /03 三大標準明確提到:他的結果是不確定(unspecified) 的。 下面是關於C/C++ 表達式求值順序,歡迎學習:

關於C/C++ 表達式求值順序

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] 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 ]ator, ( ++i );

C/C++ 中,函數只在調用前有一個求值順序點。所以 ar[i] 和 ++i 的求值、以及 ++i 副作用的順序是任意的。這會引起混亂。

更可怕的是,重載 && 和 || 。 大家已經習慣了其速死算法: 如果左側求值已經決定了最終結果,則右側不會被求值。而且大家很依賴這個行爲,比如是C風格字符串拷貝常常這樣寫:

while( p && *p )

*pd++ = *p++;

假如p 爲 0, 那麼 *p 的行爲是未定義的,可能令程序崩潰。 而 && 的求值順序避免了這一點。 但是,如果我們重載 && 就等於下面的做法:

exp1 ator && ( exp2 )

現在不僅僅是求值混亂了。無論exp1是什麼結果,exp2 必然會被求值。