C語言像一把雕刻刀,鋒利,並且在技師手中非常有用。和任何鋒利的工具一樣,C會傷到那些不能掌握它的人。本文介紹C語言傷害粗心的人的方法,以及如何避免傷害。
第一部分研究了當程序被劃分爲記號時會發生的問題。第二部分繼續研究了當程序的記號被編譯器組合爲聲明、表達式和語句時會出現的問題。第三部分研究了由多個部分組成、分別編譯並綁定到一起的C程序。第四部分處理了概念上的誤解:當一個程序具體執行時會發生的事情。第五部分研究了我們的程序和它們所使用的常用庫之間的關系。在第六部分中,我們注意到了我們所寫的程序也許並不是我們所運行的程序;預處理器將首先運行。最後,第七部分討論了可移植性問題:一個能在一個實現中運行的程序無法在另一個實現中運行的原因。
詞法分析器(lexical analyzer):檢查組成程序的字符序列,並將它們劃分爲記號(token)一個記號是一個由一個或多個字符構成的序列,它在語言被編譯時具有一個(相關地)統一的意義。
C程序被兩次劃分爲記號,首先是預處理器讀取程序,它必須對程序進行記號劃分以發現標識宏的標識符。通過對每個宏進行求值來替換宏調用,最後,經過宏替換的程序又被匯集成字符流送給編譯器。編譯器再第二次將這個流劃分爲記號。
1.1= 不是 ==:C語言則是用=表示賦值而用==表示比較。這是因爲賦值的頻率要高於比較,因此爲其分配更短的符號。C還將賦值視爲一個運算符,因此可以很容易地寫出多重賦值(如a = b = c),並且可以將賦值嵌入到一個大的表達式中。
C語言參考手冊說明瞭如何決定:“如果輸入流到一個給定的字符串爲止已經被識別爲記號,則應該包含下一個字符以組成能夠構成記號的最長的字符串” “最長子串原則”
組合賦值運算符如+=實際上是兩個記號。因此,
a + /* strange */ = 1
和
a += 1
是一個意思。看起來像一個單獨的記號而實際上是多個記號的只有這一個特例。特別地,
p - > a
是不合法的。它和
p -> a
不是同義詞。
另一方面,有些老式編譯器還是將=+視爲一個單獨的記號並且和+=是同義詞。
包圍在單引號中的一個字符只是編寫整數的另一種方法。這個整數是給定的字符在實現的對照序列中的一個對應的值。而一個包圍在雙引號中的字符串,只是編寫一個有雙引號之間的字符和一個附加的二進制值爲零的字符所初始化的一個無名數組的指針的一種簡短方法。
使用一個指針來代替一個整數通常會得到一個警告消息(反之亦然),使用雙引號來代替單引號也會得到一個警告消息(反之亦然)。但對於不檢查參數類型的編譯器卻除外。
由於一個整數通常足夠大,以至於能夠放下多個字符,一些C編譯器允許在一個字符常量中存放多個字符。這意味着用'yes'代替"yes"將不會被發現。後者意味着“分別包含y、e、s和一個空字符的四個連續存儲器區域中的第一個的地址”,而前者意味着“在一些實現定義的樣式中表示由字符y、e、s聯合構成的一個整數”。這兩者之間的任何一致性都純屬巧合。
理解這些記號是如何構成聲明、表達式、語句和程序的。
每個C變量聲明都具有兩個部分:一個類型和一組具有特定格式的、期望用來對該類型求值的表達式。
float *g(), (*h)();
表示*g()和(*h)()都是float表達式。由於()比*綁定得更緊密,*g()和*(g())表示同樣的東西:g是一個返回指float指針的函數,而h是一個指向返回float的函數的指針。
當我們知道如何聲明一個給定類型的變量以後,就能夠很容易地寫出一個類型的模型(cast):只要刪除變量名和分號並將所有的東西包圍在一對圓括號中即可。
float *g();
聲明g是一個返回float指針的函數,所以(float *())就是它的模型。
(*(void(*)())0)();硬件會調用地址爲0處的子程序
(*0)(); 但這樣並不行,因爲*運算符要求必須有一個指針作爲它的操作數。另外,這個操作數必須是一個指向函數的指針,以保證*的結果可以被調用。需要將0轉換爲一個可以描述“指向一個返回void的函數的指針”的類型。(Void(*)())0
在這裏,我們解決這個問題時沒有使用typedef聲明。通過使用它,我們可以更清晰地解決這個問題:
typedef void (*funcptr)();// typedef funcptr void (*)();指向返回void的函數的指針
(*(funcptr)0)();//調用地址爲0處的子程序
綁定得最緊密的運算符並不是真正的運算符:下標、函數調用和結構選擇。這些都與左邊相關聯。
接下來是一元運算符。它們具有真正的運算符中的最高優先級。由於函數調用比一元運算符綁定得更緊密,你必須寫(*p)()來調用p指向的函數;*p()表示p是一個返回一個指針的函數。轉換是一元運算符,並且和其他一元運算符具有相同的優先級。一元運算符是右結合的,因此*p++表示*(p++),而不是(*p)++。
在接下來是真正的二元運算符。其中數學運算符具有最高的優先級,然後是移位運算符、關系運算符、邏輯運算符、賦值運算符,最後是條件運算符。需要記住的兩個重要的東西是:
1. 所有的邏輯運算符具有比所有關系運算符都低的優先級。
2. 移位運算符比關系運算符綁定得更緊密,但又不如數學運算符。
乘法、除法和求餘具有相同的優先級,加法和減法具有相同的優先級,以及移位運算符具有相同的優先級。
還有就是六個關系運算符並不具有相同的優先級:==和!=的優先級比其他關系運算符要低。
在邏輯運算符中,沒有任何兩個具有相同的優先級。按位運算符比所有順序運算符綁定得都緊密,每種與運算符都比相應的或運算符綁定得更緊密,並且按位異或(^)運算符介於按位與和按位或之間。
三元運算符的優先級比我們提到過的所有運算符的優先級都低。
這個例子還說明瞭賦值運算符具有比條件運算符更低的優先級是有意義的。另外,所有的復合賦值運算符具有相同的優先級並且是自右至左結合的
具有最低優先級的是逗號運算符。賦值是另一種運算符,通常具有混合的優先級。
或者是一個空語句,無任何效果;或者編譯器可能提出一個診斷消息,可以方便除去掉它。一個重要的區別是在必須跟有一個語句的if和while語句中。另一個因分號引起巨大不同的地方是函數定義前面的結構聲明的末尾,考慮下面的程序片段:
struct foo {
int x;
}
f() {
...
}
在緊挨着f的第一個}後面丟失了一個分號。它的效果是聲明瞭一個函數f,返回值類型是struct foo,這個結構成了函數聲明的一部分。如果這裏出現了分號,則f將被定義爲具有默認的整型返回值[5]。
C中的case標籤是真正的標籤:控制流程可以無限制地進入到一個case標籤中。
看看另一種形式,假設C程序段看起來更像Pascal:
switch(color) {
case 1: printf ("red");
case 2: printf ("yellow");
case 3: printf ("blue");
}
並且假設color的值是2。則該程序將打印yellowblue,因爲控制自然地轉入到下一個printf()的調用。
這既是C語言switch語句的優點又是它的弱點。說它是弱點,是因爲很容易忘記一個break語句,從而導致程序出現隱晦的異常行爲。說它是優點,是因爲通過故意去掉break語句,可以很容易實現其他方法難以實現的控制結構。尤其是在一個大型的switch語句中,我們經常發現對一個case的處理可以簡化其他一些特殊的處理。
和其他程序設計語言不同,C要求一個函數調用必須有一個參數列表,但可以沒有參數。因此,如果f是一個函數,
f();
就是對該函數進行調用的語句,而
f;
什麼也不做。它會作爲函數地址被求值,但不會調用它[6]。
一個else總是與其最近的if相關聯。
一個C程序可能有很多部分組成,它們被分別編譯,並由一個通常稱爲連接器、連接編輯器或加載器的程序綁定到一起。由於編譯器一次通常只能看到一個文件,因此它無法檢測到需要程序的多個源文件的內容才能發現的錯誤。
假設你有一個C程序,被劃分爲兩個文件。其中一個包含如下聲明:
int n;
而令一個包含如下聲明:
long n;
這不是一個有效的C程序,因爲一些外部名稱在兩個文件中被聲明爲不同的類型。然而,很多實現檢測不到這個錯誤,因爲編譯器在編譯其中一個文件時並不知道另一個文件的內容。因此,檢查類型的工作只能由連接器(或一些工具程序如lint)來完成;如果操作系統的連接器不能識別數據類型,C編譯器也沒法過多地強制它。
那麼,這個程序運行時實際會發生什麼?這有很多可能性:
1. 實現足夠聰明,能夠檢測到類型衝突。則我們會得到一個診斷消息,說明n在兩個文件中具有不同的類型。
2. 你所使用的實現將int和long視爲相同的類型。典型的情況是機器可以自然地進行32位運算。在這種情況下你的程序或許能夠工作,好象你兩次都將變量聲明爲long(或int)。但這種程序的工作純屬偶然。
3. n的兩個實例需要不同的存儲,它們以某種方式共享存儲區,即對其中一個的賦值對另一個也有效。這可能發生,例如,編譯器可以將int安排在long的低位。不論這是基於系統的還是基於機器的,這種程序的運行同樣是偶然。
4. n的兩個實例以另一種方式共享存儲區,即對其中一個賦值的效果是對另一個賦以不同的值。在這種情況下,程序可能失敗。
這種情況發生的另一個例子出奇地頻繁。程序的某一個文件包含下面的聲明:
char filename[] = "etc/passwd";
而另一個文件包含這樣的聲明:
char *filename;
儘管在某些環境中數組和指針的行爲非常相似,但它們是不同的。在第一個聲明中,filename是一個字符數組的名字。儘管使用數組的名字可以產生數組第一個元素的指針,但這個指針只有在需要的時候才產生並且不會持續。在第二個聲明中,filename是一個指針的名字。這個指針可以指向程序員讓它指向的任何地方。如果程序員沒有給它賦一個值,它將具有一個默認的0值(NULL)([譯注]實際上,在C中一個爲初始化的指針通常具有一個隨機的值,這是很危險的!)。
這兩個聲明以不同的方式使用存儲區,它們不可能共存。
避免這種類型衝突的一個方法是使用像lint這樣的工具(如果可以的話)。爲了在一個程序的不同編譯單元之間檢查類型衝突,一些程序需要一次看到其所有部分。典型的編譯器無法完成,但lint可以。
避免該問題的另一種方法是將外部聲明放到包含文件中。這時,一個外部對象的類型僅出現一次[7]。
一些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增長之前被求值。在某些實現中,這是可能的;但在另一些實現中卻不可能。另一種情況出於同樣的原因會失敗:
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];
在很多語言中,具有n個元素的數組其元素的號碼和它的下標是從1到n嚴格對應的。但在C中不是這樣。
個具有n個元素的C數組中沒有下標爲n的元素,其中的元素的下標是從0到n - 1。因此從其它語言轉到C語言的程序員應該特別小心地使用數組:
int i, a[10];
for(i = 1; i <= 10; i++)
a[i] = 0;
下面的程序段由於兩個原因會失敗:
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,因此在成功使用它之前必須要聲名。
這裏有一個更加壯觀的例子:
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纔可以正常地增長,直到循環結束。
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);
提喻法(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.(將全面的單位用作不全面的單位,或反之;如整體對局部或局部對整體、一般對特殊或特殊對一般,等等。)”
要記住的是,復制一個指針並不能復制它所指向的東西。
將一個整數轉換爲一個指針的結果是實現相關的(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);
C語言關於整數操作的上溢或下溢定義得非常明確。
只要有一個操作數是無符號的,結果就是無符號的,並且以2n爲模,其中n爲字長。如果兩個操作數都是帶符號的,則結果是未定義的。
例如,假設a和b是兩個非負整型變量,你希望測試a + b是否溢出。一個明顯的辦法是這樣的:
if(a + b < 0)
complain();
通常,這是不會工作的。
一旦a + b發生了溢出,對於結果的任何賭注都是沒有意義的。例如,在某些機器上,一個加法運算會將一個內部寄存器設置爲四種狀態:正、負、零或溢出。在這樣的機器上,編譯器有權將上面的例子實現爲首先將a和b加在一起,然後檢查內部寄存器狀態是否爲負。如果該運算溢出,內部寄存器將處於溢出狀態,這個測試會失敗。
使這個特殊的測試能夠成功的一個正確的方法是依賴於無符號算術的良好定義,即要在有符號和無符號之間進行轉換:
if((int)((unsigned)a + (unsigned)b) < 0)
complain();
兩個原因會令使用移位運算符的人感到煩惱:
1. 在右移運算中,空出的位是用0填充還是用符號位填充?
2. 移位的數量允許使用哪些數?
第一個問題的答案很簡單,但有時是實現相關的。如果要進行移位的操作數是無符號的,會移入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。]
考慮下面的程序:
#include
main() {
char c;//int c;
while((c = getchar()) != EOF)
putchar(c);
}
這段程序看起來好像要將標準輸入復制到標準輸出。實際上,它並不完全會做這些。
原因是c被聲明爲字符而不是整數。這意味着它將不能接收可能出現的所有字符包括EOF。
因此這裏有兩種可能性。有時一些合法的輸入字符會導致c攜帶和EOF相同的值,有時又會使c無法存放EOF值。在前一種情況下,程序會在文件的中間停止復制。在後一種情況下,程序會陷入一個無限循環。
實際上,還存在着第三種可能:程序會偶然地正確工作。C語言參考手冊嚴格地定義了表達式
((c = getchar()) != EOF)
的結果。其6.1節中聲明:
當一個較長的整數被轉換爲一個較短的整數或一個char時,它會被截去左側;超出的位被簡單地丟棄。
7.14節聲明:
存在着很多賦值運算符,它們都是從右至左結合的。它們都需要一個左值作爲左側的操作數,而賦值表達式的類型就是其左側的操作數的類型。其值就是已經賦過值的左操作數的值。
這兩個條款的組合效果就是必須通過丟棄getchar()的結果的高位,將其截短爲字符,之後這個被截短的值再與EOF進行比較。作爲這個比較的一部分,c必須被擴展爲一個整數,或者採取將左側的位用0填充,或者適當地採取符號擴展。
然而,一些編譯器並沒有正確地實現這個表達式。它們確實將getchar()的值的低幾位賦給c。但在c和EOF的比較中,它們卻使用了getchar()的值!這樣做的編譯器會使這個事例程序看起來能夠“正確地”工作。
立即安排輸出的顯示通常比將其暫時保存在一大塊一起輸出要昂貴得多。因此,C實現通常允許程序員控制產生多少輸出後在實際地寫出它們。
這個控制通常約定爲一個稱爲setbuf()的庫函數。如果buf是一個具有適當大小的字符數組,則
setbuf(stdout, buf);
將告訴I/O庫寫入到stdout中的輸出要以buf作爲一個輸出緩衝,並且等到buf滿了或程序員直接調用fflush()再實際寫出。緩衝區的合適的大小在中定義爲BUFSIZ。
因此,下面的程序解釋了通過使用setbuf()來講標準輸入復制到標準輸出:
#include
main() {
int c;
char buf[BUFSIZ];
setbuf(stdout, buf);
while((c = getchar()) != EOF)
putchar(c);
}
不幸的是,這個程序是錯誤的,因爲一個細微的原因。
要知道毛病出在哪,我們需要知道緩衝區最後一次刷新是在什麼時候。答案;主程序完成之後,庫將控制交回到操作系統之前所執行的清理的一部分。在這一時刻,緩衝區已經被釋放了!
有兩種方法可以避免這一問題。
首先,使用靜態緩衝區,或者將其顯式地聲明爲靜態:
static char buf[BUFSIZ];
或者將整個聲明移到主函數之外。
另一種可能的方法是動態地分配緩衝區並且從不釋放它:
char *malloc();
setbuf(stdout, malloc(BUFSIZ));
注意在後一種情況中,不必檢查malloc()的返回值,因爲如果它失敗了,會返回一個空指針。而setbuf()可以接受一個空指針作爲其第二個參數,這將使得stdout變成非緩衝的。這會運行得很慢,但它是可以運行的。
由於宏可以象函數那樣出現,有些程序員有時就會將它們視爲等價的。因此,看下面的定義:
#define max(a, b) ((a) > (b) ? (a) : (b))
注意宏體中所有的括號。它們是爲了防止出現a和b是帶有比>優先級低的表達式的情況。
一個重要的問題是,像max()這樣定義的宏每個操作數都會出現兩次並且會被求值兩次。因此,在這個例子中,如果a比b大,則a就會被求值兩次:一次是在比較的時候,而另一次是在計算max()值的時候。
這不僅是低效的,還會發生錯誤:
biggest = x[0];
i = 1;
while(i < n)
biggest = max(biggest, x[i++]);
當max()是一個真正的函數時,這會正常地工作,但當max()是一個宏的時候會失敗。譬如,假設x[0]是2、x[1]是3、x[2]是1。我們來看看在第一次循環時會發生什麼。賦值語句會被擴展爲:
biggest = ((biggest) > (x[i++]) ? (biggest) : (x[i++]));
首先,biggest與x[i++]進行比較。由於i是1而x[1]是3,這個關系是“假”。其副作用是,i增長到2。
由於關系是“假”,x[i++]的值要賦給biggest。然而,這時的i變成2了,因此賦給biggest的值是x[2]的值,即1。
避免這些問題的方法是保證max()宏的參數沒有副作用:
biggest = x[0];
for(i = 1; i < n; i++)
biggest = max(biggest, x[i]);
還有一個危險的例子是混合宏及其副作用。這是來自UNIX第八版的中putc()宏的定義:
#define putc(x, p) (--(p)->_cnt >= 0 ? (*(p)->_ptr++ = (x)) : _flsbuf(x, p))
putc()的第一個參數是一個要寫入到文件中的字符,第二個參數是一個指向一個表示文件的內部數據結構的指針。注意第一個參數完全可以使用如*z++之類的東西,儘管它在宏中兩次出現,但只會被求值一次。而第二個參數會被求值兩次(在宏體中,x出現了兩次,但由於它的兩次出現分別在一個:的兩邊,因此在putc()的一個實例中它們之中有且僅有一個被求值)。由於putc()中的文件參數可能帶有副作用,這偶爾會出現問題。不過,用戶手冊文檔中提到:“由於putc()被實現爲宏,其對待stream可能會具有副作用。特別是putc(c, *f++)不能正確地工作。”但是putc(*c++, f)在這個實現中是可以工作的。
有些C實現很不小心。例如,沒有人能正確處理putc(*c++, f)。另一個例子,考慮很多C庫中出現的toupper()函數。它將一個小寫字母轉換爲相應的大寫字母,而其它字符不變。如果我們假設所有的小寫字母和所有的大寫字母都是相鄰的(大小寫之間可能有所差距),我們可以得到這樣的函數:
toupper(c) {
if(c >= 'a' && c <= 'z')
c += 'A' - 'a';
return c;
}
在很多C實現中,爲了減少比實際計算還要多的調用開銷,通常將其實現爲宏:
#define toupper(c) ((c) >= 'a' && (c) <= 'z' ? (c) + ('A' - 'a') : (c))
很多時候這確實比函數要快。然而,當你試着寫toupper(*p++)時,會出現奇怪的結果。
另一個需要注意的地方是使用宏可能會產生巨大的表達式。例如,繼續考慮max()的定義:
#define max(a, b) ((a) > (b) ? (a) : (b))
假設我們這個定義來查找a、b、c和d中的最大值。如果我們直接寫:
max(a, max(b, max(c, d)))
它將被擴展爲:
((a) > (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))) ?
(a) : (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))))
這出奇的龐大。我們可以通過平衡操作數來使它短一些:
max(max(a, b), max(c, d))
這會得到:
((((a) > (b) ? (a) : (b))) > (((c) > (d) ? (c) : (d))) ?
(((a) > (b) ? (a) : (b))) : (((c) > (d) ? (c) : (d))))
這看起來還是寫:
biggest = a;
if(biggest < b) biggest = b;
if(biggest < c) biggest = c;
if(biggest < d) biggest = d;
比較好一些。
宏的一個通常的用途是保證不同地方的多個事物具有相同的類型:
#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b, c;
這允許程序員可以通過只改變程序中的一行就能改變a、b和c的類型,儘管a、b和c可能聲明在很遠的不同地方。
使用這樣的宏定義還有着可移植性的優勢——所有的C編譯器都支持它。很多C編譯器並不支持另一種方法:
typedef struct foo FOOTYPE;
這將FOOTYPE定義爲一個與struct foo等價的新類型。
這兩種爲類型命名的方法可以是等價的,但typedef更靈活一些。例如,考慮下面的例子:
#define T1 struct foo *
typedef struct foo * T2;
這兩個定義使得T1和T2都等價於一個struct foo的指針。但看看當我們試圖在一行中聲明多於一個變量的時候會發生什麼:
T1 a, b;
T2 c, d;
第一個聲明被擴展爲:
struct foo * a, b;
這裏a被定義爲一個結構指針,但b被定義爲一個結構(而不是指針)。相反,第二個聲明中c和d都被定義爲指向結構的指針,因爲T2的行爲好像真正的類型一樣。
今天,一個C程序員如果想寫出對於不同環境中的用戶都有用的程序就必須知道很多這些細微的差別。
一個標識符是一個字符和數字序列,第一個字符必須是一個字母。下劃線_算作字母。大寫字母和小寫字母是不同的。只有前八個字符是籤名,但可以使用更多的字符。可以被多種彙編器和加載器使用的外部標識符,有着更多的限制:
考慮下面這個顯著的函數:
char *Malloc(unsigned n) {
char *p, *malloc();
p = malloc(n);
if(p == NULL)
panic("out of memory");
return p;
}
這個函數是保證耗盡內存而不會導致沒有檢測的一個簡單的辦法。程序員可以通過調用Mallo()來代替malloc()。如果malloc()不幸失敗,將調用panic()來顯示一個恰當的錯誤消息並終止程序。
然而,考慮當該函數用於一個忽略大小寫區別的系統中時會發生什麼。這時,名字malloc和Malloc是等價的。換句話說,庫函數malloc()被上面的Malloc()函數完全取代了,當調用malloc()時它調用的是它自己。顯然,其結果就是第一次嘗試分配內存就會陷入一個遞歸循環並隨之發生混亂。但在一些能夠區分大小寫的實現中這個函數還是可以工作的。
C爲程序員提供三種整數尺寸:普通、短和長,還有字符,其行爲像一個很小的整數。C語言定義對各種整數的大小不作任何保證:
1. 整數的四種尺寸是非遞減的。
2. 普通整數的大小要足夠存放任意的數組下標。
3. 字符的大小應該體現特定硬件的本質。
許多現代機器具有8位字符,不過還有一些具有7位獲9位字符。因此字符通常是7、8或9位。
長整數通常至少32位,因此一個長整數可以用於表示文件的大小。
普通整數通常至少16位,因爲太小的整數會更多地限制一個數組的最大大小。
短整數總是恰好16位。
一種更可移植的做法是定義一個“新的”類型:
typedef long tenmil;
現在你就可以使用這個類型來聲明一個變量並知道它的寬度了,最壞的情況下,你也只要改變這個單獨的類型定義就可以使所有這些變量具有正確的類型。
這些問題在將一個char制轉換爲一個更大的整數時變得尤爲重要。對於相反的轉換,其結果卻是定義良好的:多餘的位被簡單地丟棄掉。但一個編譯器將一個char轉換爲一個int卻需要作出選擇:將char視爲帶符號量還是無符號量?如果是前者,將char擴展爲int時要復制符號位;如果是後者,則要將多餘的位用0填充。
這個決定的結果對於那些在處理字符時習慣將高位置1的人來說非常重要。這決定着8位的字符範圍是從-128到127還是從0到255。這又影響着程序員對哈希表和轉換表之類的東西的設計。
如果你關心一個字符值最高位置一時是否被視爲一個負數,你應該顯式地將它聲明爲unsigned char。這樣就能保證在轉換爲整數時是基0的,而不像普通char變量那樣在一些實現中是帶符號的而在另一些實現中是無符號的。
另外,還有一種誤解是認爲當c是一個字符變量時,可以通過寫(unsigned)c來得到與c等價的無符號整數。這是錯誤的,因爲一個char值在進行任何操作(包括轉換)之前轉換爲int。這時c會首先轉換爲一個帶符號整數再轉換爲一個無符號整數,這會產生奇怪的結果。
正確的方法是寫(unsigned char)c。
這裏再一次重復:一個關心右移操作如何進行的程序最好將所有待移位的量聲明爲無符號的。
假設我們用b除a得到商爲q餘數爲r:
q = a / b;
r = a % b;
我們暫時假設b > 0。
1. 最重要的,我們期望q * b + r == a,因爲這是對餘數的定義。
2. 如果a的符號發生改變,我們期望q的符號也發生改變,但絕對值不變。
3. 我們希望保證r >= 0且r < b。例如,如果餘數將作爲一個哈希表的索引,它必須要保證總是一個有效的索引。
這三點清楚地描述了整數除法和求餘操作。不幸的是,它們不能同時爲真。
考慮3 / 2,商1餘0。(1)這滿足第一點。而-3 / 2的值呢?根據第二點,商應該是-1,但如果是這樣的話,餘數必須也是-1,這違反了第三點。或者,我們可以通過將餘數標記爲1來滿足第三點,但這時根據第一點商應該是-2。這又違反了第二點。
因此C和其他任何實現了整數除法舍入的語言必須放棄上述三個原則中的至少一個。
很多程序設計語言放棄了第三點,要求餘數的符號必須和被除數相同。這可以保證第一點和第二點。很多C實現也是這樣做的。
儘管有些時候不需要靈活性,C語言還是足夠可以讓我們令除法完成我們所要做的、提供我們所想知道的。例如,假設我們有一個數n表示一個標識符中的字符的一些函數,並且我們想通過除法得到一個哈希表入口h,其中0 <= h <= HASHSIZE。如果我們知道n是非負的,我們可以簡單地寫:
h = n % HASHSIZE;
然而,如果n有可能是負的,這樣寫就不好了,因爲h可能也是負的。然而,我們知道h > -HASHSIZE,因此我們可以寫:
h = n % HASHSIZE;
if(n < 0)
h += HASHSIZE;
同樣,將n聲明爲unsigned也可以。
這個尺寸是模糊的,還受庫設計的影響。在PDP-11[10]機器上運行的僅有的C實現中,有一個稱爲rand()的函數可以返回一個(僞)隨機非負整數。PDP-11中整數長度包括符號位是16位,因此rand()返回一個0到215-1之間的整數。
當C在VAX-11上實現時,整數的長度變爲32位長。那麼VAX-11上的rand()函數返回值範圍是什麼呢?
對於這個系統,加利福尼亞大學的人認爲rand()的返回值應該涵蓋所有可能的非負整數,因此它們的rand()版本返回一個0到231-1之間的整數。
而AT&T的人則覺得如果rand()函數仍然返回一個0到215之間的值則可以很容易地將PDP-11中期望rand()能夠返回一個小於215的值的程序移植到VAX-11上。
因此,現在還很難寫出不依賴實現而調用rand()函數的程序。
toupper()和tolower()函數有着類似的歷史。他們最初都被實現爲宏:
#define toupper(c) ((c) + 'A' - 'a')
#define tolower(c) ((c) + 'A' - 'a')
這些宏確實有一個缺陷,即:當給定的東西不是一個恰當的字符,它會返回垃圾。因此,下面這個通過使用這些宏來將一個文件轉爲小寫的程序是無法工作的:
int c;
while((c = getchar()) != EOF)
putchar(tolower(c));
我們必須寫:
int c;
while((c = getchar()) != EOF)
putchar(isupper(c) ? tolower(c) : c);
就這一點,AT&T中的UNIX開發組織提醒我們,toupper()和tolower()都是事先經過一些適當的參數進行測試的。考慮這樣重寫這些宏:
#define toupper(c) ((c) >= 'a' && (c) <= 'z' ? (c) + 'A' - 'a' : (c))
#define tolower(c) ((c) >= 'A' && (c) <= 'Z' ? (c) + 'a' - 'A' : (c))
但要知道,這裏c的三次出現都要被求值,這會破壞如toupper(*p++)這樣的表達式。因此,可以考慮將toupper()和tolower()重寫爲函數。toupper()看起來可能像這樣:
int toupper(int c) {
if(c >= 'a' && c <= 'z')
return c + 'A' - 'a';
return c;
}
tolower()類似。
這個改變帶來更多的問題,每次使用這些函數的時候都會引入函數調用開銷。我們的英雄認爲一些人可能不願意支付這些開銷,因此他們將這個宏重命名爲:
#define _toupper(c) ((c) + 'A' - 'a')
#define _tolower(c) ((c) + 'a' - 'A')
這就允許用戶選擇方便或速度。
這裏面其實只有一個問題:伯克利的人們和其他的C實現者並沒有跟着這麼做。這意味着一個在AT&T系統上編寫的使用了toupper()或tolower()的程序,如果沒有爲其傳遞正確大小寫字母參數,在其他C實現中可能不會正常工作。
如果不知道這些歷史,可能很難對這類錯誤進行跟蹤。
很多C實現爲用戶提供了三個內存分配函數:malloc()、realloc()和free()。調用malloc(n)返回一個指向有n個字符的新分配的內存的指針,這個指針可以由程序員使用。給free()傳遞一個指向由malloc()分配的內存的指針可以使這塊內存得以再次使用。通過一個指向已分配區域的指針和一個新的大小調用realloc()可以將這塊內存擴大或縮小到新尺寸,這個過程中可能要復制內存。
也許有人會想,真相真是有點微妙啊。下面是System V接口定義中出現的對realloc()的描述:
realloc改變一個由ptr指向的size個字節的塊,並返回該塊(可能被移動)的指針。在新舊尺寸中比較小的一個尺寸之下的內容不會被改變。此外,還包含了描述realloc()的另外一段:
如果在最後一次調用malloc、realloc或calloc後釋放了ptr所指向的塊,realloc依舊可以工作;因此,free、malloc和realloc的順序可以利用malloc壓縮存貯的查找策略。
因此,下面的代碼片段在UNIX第七版中是合法的:
free (p);
p = realloc(p, newsize);
這一特性保留在從UNIX第七版衍生出來的系統中:可以先釋放一塊存儲區域,然後再重新分配它。這意味着,在這些系統中釋放的內存中的內容在下一次內存分配之前可以保證不變。因此,在這些系統中,我們可以用下面這種奇特的思想來釋放一個鏈表中的所有元素:
for(p = head; p != NULL; p = p->next)
free((char *)p);
而不用擔心調用free()會導致p->next不可用。
不用說,這種技術是不推薦的,因爲不是所有C實現都能在內存被釋放後將它的內容保留足夠長的時間。然而,第七版的手冊遺留了一個未聲明的問題:realloc()的原始實現實際上是必須要先釋放再重新分配的。出於這個原因,一些C程序都是先釋放內存再重新分配的,而當這些程序移植到其他實現中時就會出現問題。
下面的程序帶有兩個參數:一個長整數和一個函數(的指針)。它將整數轉換位十進制數,並用代表其中每一個數字的字符來調用給定的函數。
void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)('-');
n = -n;
}
if(n >= 10)
printnum(n / 10, p);
(*p)(n % 10 + '0');
}
這個程序非常簡單。首先檢查n是否爲負數;如果是,則打印一個符號並將n變爲正數。接下來,測試是否n >= 10。如果是,則它的十進製表示中包含兩個或更多個數字,因此我們遞歸地調用printnum()來打印除最後一個數字外的所有數字。最後,我們打印最後一個數字。
這個程序——由於它的簡單——具有很多可移植性問題。首先是將n的低位數字轉換成字符形式的方法。用n % 10來獲取低位數字的值是好的,但爲它加上'0'來獲得相應的字符表示就不好了。這個加法假設機器中順序的數字所對應的字符數順序的,沒有間隔,因此'0' + 5和'5'的值是相同的,等等。儘管這個假設對於ASCII和EBCDIC字符集是成立的,但對於其他一些機器可能不成立。避免這個問題的方法是使用一個表:
void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)('-');
n = -n;
}
if(n >= 10)
printnum(n / 10, p);
(*p)("0123456789"[n % 10]);
}
另一個問題發生在當n < 0時。這時程序會打印一個負號並將n設置爲-n。這個賦值會發生溢出,因爲在使用2的補碼的機器上通常能夠表示的負數比正數要多。例如,一個(長)整數有k位和一個附加位表示符號,則-2k可以表示而2k卻不能。
解決這一問題有很多方法。最直觀的一種是將n賦給一個unsigned long值。然而,一些C便一起可能沒有實現unsigned long,因此我們來看看沒有它怎麼辦。
在第一個實現和第二個實現的機器上,改變一個正整數的符號保證不會發生溢出。問題僅出在改變一個負數的符號時。因此,我們可以通過避免將n變爲正數來避免這個問題。
當然,一旦我們打印了負數的符號,我們就能夠將負數和正數視爲是一樣的。下面的方法就強制在打印符號之後n爲負數,並且用負數值完成我們所有的算法。如果我們這麼做,我們就必須保證程序中打印符號的部分只執行一次;一個簡單的方法是將這個程序劃分爲兩個函數:
void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)('-');
printneg(n, p);
}
else
printneg(-n, p);
}
void printneg(long n, void (*p)()) {
if(n <= -10)
printneg(n / 10, p);
(*p)("0123456789"[-(n % 10)]);
}
printnum()現在只檢查要打印的數是否爲負數;如果是的話則打印一個符號。否則,它以n的負絕對值來調用printneg()。我們同時改變了printneg()的函數體來適應n永遠是負數或零這一事實。
我們得到什麼?我們使用n / 10和n % 10來獲取n的前導數字和結尾數字(經過適當的符號變換)。調用整數除法的行爲在其中一個操作數爲負的時候是實現相關的。因此,n % 10有可能是正的!這時,-(n % 10)是負數,將會超出我們的數字字符數組的末尾。
爲瞭解決這一問題,我們建立兩個臨時變量來存放商和餘數。作完除法後,我們檢查餘數是否在正確的範圍內,如果不是的話則調整這兩個變量。printnum()沒有改變,因此我們只列出printneg():
void printneg(long n, void (*p)()) {
long q;
int r;
if(r > 0) {
r -= 10;
q++;
}
if(n <= -10) {
printneg(q, p);
}
(*p)("0123456789"[-r]);
}
《The C Programming Language》(Kernighan and Ritchie, Prentice-Hall 1978)是最具權威的C著作。它包含了一個優秀的教程,面向那些熟悉其他高級語言程序設計的人,和一個參考手冊,簡潔地描述了整個語言。儘管自1978年以來這門語言發生了不少變化,這本書對於很多主題來說仍然是個定論。這本書同時還包含了本文中多次提到的“C語言參考手冊”。
《The C Puzzle Book》(Feuer, Prentice-Hall, 1982)是一本少見的磨煉人們文法能力的書。這本書收集了很多謎題(和答案),它們的解決方法能夠測試讀者對於C語言精妙之處的知識。
《C: A Referenct Manual》(Harbison and Steele, Prentice Hall 1984)是特意爲實現者編寫的一本參考資料。其他人也會發現它是特別有用的——因爲他能從中參考細節。
1.這本書是基於圖書《C Traps and Pitfalls》(Addison-Wesley, 1989, ISBN 0-201-17928-8)的一個擴充,有興趣的讀者可以讀一讀它。