C語言:陷阱和缺陷

原著:Andrew Koenig - AT&T Bell Laboratories Murray Hill, New Jersey 07094
翻譯:lover_P

0 簡介

    C語言及其典型實現被設計爲能被專家們容易地使用。這門語言簡潔並附有表達力。但有一些限制可以保護那些浮躁的人。一個浮躁的人可以從這些條款中獲得一些幫助。

    在本文中,我們將會看一看這些未可知的益處。這是由於它的未可知,我們無法爲其進行完全的分類。不過,我們仍然通過研究爲了一個C程序的運行所需要做的事來做到這些。我們假設讀者對C語言至少有個粗淺的瞭解。

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

1 詞法缺陷

    編譯器的第一個部分常被稱爲詞法分析器(lexical analyzer)。詞法分析器檢查組成程序的字符序列,並將它們劃分爲記號(token)一個記號是一個有一個或多個字符的序列,它在語言被編譯時具有一個(相關地)統一的意義。在C中, 例如,記號->的意義和組成它的每個獨立的字符具有明顯的區別,而且其意義獨立於->出現的上下文環境。

    另外一個例子,考慮下面的語句:

if(x > big) big = x;

該語句中的每一個分離的字符都被劃分爲一個記號,除了關鍵字if和標識符big的兩個實例。

    事實上,C程序被兩次劃分爲記號。首先是預處理器讀取程序。它必須對程序進行記號劃分以發現標識宏的標識符。它必須通過對每個宏進行求值來替換宏調用。最後,經過宏替換的程序又被彙集成字符流送給編譯器。編譯器再第二次將這個流劃分爲記號。

    在這一節中,我們將探索對記號的意義的普遍的誤解以及記號和組成它們的字符之間的關係。稍後我們將談到預處理器。

1.1 = 不是 ==

    從Algol派生出來的語言,如Pascal和Ada,用:=表示賦值而用=表示比較。而C語言則是用=表示賦值而用==表示比較。這是因爲賦值的頻率要高於比較,因此爲其分配更短的符號。

    此外,C還將賦值視爲一個運算符,因此可以很容易地寫出多重賦值(如a = b = c),並且可以將賦值嵌入到一個大的表達式中。

    這種便捷導致了一個潛在的問題:可能將需要比較的地方寫成賦值。因此,下面的語句好像看起來是要檢查x是否等於y:

if(x = y)
    foo();

而實際上是將x設置爲y的值並檢查結果是否非零。在考慮下面的一個希望跳過空格、製表符和換行符的循環:

while(c == ' ' || c = '/t' || c == '/n')
    c = getc(f);

在與'/t'進行比較的地方程序員錯誤地使用=代替了==。這個“比較”實際上是將'/t'賦給c,然後判斷c的(新的)值是否爲零。因爲'/t'不爲零,這個“比較”將一直爲真,因此這個循環會吃盡整個文件。這之後會發生什麼取決於特定的實現是否允許一個程序讀取超過文件尾部的部分。如果允許,這個循環會一直運行。

    一些C編譯器會對形如e1 = e2的條件給出一個警告以提醒用戶。當你確實需要先對一個變量進行賦值之後再檢查變量是否非零時,爲了在這種編譯器中避免警告信息,應考慮顯式給出比較符。換句話說,將:

if(x = y)
    foo();

改寫爲:

if((x = y) != 0)
    foo();

這樣可以清晰地表示你的意圖。

1.2 & 和 | 不是 && 和 ||

    容易將==錯寫爲=是因爲很多其他語言使用=表示比較運算。 其他容易寫錯的運算符還有&和&&,或|和||,這主要是因爲C語言中的&和|運算符於其他語言中具有類似功能的運算符大爲不同。我們將在第4節中貼近地觀察這些運算符。

1.3 多字符記號

    一些C記號,如/、*和=只有一個字符。而其他一些C記號,如/*和==,以及標識符,具有多個字符。當C編譯器遇到緊連在一起的/和*時,它必須能夠決定是將這兩個字符識別爲兩個分離的記號還是一個單獨的記號。C語言參考手冊說明了如何決定:“如果輸入流到一個給定的字符串爲止已經被識別爲記號,則應該包含下一個字符以組成能夠構成記號的最長的字符串”。因此,如果/是一個記號的第一個字符,並且/後面緊隨了一個*,則這兩個字符構成了註釋的開始,不管其他上下文環境。

    下面的語句看起來像是將y的值設置爲x的值除以p所指向的值:

y = x/*p    /* p 指向除數 */;

