3. Side Effect與Sequence Point

如果你只想規規矩矩地寫代碼,那麼基本用不着看這一節。本節的內容基本上是鑽牛角尖兒的,除了Short-circuit比較實用,其它寫法都應該避免使用。但沒辦法,有時候不是你想鑽牛角尖兒,而是有人逼你去鑽牛角尖兒。這是我們的學員在找工作筆試時碰到的問題:

int a=0;
a = (++a)+(++a)+(++a)+(++a);

據我瞭解,似乎很多公司都有出這種筆試題的惡趣味。答案應該是Undefined,我甚至有些懷疑出題人是否真的知道答案。下面我來解釋為什麼是Undefined。

我們知道,調用一個函數可能產生Side Effect,使用某些運算符(++ -- = 復合賦值)也會產生Side Effect,如果一個表達式中隱含着多個Side Effect,究竟哪個先發生哪個後發生呢?C標準規定代碼中的某些點是Sequence Point,當執行到一個Sequence Point時,在此之前的Side Effect必須全部作用完畢,在此之後的Side Effect必須一個都沒發生。至于兩個Sequence Point之間的多個Side Effect哪個先發生哪個後發生則沒有規定,編譯器可以任意選擇各Side Effect的作用順序。下面詳細解釋各種Sequence Point。

1、調用一個函數時,在所有準備工作做完之後、函數調用開始之前是Sequence Point。比如調用foo(f(), g())時,foof()g()這三個表達式哪個先求值哪個後求值是Unspecified,但是必須都求值完了才能做最後的函數調用,所以f()g()的Side Effect按什麼順序發生不一定,但必定在這些Side Effect全部作用完之後才開始調用foo函數。

2、條件運算符?:、逗號運算符、邏輯與&&、邏輯或||的第一個操作數求值之後是Sequence Point。我們剛講過條件運算符和逗號運算符,條件運算符要根據表達式1的值是否為真決定下一步求表達式2還是表達式3的值,如果決定求表達式2的值,表達式3就不會被求值了,反之也一樣,逗號運算符也是這樣,表達式1求值結束才繼續求表達式2的值。

邏輯與和邏輯或早在第 3 節 “布爾代數”就講了,但在初學階段我一直迴避它們的操作數求值順序問題。這兩個運算符和條件運算符類似,先求左操作數的值,然後根據這個值是否為真,右操作數可能被求值,也可能不被求值。比如例 8.5 “剪刀石頭布”這個程序中的這幾句:

ret = scanf("%d", &man);
if (ret != 1 || man < 0 || man > 2) {
	printf("Invalid input! Please input 0, 1 or 2.\n");
	continue;
}

其實可以寫得更簡單(類似於[K&R]的簡潔風格):

if (scanf("%d", &man) != 1 || man < 0 || man > 2) {
	printf("Invalid input! Please input 0, 1 or 2.\n");
	continue;
}

這個控製表達式的求值順序是:先求scanf("%d", &man) = 1的值,如果scanf調用失敗,則返回值不等於1成立,||運算有一個操作數為真則整個表達式為真,這時直接執行下一句printf,根本不會再去求man < 0man > 2的值;如果scanf調用成功,則讀入的數保存在變數man中,並且返回值等於1,那麼說它不等於1就不成立了,第一個||運算的左操作數為假,就會去求右操作數man < 0的值作為整個表達式的值,這時變數man的值正是scanf讀上來的值,我們判斷它是否在[0, 2]之間,如果man < 0不成立,則整個表達式scanf("%d", &man) != 1 || man < 0 的值為假,也就是第二個||運算的左操作數為假,所以最後求右操作數man > 2的值作為整個表達式的值。

&&運算與此類似,a && b的計算過程是:首先求表達式a的值,如果a的值是假則整個表達式的值是假,不會再去求b的值;如果a的值是真,則下一步求b的值作為整個表達式的值。所以,a && b相當於“if a then b”,而a || b相當於“if not a then b”。這種特性稱為Short-circuit,很多人喜歡利用Short-circuit特性簡化代碼。

3、在一個完整的聲明末尾是Sequence Point,所謂完整的聲明是指這個聲明不是另外一個聲明的一部分。比如聲明int a[10], b[20];,在a[10]末尾是Sequence Point,在b[20]末尾也是。

4、在一個完整的表達式末尾是Sequence Point,所謂完整的表達式是指這個表達式不是另外一個表達式的一部分。所以如果有f(); g();這樣兩條語句,f()g()是兩個完整的表達式,f()的Side Effect必定在g()之前發生。

5、在庫函數即將返回時是Sequence Point。這條規則似乎可以包含在上一條規則裡面,因為函數返回時必然會結束掉一個完整的表達式。而事實上很多庫函數是以宏定義的形式實現的(第 2.1 節 “函數式宏定義”),並不是真正的函數,所以才需要有這條規則。

還有兩種Sequence Point和某些C標準庫函數的執行過程相關,此處從略,有興趣的讀者可參考[C99]的Annex C。

現在可以分析一下本節開頭的例子了。a = (++a)+(++a)+(++a)+(++a);的結果之所以是Undefined,因為在這個表達式中有五個Side Effect都在改變a的值,這些Side Effect按什麼順序發生不一定,只知道在整個表達式求值結束時一定都發生了。比如現在求第二個++a的值,這時第一個、第三個、第四個++a的Side Effect發生了沒有,a的值被加過幾次了,這些都不確定,所以第二個++a的值也不確定。這行代碼用不同平台的不同編譯器來編譯結果是不同的,甚至在同一平台上用同一編譯器的不同版本來編譯也可能不同。

寫表達式應遵循的原則一:在兩個Sequence Point之間,同一個變數的值只允許被改變一次。僅有這一條原則還不夠,例如a[i++] = i;的變數i只改變了一次,但結果仍是Undefined,因為等號左邊改i的值,等號右邊讀i的值,到底是先改還是先讀?這個讀寫順序是不確定的。但為什麼i = i + 1;就沒有歧義呢?雖然也是等號左邊改i的值,等號右邊讀i的值,但你不讀出i的值就沒法計算i + 1,那拿什麼去改i的值呢?所以這個讀寫順序是確定的。寫表達式應遵循的原則二:如果在兩個Sequence Point之間既要讀一個變數的值又要改它的值,只有在讀寫順序確定的情況下才可以這麼寫