天天看點

C語言缺陷與陷阱(一)

C語言像一把雕刻刀,鋒利,并且在技師手中非常有用。和任何鋒利的工具一樣,C會傷到那些不能掌握它的人。本文介紹C語言傷害粗心的人的方法,以及如何避免傷害。

第一部分研究了當程式被劃分為記号時會發生的問題。第二部分繼續研究了當程式的記号被編譯器組合為聲明、表達式和語句時會出現的問題。第三部分研究了由多個部分組成、分别編譯并綁定到一起的C程式。第四部分處理了概念上的誤解:當一個程式具體執行時會發生的事情。第五部分研究了我們的程式和它們所使用的常用庫之間的關系。在第六部分中,我們注意到了我們所寫的程式也許并不是我們所運作的程式;預處理器将首先運作。最後,第七部分讨論了可移植性問題:一個能在一個實作中運作的程式無法在另一個實作中運作的原因。

詞法分析器(lexical analyzer):檢查組成程式的字元序列,并将它們劃分為記号(token)一個記号是一個由一個或多個字元構成的序列,它在語言被編譯時具有一個(相關地)統一的意義。

C程式被兩次劃分為記号,首先是預處理器讀取程式,它必須對程式進行記号劃分以發現辨別宏的辨別符。通過對每個宏進行求值來替換宏調用,最後,經過宏替換的程式又被彙內建字元流送給編譯器。編譯器再第二次将這個流劃分為記号。

1.1= 不是 ==:C語言則是用=表示指派而用==表示比較。這是因為指派的頻率要高于比較,是以為其配置設定更短的符号。C還将指派視為一個運算符,是以可以很容易地寫出多重指派(如a = b = c),并且可以将指派嵌入到一個大的表達式中。

1.2 & 和| 不是&& 和||

1.3 多字元記号

C語言參考手冊說明了如何決定:“如果輸入流到一個給定的字元串為止已經被識别為記号,則應該包含下一個字元以組成能夠構成記号的最長的字元串” “最長子串原則”

1.4 例外

    組合指派運算符如+=實際上是兩個記号。是以,

a + /* strange */ = 1

a += 1

是一個意思。看起來像一個單獨的記号而實際上是多個記号的隻有這一個特例。特别地,

p - > a

是不合法的。它和

p -> a

不是同義詞。

另一方面,有些老式編譯器還是将=+視為一個單獨的記号并且和+=是同義詞。

1.5 字元串和字元

包圍在單引号中的一個字元隻是編寫整數的另一種方法。這個整數是給定的字元在實作的對照序列中的一個對應的值。而一個包圍在雙引号中的字元串,隻是編寫一個有雙引号之間的字元和一個附加的二進制值為零的字元所初始化的一個無名數組的指針的一種簡短方法。

使用一個指針來代替一個整數通常會得到一個警告消息(反之亦然),使用雙引号來代替單引号也會得到一個警告消息(反之亦然)。但對于不檢查參數類型的編譯器卻除外。

由于一個整數通常足夠大,以至于能夠放下多個字元,一些C編譯器允許在一個字元常量中存放多個字元。這意味着用'yes'代替"yes"将不會被發現。後者意味着“分别包含y、e、s和一個空字元的四個連續存儲器區域中的第一個的位址”,而前者意味着“在一些實作定義的樣式中表示由字元y、e、s聯合構成的一個整數”。這兩者之間的任何一緻性都純屬巧合。

2 句法缺陷

了解這些記号是如何構成聲明、表達式、語句和程式的。

2.1 了解聲明

每個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處的子程式

2.2 運算符并不總是具有你所想象的優先級

綁定得最緊密的運算符并不是真正的運算符:下标、函數調用和結構選擇。這些都與左邊相關聯。

接下來是一進制運算符。它們具有真正的運算符中的最高優先級。由于函數調用比一進制運算符綁定得更緊密,你必須寫(*p)()來調用p指向的函數;*p()表示p是一個傳回一個指針的函數。轉換是一進制運算符,并且和其他一進制運算符具有相同的優先級。一進制運算符是右結合的,是以*p++表示*(p++),而不是(*p)++。

在接下來是真正的二進制運算符。其中數學運算符具有最高的優先級,然後是移位運算符、關系運算符、邏輯運算符、指派運算符,最後是條件運算符。需要記住的兩個重要的東西是:

1.    所有的邏輯運算符具有比所有關系運算符都低的優先級。

2.    移位運算符比關系運算符綁定得更緊密,但又不如數學運算符。

乘法、除法和求餘具有相同的優先級,加法和減法具有相同的優先級,以及移位運算符具有相同的優先級。

還有就是六個關系運算符并不具有相同的優先級:==和!=的優先級比其他關系運算符要低。

在邏輯運算符中,沒有任何兩個具有相同的優先級。按位運算符比所有順序運算符綁定得都緊密,每種與運算符都比相應的或運算符綁定得更緊密,并且按位異或(^)運算符介于按位與和按位或之間。

    三元運算符的優先級比我們提到過的所有運算符的優先級都低。

這個例子還說明了指派運算符具有比條件運算符更低的優先級是有意義的。另外,所有的複合指派運算符具有相同的優先級并且是自右至左結合的

具有最低優先級的是逗号運算符。指派是另一種運算符,通常具有混合的優先級。

2.3 看看這些分号!

或者是一個空語句,無任何效果;或者編譯器可能提出一個診斷消息,可以友善除去掉它。一個重要的差別是在必須跟有一個語句的if和while語句中。另一個因分号引起巨大不同的地方是函數定義前面的結構聲明的末尾,考慮下面的程式片段:

struct foo {

    int x;

}

f() {

    ...

在緊挨着f的第一個}後面丢失了一個分号。它的效果是聲明了一個函數f,傳回值類型是struct foo,這個結構成了函數聲明的一部分。如果這裡出現了分号,則f将被定義為具有預設的整型傳回值[5]。

2.4 switch語句

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的處理可以簡化其他一些特殊的處理。

2.5 函數調用

和其他程式設計語言不同,C要求一個函數調用必須有一個參數清單,但可以沒有參數。是以,如果f是一個函數,

f();

就是對該函數進行調用的語句,而

f;

什麼也不做。它會作為函數位址被求值,但不會調用它[6]。

2.6 懸挂else問題

一個else總是與其最近的if相關聯。

3 連接配接

一個C程式可能有很多部分組成,它們被分别編譯,并由一個通常稱為連接配接器、連接配接編輯器或加載器的程式綁定到一起。由于編譯器一次通常隻能看到一個檔案,是以它無法檢測到需要程式的多個源檔案的内容才能發現的錯誤。

3.1 你必須自己檢查外部類型

假設你有一個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]。

4 語義缺陷

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增長之前被求值。在某些實作中,這是可能的;但在另一些實作中卻不可能。另一種情況出于同樣的原因會失敗:

    y[i++] = x[i];

而下面的代碼是可以工作的:

while(i < n) {

    y[i] = x[i];

    i++;

當然,這可以簡寫為:

for(i = 0; i < n; i++)

4.2 &&、||和!運算符

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;

4.4 C并不總是轉換實參

    下面的程式段由于兩個原因會失敗:

double s;

s = sqrt(2);

printf("%g\n", s);

    第一個原因是sqrt()需要一個double值作為它的參數,但沒有得到。第二個原因是它傳回一個double值但沒有這樣聲名。改正的方法隻有一個:

double s, sqrt();

s = sqrt(2.0);

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

繼續閱讀