實際上,/*開始了一個註釋,因此編譯器簡單地吞噬程序文本,直到*/的出現。換句話說,這條語句僅僅把y的值設置爲x的值,而根本沒有看到p。將這條語句重寫爲:

y = x / *p    /* p 指向除數 */;

或者乾脆是

y = x / (*p)    /* p指向除數 */;

它就可以做註釋所暗示的除法了。

    這種模棱兩可的寫法在其他環境中就會引起麻煩。例如,老版本的C使用=+表示現在版本中的+=。這樣的編譯器會將

a=-1;

視爲

a =- 1;



a = a - 1;

這會讓打算寫

a = -1;

的程序員感到吃驚。

    另一方面,這種老版本的C編譯器會將

a=/*b;

斷句爲

a =/ *b;

儘管/*看起來像一個註釋。

1.4 例外

    組合賦值運算符如+=實際上是兩個記號。因此,

a + /* strange */ = 1



a += 1

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

p - > a

是不合法的。它和

p -> a

不是同義詞。

    另一方面,有些老式編譯器還是將=+視爲一個單獨的記號並且和+=是同義詞。

1.5 字符串和字符

    單引號和雙引號在C中的意義完全不同,在一些混亂的上下文中它們會導致奇怪的結果而不是錯誤消息。

    包圍在單引號中的一個字符只是書寫整數的另一種方法。這個整數是給定的字符在實現的對照序列中的一個對應的值。因此,在一個ASCII實現中,'a'和0141或97表示完全相同的東西。而一個包圍在雙引號中的字符串,只是書寫一個有雙引號之間的字符和一個附加的二進制值爲零的字符所初始化的一個無名數組的指針的一種簡短方法。

    下面的兩個程序片斷是等價的:

printf("Hello world/n");

char hello[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '/n', 0 };
printf(hello);

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

printf('/n');

來代替

printf("/n");

通常會在運行時得到奇怪的結果。

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

2 句法缺陷

    要理解C語言程序,僅瞭解構成它的記號是不夠的。還要理解這些記號是如何構成聲明、表達式、語句和程序的。儘管這些構成通常都是定義良好的,但這些定義有時候是有悖於直覺的或混亂的。

    在這一節中,我們將着眼於一些不明顯句法構造。

2.1 理解聲明

    我曾經和一些人聊過天,他們那時在書寫在一個小型的微處理器上單機運行的C程序。當這臺機器的開關打開的時候,硬件會調用地址爲0處的子程序。

    爲了模仿電源打開的情形,我們要設計一條C語句來顯式地調用這個子程序。經過一些思考,我們寫出了下面的語句:

(*(void(*)())0)();

    這樣的表達式會令C程序員心驚膽戰。但是,並不需要這樣,因爲他們可以在一個簡單的規則的幫助下很容易地構造它:以你使用的方式聲明它。

    每個C變量聲明都具有兩個部分:一個類型和一組具有特定格式的期望用來對該類型求值的表達式。最簡單的表達式就是一個變量:

float f, g;

說明表達式f和g——在求值的時候——具有類型float。由於待求值的是表達式,因此可以自由地使用圓括號:

float ((f));

這表示((f))求值爲float並且因此,通過推斷,f也是一個float。

    同樣的邏輯用在函數和指針類型。例如:

float ff();

表示表達式ff()是一個float,因此ff是一個返回一個float的函數。類似地,

float *pf;

表示*pf是一個float並且因此pf是一個指向一個float的指針。

    這些形式的組合聲明對表達式是一樣的。因此,

float *g(), (*h)();

表示*g()和(*h)()都是float表達式。由於()比*綁定得更緊密,*g()和*(g())表示同樣的東西:g是一個返回指float指針的函數,而h是一個指向返回float的函數的指針。

    當我們知道如何聲明一個給定類型的變量以後,就能夠很容易地寫出一個類型的模型(cast):只要刪除變量名和分號並將所有的東西包圍在一對圓括號中即可。因此,由於

