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才能夠正常地增長,直到循環結束。