4.1 表達式求值順序
一些C運算符以一種已知的、特定的順序對其操作數進行求值。但另一些不能。例如,考慮下面的表達式:
a < b && c < d
C語言定義規定a < b首先被求值。如果a確實小于b,c < d必須緊接著被求值以計算整個表達式的值。但如果a大于或等于b,則c < d根本不會被求值。
要對a < b求值,編譯器對a和b的求值就會有一個先后。但在一些機器上,它們也許是并行進行的。
C中只有四個運算符&&、||、?:和,指定了求值順序。&&和||最先對左邊的操作數進行求值,而右邊的操作數只有在需要的時候才進行求值。而?:運算符中的三個操作數:a、b和c,最先對a進行求值,之后僅對b或c中的一個進行求值,這取決于a的值。,運算符首先對左邊的操作數進行求值,然后拋棄它的值,對右邊的操作數進行求值腳注[8]。
C中所有其它的運算符對操作數的求值順序都是未定義的。事實上,賦值運算符不對求值順序做出任何保證。
出于這個原因,下面這種將數組x中的前n個元素復制到數組y中的方法是不可行的:
i = 0;
while(i < n)
y[i] = x[i++];
其中的問題是y[i]的地址并不保證在i增長之前被求值。在某些實現中,這是可能的;但在另一些實現中卻不可能。另一種情況出于同樣的原因會失?。?br />
i = 0;
while(i < n)
y[i++] = x[i];
而下面的代碼是可以工作的:
i = 0;
while(i < n) {
y[i] = x[i];
i++;
}
當然,這可以簡寫為:
for(i = 0; i < n; i++)
y[i] = x[i];
4.2 &&、||和!運算符
C中有兩種邏輯運算符,在某些情況下是可以交換的:按位運算符&、|和~,以及邏輯運算符&&、||和!。一個程序員如果用某一類運算符替換相應的另一類運算符會得到某些奇怪的效果:程序可能會正確地工作,但這純屬偶然。
&&、||和!運算符將它們的參數視為僅有“真”或“假”,通常約定0代表“假”而其它的任意值都代表“真”。這些運算符返回1表示“真”而返回0表示“假”,而且&&和||運算符當可以通過左邊的操作數確定其返回值時,就不會對右邊的操作數進行求值。
因此!10是零,因為10非零;10 && 12是1,因為10和12都非零;10 || 12也是1,因為10非零。另外,最后一個表達式中的12不會被求值,10 || f()中的f()也不會被求值。
考慮下面這段用于在一個表中查找一個特定元素的程序:
i = 0;
while(i < tabsize && tab[i] != x)
i++;
這段循環背后的意思是如果i等于tabsize時循環結束,元素未被找到。否則,i包含了元素的索引。
假設這個例子中的&&不小心被替換為了&,這個循環可能仍然能夠工作,但只有兩種幸運的情況可以使它停下來。
首先,這兩個操作都是當條件為假時返回0,當條件為真時返回1。只要x和y都是1或0,x & y和x && y都具有相同的值。然而,如果當使用了出了1之外的非零值表示“真”時互換了這兩個運算符,這個循環將不會工作。
其次,由于數組元素不會改變,因此越過數組最后一個元素進一個位置時是無害的,循環會幸運地停下來。失誤的程序會越過數組的結尾,因為&不像&&,總是會對所有的操作數進行求值。因此循環的最后一次獲取tab[i]時i的值已經等于tabsize了。如果tabsize是tab中元素的數量, 則會取到tab中不存在的一個值。
4.3 下標從零開始
在很多語言中,具有n個元素的數組其元素的號碼和它的下標是從1到n嚴格對應的。但在C中不是這樣。
一個具有n個元素的C數組中沒有下標為n的元素,其中的元素的下標是從0到n - 1。因此從其它語言轉到C語言的程序員應該特別小心地使用數組:
int i, a[10];
for(i = 1; i <= 10; i++)
a[i] = 0;
這個例子的目的是要將a中的每個元素都設置為0,但沒有期望的效果。因為for語句中的比較i < 10被替換成了i <= 10,a中的一個編號為10的并不存在的元素被設置為了0,這樣內存中a后面的一個字被破壞了。如果編譯該程序的編譯器按照降序地址為用戶變量分配內存,則a后面就是i。將i設置為零會導致該循環陷入一個無限循環。
4.4 C并不總是轉換實參
下面的程序段由于兩個原因會失?。?br />
double s;
s = sqrt(2);
printf("%g\n", s);
第一個原因是sqrt()需要一個double值作為它的參數,但沒有得到。第二個原因是它返回一個double值但沒有這樣聲名。改正的方法只有一個:
double s, sqrt();
s = sqrt(2.0);
printf("%g\n", s);
C中有兩個簡單的規則控制著函數參數的轉換:(1)比int短的整型被轉換為int;(2)比double短的浮點類型被轉換為double。所有的其它值不被轉換。確保函數參數類型的正確行使程序員的責任。
因此,一個程序員如果想使用如sqrt()這樣接受一個double類型參數的函數,就必須僅傳遞給它float或double類型的參數。常數2是一個int,因此其類型是錯誤的。
當一個函數的值被用在表達式中時,其值會被自動地轉換為適當的類型。然而,為了完成這個自動轉換,編譯器必須知道該函數實際返回的類型。沒有更進一步聲名的函數被假設返回int,因此聲名這樣的函數并不是必須的。然而,sqrt()返回double,因此在成功使用它之前必須要聲名。
實際上,C實現通常允許一個文件包含include語句來包含如sqrt()這些庫函數的聲名,但是對那些自己寫函數的程序員來說,書寫聲名也是必要的——或者說,對那些書寫非凡的C程序的人來說是有必要的。
這里有一個更加壯觀的例子:
main() {
int i;
char c;
for(i = 0; i < 5; i++) {
scanf("%d", &c);
printf("%d", i);
}
printf("\n");
}
表面上看,這個程序從標準輸入中讀取五個整數并向標準輸出寫入0 1 2 3 4。實際上,它并不總是這么做。譬如在一些編譯器中,它的輸出為0 0 0 0 0 1 2 3 4。
為什么?因為c的聲名是char而不是int。當你令scanf()去讀取一個整數時,它需要一個指向一個整數的指針。但這里它得到的是一個字符的指針。但scanf()并不知道它沒有得到它所需要的:它將輸入看作是一個指向整數的指針并將一個整數存貯到那里。由于整數占用比字符更多的內存,這樣做會影響到c附近的內存。
c附近確切是什么是編譯器的事;在這種情況下這有可能是i的低位。因此,每當向c中讀入一個值,i就被置零。當程序最后到達文件結尾時,scanf()不再嘗試向c中放入新值,i才可以正常地增長,直到循環結束。
4.5 指針不是數組
C程序通常將一個字符串轉換為一個以空字符結尾的字符數組。假設我們有兩個這樣的字符串s和t,并且我們想要將它們連接為一個單獨的字符串r。我們通常使用庫函數strcpy()和strcat()來完成。下面這種明顯的方法并不會工作:
char *r;
strcpy(r, s);
strcat(r, t);
這是因為r沒有被 初始化為指向任何地方。盡管r可能潛在地表示某一塊內存,但這并不存在,直到你分配它。
讓我們再試試,為r分配一些內存:
char r[100];
strcpy(r, s);
strcat(r, t);
這只有在s和t所指向的字符串不很大的時候才能夠工作。不幸的是,C要求我們為數組指定的大小是一個常數,因此無法確定r是否足夠大。然而,很多C實現帶有一個叫做malloc()的庫函數,它接受一個數字并分配這么多的內存。通產還有一個函數成為strlen(),可以告訴我們一個字符串中有多少個字符:因此,我們可以寫:
char *r, *malloc();
r = malloc(strlen(s) + strlen(t));
strcpy(r, s);
strcat(r, t);
然而這個例子會因為兩個原因而失敗。首先,malloc()可能會耗盡內存,而這個事件僅通過靜靜地返回一個空指針來表示。
其次,更重要的是,malloc()并沒有分配足夠的內存。一個字符串是以一個空字符結束的。而strlen()函數返回其字符串參數 中所包含字符的數量,但不包括結尾的空字符。因此,如果strlen(s)是n,則s需要n + 1個字符來盛放它。因此我們需要為r分配額外的一個字符。再加上檢查malloc()是否成功,我們得到:
char *r, *malloc();
r = malloc(strlen(s) + strlen(t) + 1);
if(!r) {
complain();
exit(1);
}
strcpy(r, s);
strcat(r, t);
4.6 避免提喻法
提喻法(Synecdoche, sin-ECK-duh-key)是一種文學手法,有點類似于明喻或暗喻,在牛津英文詞典中解釋如下:“a more comprehensive term is used for a less comprehensive or vice versa; as whole for part or part for whole, genus for species or species for genus, etc.(將全面的單位用作不全面的單位,或反之;如整體對局部或局部對整體、一般對特殊或特殊對一般,等等。)”
這可以精確地描述C中通常將指針誤以為是其指向的數據的錯誤。正將常會在字符串中發生。例如:
char *p, *q;
p = "xyz";
盡管認為p的值是xyz有時是有用的,但這并不是真的,理解這一點非常重要。p的值是指向一個有四個字符的數組中第0個元素的指針,這四個字符是'x'、'y'、'z'和''。因此,如果我們現在執行:
q = p;
p和q會指向同一塊內存。內存中的字符沒有因為賦值而被復制。這種情況看起來是這樣的:
<center><img src="images/CTraps/CTraps1.gif"></center>
要記住的是,復制一個指針并不能復制它所指向的東西。
因此,如果之后我們執行:
q[1] = 'Y';
q所指向的內存包含字符串xYz。p也是,因為p和q指向相同的內存。
4.7 空指針不是空字符串
將一個整數轉換為一個指針的結果是實現相關的(implementation-dependent),除了一個例外。這個例外是常數0,它可以保證被轉換為一個與其它任何有效指針都不相等的指針。這個值通常類似這樣定義:
#define NULL 0
但其效果是相同的。要記住的一個重要的事情是,當用0作為指針時它決不能被解除引用。換句話說,當你將0賦給一個指針變量后,你就不能訪問它所指向的內存。不能這樣寫:
if(p == (char *)0) ...
也不能這樣寫:
if(strcmp(p, (char *)0) == 0) ...
因為strcmp()總是通過其參數來查看內存地址的。
如果p是一個空指針,這樣寫也是無效的:
printf(p);
或
printf("%s", p);
4.8 整數溢出
C語言關于整數操作的上溢或下溢定義得非常明確。
只要有一次操作數是無符號的,結果就是無符號的,并且以2n為模,其中n為字長。如果兩個操作數都是帶符號的,則結果是未定義的。
例如,假設a和b是兩個非負整型變量,你希望測試a + b是否溢出。一個明顯的辦法是這樣的:
if(a + b < 0)
complain();
通常,這是不會工作的。
一旦a + b發生了溢出,對于結果的任何賭注都是沒有意義的。例如,在某些機器上,一個加法運算會將一個內部寄存器設置為四種狀態:正、負、零或溢出。 在這樣的機器上,編譯器有權將上面的例子實現為首先將a和b加在一起,然后檢查內部寄存器狀態是否為負。如果該運算溢出,內部寄存器將處于溢出狀態,這個測試會失敗。
使這個特殊的測試能夠成功的一個正確的方法是依賴于無符號算術的良好定義,既要在有符號和無符號之間進行轉換:
if((int)((unsigned)a + (unsigned)b) < 0)
complain();
4.9 移位運算符
兩個原因會令使用移位運算符的人感到煩惱:
在右移運算中,空出的位是用0填充還是用符號位填充?
移位的數量允許使用哪些數?
第一個問題的答案很簡單,但有時是實現相關的。如果要進行移位的操作數是無符號的,會移入0。如果操作數是帶符號的,則實現有權決定是移入0還是移入符號位。如果在一個右移操作中你很關心空位,那么用unsigned來聲明變量。這樣你就有權假設空位被設置為0。
第二個問題的答案同樣簡單:如果待移位的數長度為n,則移位的數量必須大于等于0并且嚴格地小于n。因此,在一次單獨的操作中不可能將所有的位從變量中移出。
例如,如果一個int是32位,且n是一個int,寫n << 31和n << 0是合法的,但n << 32和n << -1是不合法的。
注意,即使實現將符號為移入空位,對一個帶符號整數的右移運算和除以2的某次冪也不是等價的。為了證明這一點,考慮(-1) >> 1的值,這是不可能為0的。[譯注:(-1) / 2的結果是0。]
腳注
1. 本文是基于圖書《C Traps and Pitfalls》(Addison-Wesley, 1989, ISBN 0-201-17928-8)的一個擴充,有興趣的讀者可以讀一讀它。
2. 因為!=的結果不是1就是0。
3. 感謝Guy Harris為我指出這個問題。
4. Dennis Ritchie和Steve Johnson同時向我指出了這個問題。
5. 感謝一位不知名的志愿者提出這個問題。
6. 感謝Richard Stevens指出了這個問題。
7. 一些C編譯器要求每個外部對象僅有一個定義,但可以有多個聲明。使用這樣的編譯器時,我們何以很容易地將一個聲明放到一個包含文件中,并將其定義放到其它地方。這意味著每個外部對象的類型將出現兩次,但這比出現多于兩次要好。
8. 分離函數參數用的逗號不是逗號運算符。例如在f(x, y)中,x和y的獲取順序是未定義的,但在g((x, y))中不是這樣的。其中g只有一個參數。它的值是通過對x進行求值、拋棄這個值、再對y進行求值來確定的。
9. 預處理器還可以很容易地組織這樣的顯式常量以能夠方便地找到它們。
10. PDP-11和VAX-11是數組設備集團(DEC)的商標。