float *g();

聲明g是一個返回float指針的函數,所以(float *())就是它的模型。

    有了這些知識的武裝,我們現在可以準備解決(*(void(*)())0)()了。 我們可以將它分爲兩個部分進行分析。首先,假設我們有一個變量fp,它包含了一個函數指針,並且我們希望調用fp所指向的函數。可以這樣寫:

(*fp)();

如果fp是一個指向函數的指針,則*fp就是函數本身,因此(*fp)()是調用它的一種方法。(*fp)中的括號是必須的,否則這個表達式將會被分析爲*(fp())。我們現在要找一個適當的表達式來替換fp。

    這個問題就是我們的第二步分析。如果C可以讀入並理解類型,我們可以寫:

(*0)();

但這樣並不行,因爲*運算符要求必須有一個指針作爲他的操作數。另外,這個操作數必須是一個指向函數的指針,以保證*的結果可以被調用。因此,我們需要將0轉換爲一個可以描述“指向一個返回void的函數的指針”的類型。

    如果fp是一個指向返回void的函數的指針,則(*fp)()是一個void值,並且它的聲明將會是這樣的:

void (*fp)();

因此,我們需要寫:

void (*fp)();
(*fp)();

來聲明一個啞變量。一旦我們知道了如何聲明該變量,我們也就知道了如何將一個常數轉換爲該類型:只要從變量的聲明中去掉名字即可。因此,我們像下面這樣將0轉換爲一個“指向返回void的函數的指針”:

(void(*)())0

接下來,我們用(void(*)())0來替換fp:

(*(void(*)())0)();

結尾處的分號用於將這個表達式轉換爲一個語句。

    在這裏,我們就解決了這個問題時沒有使用typedef聲明。通過使用它,我們可以更清晰地解決這個問題:

typedef void (*funcptr)();
(*(funcptr)0)();

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

    假設有一個聲明瞭的常量FLAG是一個整數,其二進制表示中的某一位被置位(換句話說,它是2的某次冪),並且你希望測試一個整型變量flags該位是否被置位。通常的寫法是:

if(flags & FLAG) ...

其意義對於很多C程序員都是很明確的:if語句測試括號中的表達式求值的結果是否爲0。出於清晰的目的我們可以將它寫得更明確:

if(flags & FLAG != 0) ...

這個語句現在更容易理解了。但它仍然是錯的,因爲!=比&綁定得更緊密,因此它被分析爲:

if(flags & (FLAG != 0)) ...

這(偶爾)是可以的,如FLAG是1或0(!)的時候,但對於其他2的冪是不行的腳註[2]

    假設你有兩個整型變量,h和l,它們的值在0和15(含0和15)之間,並且你希望將r設置爲8位值,其低位爲l,高位爲h。一種自然的寫法是:

r = h << 4 + 1;

不幸的是,這是錯誤的。加法比移位綁定得更緊密,因此這個例子等價於:

r = h << (4 + l);

正確的方法有兩種:

r = (h << 4) + l;

r = h << 4 | l;

    避免這種問題的一個方法是將所有的東西都用括號括起來,但表達式中的括號過度就會難以理解,因此最好還是是記住C中的優先級。

    不幸的是,這有15個,太困難了。然而,通過將它們分組可以變得容易。

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

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

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

所有的邏輯運算符具有比所有關係運算符都低的優先級。
移位運算符比關係運算符綁定得更緊密,但又不如數學運算符。
    在這些運算符類別中,有一些奇怪的地方。乘法、除法和求餘具有相同的優先級,加法和減法具有相同的優先級,以及移位運算符具有相同的優先級。

    還有就是六個關係運算符並不具有相同的優先級:==和!=的優先級比其他關係運算符要低。這就允許我們判斷a和b是否具有與c和d相同的順序,例如:

a < b == c < d

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

    三元運算符的優先級比我們提到過的所有運算符的優先級都低。這可以保證選擇表達式中包含的關係運算符的邏輯組合特性,如:

z = a < b && b < c ? d : e

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

a = b = c



b = c; a = b;

是等價的。

    具有最低優先級的是逗號運算符。這很容易理解,因爲逗號通常在需要表達式而不是語句的時候用來替代分號。

    賦值是另一種運算符,通常具有混合的優先級。例如,考慮下面這個用於複製文件的循環:

while(c = getc(in) != EOF)
    putc(c, out);

這個while循環中的表達式看起來像是c被賦以getc(in)的值,接下來判斷是否等於EOF以結束循環。不幸的是,賦值的優先級比任何比較操作都低,因此c的值將會是getc(in)和EOF比較的結果,並且會被拋棄。因此,“複製”得到的文件將是一個由值爲1的字節流組成的文件。

    上面這個例子正確的寫法並不難:

while((c = getc(in)) != EOF)
    putc(c, out);

然而,這種錯誤在很多複雜的表達式中卻很難被發現。例如,隨UNIX系統一同發佈的lint程序通常帶有下面的錯誤行:

if (((t = BTYPE(pt1->aty) == STRTY) || t == UNIONTY) {

這條語句希望給t賦一個值,然後看t是否與STRTY或UNIONTY相等。而實際的效果卻大不相同腳註[3]

    C中的邏輯運算符的優先級具有歷史原因。B——C的前輩——具有和C中的&和|運算符對應的邏輯運算符。儘管它們的定義是按位的 ,但編譯器在條件判斷上下文中將它們視爲和&&和||一樣。當在C中將它們分開後,優先級的改變是很危險的腳註[4]

2.3 看看這些分號!

    C中的一個多餘的分號通常會帶來一點點不同:或者是一個空語句,無任何效果;或者編譯器可能提出一個診斷消息,可以方便除去掉它。一個重要的區別是在必須跟有一個語句的if和while語句中。考慮下面的例子:

if(x[i] > big);
    big = x[i];

這不會發生編譯錯誤,但這段程序的意義與:

if(x[i] > big)
    big = x[i];

就大不相同了。第一個程序段等價於:

if(x[i] > big) { }
big = x[i];

也就是等價於:

big = x[i];

(除非x、i或big是帶有副作用的宏)。

    另一個因分號引起巨大不同的地方是函數定義前面的結構聲明的末尾[譯註:這句話不太好聽,看例子就明白了]。考慮下面的程序片段:

struct foo {
    int x;
}

f() {
    ...
}

在緊挨着f的第一個}後面丟失了一個分號。它的效果是聲明瞭一個函數f,返回值類型是struct foo,這個結構成了函數聲明的一部分。如果這裏出現了分號,則f將被定義爲具有默認的整型返回值腳註[5]

2.4 switch語句

    通常C中的switch語句中的case段可以進入下一個。例如,考慮下面的C和Pascal程序片斷:

switch(color) {
case 1: printf ("red");
        break;
case 2: printf ("yellow");
        break;
case 3: printf ("blue");
        break;
}

case color of
1: write ('red');
2: write ('yellow');
3: write ('blue');
end

    這兩個程序片斷都作相同的事情:根據變量color的值是1、2還是3打印red、yellow或blue(沒有新行符)。這兩個程序片斷非常相似,只有一點不同:Pascal程序中沒有C中相應的break語句。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的處理可以簡化其他一些特殊的處理。

    例如,設想有一個程序是一臺假想的機器的翻譯器。這樣的一個程序可能包含一個switch語句來處理各種操作碼。在這樣一臺機器上,通常減法在對其第二個運算數進行變號後就變成和加法一樣了。因此,最好可以寫出這樣的語句:

case SUBTRACT:
    opnd2 = -opnd2;
    /* no break; */
case ADD:
    ...

    另外一個例子,考慮編譯器通過跳過空白字符來查找一個記號。這裏,我們將空格、製表符和新行符視爲是相同的,除了新行符還要引起行計數器的增長外:

case '/n':
    linecount++;
    /* no break */
case '/t':
case ' ':
    ...

2.5 函數調用

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

f();

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

f;

什麼也不做。它會作爲函數地址被求值,但不會調用它腳註[6]

2.6 懸掛else問題

    在討論任何語法缺陷時我們都不會忘記提到這個問題。儘管這一問題不是C語言所獨有的,但它仍然傷害着那些有着多年經驗的C程序員。

    考慮下面的程序片斷:

if(x == 0)
    if(y == 0) error();
else {
    z = x + y;
    f(&z);
}

    寫這段程序的程序員的目的明顯是將情況分爲兩種:x = 0和x != 0。在第一種情況中,程序段什麼都不做,除非y = 0時調用error()。第二種情況中,程序設置z = x + y並以z的地址作爲參數調用f()。

    然而, 這段程序的實際效果卻大爲不同。其原因是一個else總是與其最近的if相關聯。如果我們希望這段程序能夠按照實際的情況運行,應該這樣寫:

if(x == 0) {
    if(y == 0)
        error();
    else {
        z = x + y;
        f(&z);
    }
}

換句話說,當x != 0發生時什麼也不做。如果要達到第一個例子的效果,應該寫:

if(x == 0) {
    if(y ==0)
        error();
}
else {
    z = z + y;
    f(&z);
}

3 鏈接

    一個C程序可能有很多部分組成,它們被分別編譯,並由一個通常稱爲鏈接器、鏈接編輯器或加載器的程序綁定到一起。由於編譯器一次通常只能看到一個文件,因此它無法檢測到需要程序的多個源文件的內容才能發現的錯誤。

    在這一節中,我們將看到一些這種類型的錯誤。有一些C實現,但不是所有的,帶有一個稱爲lint的程序來捕獲這些錯誤。如果具有一個這樣的程序,那麼無論怎樣地強調它的重要性都不過分。

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

    假設你有一個C程序,被劃分爲兩個文件。其中一個包含如下聲明:

int n;

而令一個包含如下聲明:

long n;

這不是一個有效的C程序,因爲一些外部名稱在兩個文件中被聲明爲不同的類型。然而,很多實現檢測不到這個錯誤,因爲編譯器在編譯其中一個文件時並不知道另一個文件的內容。因此,檢查類型的工作只能由鏈接器(或一些工具程序如lint)來完成;如果操作系統的鏈接器不能識別數據類型,C編譯器也沒法過多地強制它。

    那麼,這個程序運行時實際會發生什麼?這有很多可能性:

實現足夠聰明,能夠檢測到類型衝突。則我們會得到一個診斷消息,說明n在兩個文件中具有不同的類型。
你所使用的實現將int和long視爲相同的類型。典型的情況是機器可以自然地進行32位運算。在這種情況下你的程序或許能夠工作,好象你兩次都將變量聲明爲long(或int)。但這種程序的工作純屬偶然。
n的兩個實例需要不同的存儲,它們以某種方式共享存儲區,即對其中一個的賦值對另一個也有效。這可能發生,例如,編譯器可以將int安排在long的低位。不論這是基於系統的還是基於機器的,這種程序的運行同樣是偶然。
n的兩個實例以另一種方式共享存儲區,即對其中一個賦值的效果是對另一個賦以不同的值。在這種情況下,程序可能失敗。
    這種情況發生的裏一個例子出奇地頻繁。程序的某一個文件包含下面的聲明:

char filename[] = "etc/passwd";

而另一個文件包含這樣的聲明:

char *filename;

    儘管在某些環境中數組和指針的行爲非常相似,但它們是不同的。在第一個聲明中,filename是一個字符數組的名字。儘管使用數組的名字可以產生數組第一個元素的指針,但這個指針只有在需要的時候才產生並且不會持續。在第二個聲明中,filename是一個指針的名字。這個指針可以指向程序員讓它指向的任何地方。如果程序員沒有給它賦一個值,它將具有一個默認的0值(null)[譯註:實際上,在C中一個爲初始化的指針通常具有一個隨機的值,這是很危險的!]。

    這兩個聲明以不同的方式使用存儲區,他們不可能共存。

    避免這種類型衝突的一個方法是使用像lint這樣的工具(如果可以的話)。爲了在一個程序的不同編譯單元之間檢查類型衝突,一些程序需要一次看到其所有部分。典型的編譯器無法完成,但lint可以。

    避免該問題的另一種方法是將外部聲明放到包含文件中。這時,一個外部對象的類型僅出現一次腳註[7]

4 語義缺陷

    一個句子可以是精確拼寫的並且沒有語法錯誤,但仍然沒有意義。在這一節中,我們將會看到一些程序的寫法會使得它們看起來是一個意思,但實際上是另一種完全不同的意思。

    我們還要討論一些表面上看起來合理但實際上會產生未定義結果的環境。我們這裏討論的東西並不保證能夠在所有的C實現中工作。我們暫且忘記這些能夠在一些實現中工作但可能不能在另一些實現中工作的東西,直到第7節討論可以執行問題爲止。

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

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並不總是轉換實參

    下面的程序段由於兩個原因會失敗:

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'和'/0'。因此,如果我們現在執行:

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語言關於整數操作的上溢或下溢定義得非常明確。

    只要有一次操作數是無符號的,結果就是無符號的,並且以2^n爲模,其中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。]

5 庫函數

    每個有用的C程序都會用到庫函數,因爲沒有辦法把輸入和輸出內建到語言中去。在這一節中,我們將會看到一些廣泛使用的庫函數在某種情況下會出現的一些非預期行爲。

5.1 getc()返回整數

    考慮下面的程序:

#include <stdio.h>

main() {
    char 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()的值!這樣做的編譯器會使這個事例程序看起來能夠“正確地”工作。

5.2 緩衝輸出和內存分配

    當一個程序產生輸出時,能夠立即看到它有多重要?這取決於程序。

    例如,終端上顯示輸出並要求人們坐在終端前面回答一個問題,人們能夠看到輸出以知道該輸入什麼就顯得至關重要了。另一方面,如果輸出到一個文件中,並最終被髮送到一個行式打印機,只有所有的輸出最終能夠到達那裏是重要的。

    立即安排輸出的顯示通常比將其暫時保存在一大塊一起輸出要昂貴得多。因此,C實現通常允許程序員控制產生多少輸出後在實際地寫出它們。

    這個控制通常約定爲一個稱爲setbuf()的庫函數。如果buf是一個具有適當大小的字符數組,則

setbuf(stdout, buf);

將告訴I/O庫寫入到stdout中的輸出要以buf作爲一個輸出緩衝,並且等到buf滿了或程序員直接調用fflush()再實際寫出。緩衝區的合適的大小在<stdio.h>中定義爲BUFSIZ。

    因此,下面的程序解釋了通過使用setbuf()來講標準輸入複製到標準輸出:

#include <stdio.h>

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變成非緩衝的。這會運行得很慢,但它是可以運行的。

6 預處理器

    運行的程序並不是我們所寫的程序:因爲C預處理器首先對其進行了轉換。出於兩個主要原因(和很多次要原因),預處理器爲我們提供了一些簡化的途徑。

    首先,我們希望可以通過改變一個數字並重新編譯程序來改變一個特殊量(如表的大小)的所有實例腳註[9]

    其次,我們可能希望定義一些東西,它們看起來象函數但沒有函數調用所需的運行開銷。例如,putchar()和getchar()通常實現爲宏以避免對每一個字符的輸入輸出都要進行函數調用。

6.1 宏不是函數

    由於宏可以象函數那樣出現,有些程序員有時就會將它們視爲等價的。因此,看下面的定義:

#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第八版的<stdio.h>中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;

比較好一些。

6.2 宏不是類型定義

    宏的一個通常的用途是保證不同地方的多個事物具有相同的類型:

#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的行爲好像真正的類型一樣。

7 可移植性缺陷

    C被很多人實現並運行在很多機器上。這也正是在一個地方寫的C程序應該能夠很容易地轉移到另一個編程環境中去的原因。

    然而,由於有很多的實現者,它們並不和其他人交流。此外,不同的系統有不同的需求,因此一臺機器上的C實現和另一臺上的多少會有些不同。

    由於很多早期的C實現都關係到UNIX操作系統,因此這些函數的性質都是專於該系統的。當一些人開始在其他系統中實現C時,他們嘗試使庫的行爲類似於UNIX系統中的行爲。

    但他們並不總是能夠成功。更有甚者,很多人從UNIX系統的不同版本入手,一些庫函數的本質不可避免地發生分歧。今天,一個C程序員如果想寫出對於不同環境中的用戶都有用的程序就必須知道很多這些細微的差別。

7.1 一個名字中都有什麼?

    一些C編譯器將一個標識符中的所有字符視爲簽名。而另一些在存貯標識符是會忽略一個極限之外的所有字符。C編譯器產生的目標程序同將要被加載器進行處理以訪問庫中的子程序。加載器對於它們能夠處理的名字通常應用自己的約束。

    一個常見的加載器約束是所有的外部名字必須只能是大寫的。面對這樣的加載器約束,C實現者會強制要求所有的外部名字都是大寫的。這種約束在C語言參考手冊中第2.1節由所描述。

一個標識符是一個字符和數字序列,第一個字符必須是一個字母。下劃線_算作字母。大寫字母和小寫字母是不同的。只有前八個字符是簽名,但可以使用更多的字符。可以被多種彙編器和加載器使用的外部標識符,有着更多的限制:

    這裏,參考手冊中繼續給出了一些例子如有些實現要求外部標識符具有單獨的大小寫格式、或者少於八個字符、或者二者都有。

    正因爲所有這些,在一個希望可以移植的程序中小心地選擇標識符是很重要的。爲兩個子程序選擇print_fields和print_float這樣的名字不是個好辦法。

    考慮下面這個顯著的函數:

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()時它調用的是它自己。顯然,其結果就是第一次嘗試分配內存就會陷入一個遞歸循環並隨之發生混亂。但在一些能夠區分大小寫的實現中這個函數還是可以工作的。

7.2 一個整數有多大?

    C爲程序員提供三種整數尺寸:普通、短和長,還有字符,其行爲像一個很小的整數。C語言定義對各種整數的大小不作任何保證:

整數的四種尺寸是非遞減的。
普通整數的大小要足夠存放任意的數組下標。
字符的大小應該體現特定硬件的本質。
    許多現代機器具有8位字符,不過還有一些具有7位或9位字符。因此字符通常是7、8或9位。

    長整數通常至少32位,因此一個長整數可以用於表示文件的大小。

    普通整數通常至少16位,因爲太小的整數會更多地限制一個數組的最大大小。

    短整數總是恰好16位。

    在實踐中這些都意味着什麼?最重要的一點就是別指望能夠使用任何一個特定的精度。非正式情況下你可以假設一個短整數或一個普通整數是16位的,而一個長整數是32位的,但並不保證總是會有這些大小。你當然可以用普通整數來壓縮表大小和下標,但當一個變量必須存放一個一千萬的數字的時候呢?

    一種更可移植的做法是定義一個“新的”類型:

typedef long tenmil;

現在你就可以使用這個類型來聲明一個變量並知道它的寬度了,最壞的情況下,你也只要改變這個單獨的類型定義就可以使所有這些變量具有正確的類型。

7.3 字符是帶符號的還是無符號的?

    很多現代計算機支持8位字符,因此很多現代C編譯器將字符實現爲8位整數。然而,並不是所有的編譯器都按照同將的方式解釋這些8位數。

    這些問題在將一個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。

7.4 右移位是帶符號的還是無符號的?

    這裏再一次重複:一個關心右移操作如何進行的程序最好將所有待移位的量聲明爲無符號的。

7.5 除法如何舍入?

    假設我們用b除a得到商爲q餘數爲r:

q = a / b;
r = a % b;

我們暫時假設b > 0。

    我們期望a、b、q和r之間有什麼關聯?

最重要的,我們期望q * b + r == a,因爲這是對餘數的定義。
如果a的符號發生改變,我們期望q的符號也發生改變,但絕對值不變。
我們希望保證r >= 0且r < b。例如,如果餘數將作爲一個哈希表的索引,它必須要保證總是一個有效的索引。
    這三點清楚地描述了整數除法和求餘操作。不幸的是,它們不能同時爲真。

    考慮3 / 2,商1餘0。這滿足第一點。而-3 / 2的值呢?根據第二點,商應該是-1,但如果是這樣的話,餘數必須也是-1,這違反了第三點。或者,我們可以通過將餘數標記爲1來滿足第三點,但這時根據第一點商應該是-2。這又違反了第二點。

    因此C和其他任何實現了整數除法舍入的語言必須放棄上述三個原則中的至少一個。

    很多程序設計語言放棄了第三點,要求餘數的符號必須和被除數相同。這可以保證第一點和第二點。很多C實現也是這樣做的。

    然而,C語言的定義只保證了第一點和|r| < |b|以及當a >= 0且b > 0時r >= 0。 這比第二點或第三點的限制要小, 實際上有些編譯器滿足第二點或第三點,但不太常見(如一個實現可能總是向着距離0最遠的方向進行舍入)。

    儘管有些時候不需要靈活性,C語言還是足夠可以讓我們令除法完成我們所要做的、提供我們所想知道的。例如,假設我們有一個數n表示一個標識符中的字符的一些函數,並且我們想通過除法得到一個哈希表入口h,其中0 <= h <= HASHSIZE。如果我們知道n是非負的,我們可以簡單地寫:

h = n % HASHSIZE;

然而,如果n有可能是負的,這樣寫就不好了,因爲h可能也是負的。然而,我們知道h > -HASHSIZE,因此我們可以寫:

h = n % HASHSIZE;
if(n < 0)
    h += HASHSIZE;

    同樣,將n聲明爲unsigned也可以。

7.6 一個隨機數有多大?

    這個尺寸是模糊的,還受庫設計的影響。在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()函數的程序。

7.7 大小寫轉換

    toupper()和tolower()函數有着類似的歷史。他們最初都被實現爲宏:

#define toupper(c) ((c) + 'A' - 'a')
#define tolower(c) ((c) + 'a' - 'A')

當給定一個小寫字母作爲輸入時,toupper()將產生相應的大寫字母。tolower()反之。這兩個宏都依賴於實現的字符集,它們需要所有的大寫字母和對應的小寫字母之間的差別都是常數的。這個假設對於ASCII和EBCDIC字符集來說都是有效的,可能不是很危險,因爲這些不可移植的宏定義可以被封裝到一個單獨的文件中幷包含它們。

    這些宏確實有一個缺陷,即:當給定的東西不是一個恰當的字符,它會返回垃圾。因此,下面這個通過使用這些宏來將一個文件轉爲小寫的程序是無法工作的:

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實現中可能不會正常工作。

    如果不知道這些歷史,可能很難對這類錯誤進行跟蹤。

7.8 先釋放,再重新分配

    很多C實現爲用戶提供了三個內存分配函數:malloc()、realloc()和free()。調用malloc(n)返回一個指向有n個字符的新分配的內存的指針,這個指針可以由程序員使用。給free()傳遞一個指向由malloc()分配的內存的指針可以使這塊內存得以重用。通過一個指向已分配區域的指針和一個新的大小調用realloc()可以將這塊內存擴大或縮小到新尺寸,這個過程中可能要複製內存。

    也許有人會想,真相真是有點微妙啊。下面是System V接口定義中出現的對realloc()的描述:

realloc改變一個由ptr指向的size個字節的塊,並返回該塊(可能被移動)的指針。 在新舊尺寸中比較小的一個尺寸之下的內容不會被改變。

而UNIX系統第七版的參考手冊中包含了這一段的副本。此外,還包含了描述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程序都是先釋放內存再重新分配的,而當這些程序移植到其他實現中時就會出現問題。

7.9 可移植性問題的一個實例

    讓我們來看一個已經被很多人在很多時候解決了的問題。下面的程序帶有兩個參數:一個長整數和一個函數(的指針)。它將整數轉換位十進制數,並用代表其中每一個數字的字符來調用給定的函數。

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位和一個附加位表示符號,則-2^k可以表示而2^k卻不能。

    解決這一問題有很多方法。最直觀的一種是將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]);
}

8 這裏是空閒空間

    還有很多可能讓C程序員誤入迷途的地方本文沒有提到。如果你發現了,請聯繫作者。在以後的版本中它會被包含進來,並添加一個表示感謝的腳註。

參考
    《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)的一個擴充,有興趣的讀者可以讀一讀它。
    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)的商標。
發佈了38 篇原創文章 · 獲贊 1 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章