Andrew Koenig:c Traps And Pitfalls@1989

C 語言的陷阱與缺陷

本文深入探討了 C 語言中常見的陷阱與潛在問題,這些問題可能導致程式碼出現意外的行為、難以追蹤的錯誤或降低程式的可移植性。文章將這些陷阱按照程式處理的不同階段進行分類,從詞法分析到運行時行為及跨平台兼容性,提供了詳盡的解釋與避免方法。

1. 詞法分析陷阱 (Lexical Pitfalls)

詞法分析器負責將程式碼的字元序列分解成有意義的最小單元,稱為標記 (token)。C 語言在標記層面就存在一些容易混淆的點。

1.1 === 的混淆

許多源自 Algol 的程式語言使用 := 進行賦值,而 = 用於比較。C 語言則使用 = 進行賦值,== 進行比較。由於賦值比比較更常見,因此將較短的符號賦予更常用的含義。此外,C 將賦值視為一個運算子,這使得連續賦值(如 a=b=c)或將賦值嵌入更複雜的表達式變得容易。然而,這也帶來了一個潛在問題:可能無意中在應該進行比較的地方寫成了賦值。

例如,以下程式碼看似檢查 x 是否等於 y
c
if (x = y)
foo();

實際上,這行程式碼會將 y 的值賦給 x,然後檢查 x 的新值是否非零。如果 y 的值為零,條件為假,否則條件為真。這與原意完全不同。

另一個例子是 intended 遍歷檔案跳過空白字元、tab 和換行符的迴圈:
c
while (c == ' ' || c = '\t' || c == '\n')
c = getc(f);

程式設計師在與 '\t' 比較時錯誤地使用了 = 而非 ==。這個「比較」實際上將 '\t' 賦給了 c,然後將 c 的新值與零進行比較。由於 '\t' 的 ASCII 值(通常非零)會被當作真值,這個條件將永遠為真,導致迴圈讀取整個檔案。在讀到檔案結束後,根據不同的實作,程式可能會陷入無限迴圈。

一些 C 編譯器會對 e1 = e2 形式的條件給出警告。為避免這種警告並更清晰地表達意圖,當你確實想賦值後檢查變數是否為零時,應明確寫出比較:
c
if ((x = y) != 0)
foo();

1.2 &&&||| 的混淆

=== 類似,&&&,以及 ||| 也容易混淆。這部分問題與運算子的語義有關,將在後續章節討論。這裡主要指出它們在詞法上僅差一個字元,極易打錯。

1.3 多字元標記 (Multi-character Tokens)

C 語言中有些標記由多個字元組成,如 /* (註解開始)、== (等於)、-> (指向成員)。編譯器在遇到 / 後緊跟著 * 時,需要決定是將它們視為兩個獨立標記 (/*) 還是單一標記 (/*)。C 語言規範規定,編譯器應盡可能組合最長的合法標記。

例如,以下程式碼看似將 y 設為 x 除以 p 指向的值:
c
y = x/*p /* p points at the divisor */;

實際上,/* 被視為註解的開始,編譯器會忽略其後的內容直到 */。這行程式碼等同於 y = x;,完全忽略了 p 和除法運算。正確的寫法應該加入空格以區分標記:
c
y = x / *p /* p points at the divisor */;

或使用括號:
c
y = x/(*p) /* p points at the divisor */;

這種最長匹配原則也適用於其他運算子。例如,舊版 C 使用 =+ 表示 = 後跟 +,但現版 C 的 += 是單一標記。舊版編譯器會將 a=-1; 解析為 a = -1; (單元負號),而如果你想表達 a = a + (-1) 則需寫 a += -1;。這個例子強調了不同版本 C 語言規範之間的微妙差異。

1.5 字串與字元 (Strings and Characters)

單引號和雙引號在 C 語言中含義截然不同,混用可能導致意外行為而非編譯錯誤。

單引號括起來的是字元常數 (character constant),其值是該字元在編譯器字元集中的整數值。例如,在 ASCII 編碼中,'a' 的值就是 97。它是一個整數。
雙引號括起來的是字串字面值 (string literal),它代表一個匿名字元陣列,陣列內容是雙引號內的字元,末尾自動加上一個空字元 (null character, \0)。字串字面值在表達式中通常被視為指向該陣列第一個元素的指標。

以下兩段程式碼是等價的:
c
printf("Hello world\n");

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

由於字串字面值是一個指標,而字元常數是一個整數,在期望指標的地方傳遞整數(反之亦然)通常會引起編譯器警告。但有一個主要例外:函數呼叫。大多數 C 編譯器在函數呼叫時不檢查參數類型(尤其是在沒有函數原型的情況下)。因此,寫成 printf('\n'); 而非 printf("\n"); 通常不會在編譯時報錯,但在運行時會導致異常,因為 printf%s 格式符期望一個指標,而它得到了一個整數。

此外,一些 C 編譯器允許字元常數包含多個字元(例如 'yes')。這使得將 'yes' 誤寫為 "yes" 可能不會被偵測到。後者是「指向包含 ‘y’, ‘e’, ‘s’, ‘\0’ 這四個字元的記憶體區域的首位址的指標」,而前者是「一個整數,其值以某種實作定義的方式由 ‘y’, ‘e’, ‘s’ 的值組成」。這兩者之間可能沒有任何關係。

2. 語法分析陷阱 (Syntactic Pitfalls)

理解 C 程式碼不僅要理解標記,還要理解標記如何組合成聲明、表達式和語句。這些組合規則有時可能違反直覺。

2.1 理解聲明 (Understanding Declarations)

C 語言的聲明語法常令人困惑,尤其是涉及指標和函數時。一個簡單的規則可以幫助理解和構造複雜聲明:「聲明即用法」。

每個 C 變數聲明都包含一個類型和一個或多個「風格化」的表達式列表,這些表達式求值後應得到該類型的值。例如:
float f;: 表示表達式 f 求值後得到一個 float
float ff();: 表示表達式 ff() 求值後得到一個 float,因此 ff 是一個返回 float 的函數。
float *pf;: 表示表達式 *pf 求值後得到一個 float,因此 pf 是一個指向 float 的指標。

這些形式可以組合:
float *g();: *g() 求值得到 float。由於 ()* 優先級高,這意味著 g 是一個函數,其返回值是 float 指標 (*(g()))。
float (*h)();: (*h)() 求值得到 float。由於 () 作用於 (*h),這意味著 h 是一個指標,指向一個返回 float 的函數。

理解這個規則後,構造複雜的聲明(如指向函數的指標陣列)或類型轉換 (cast) 就變得容易。例如,要將 0 轉換為「指向返回 void 的函數的指標」,可以先想像如何聲明一個這樣的指標:void (*fp)();。然後去掉變數名 fp 和分號,用括號括起來,就得到類型轉換:(void(*)())。這就是程式碼 (*(void(*)())0)(); 中將地址 0 轉換為函數指標並呼叫的方法。

2.2 運算子優先級 (Operator Precedence)

C 語言有十五級運算子優先級,容易導致錯誤。常見的混淆包括:
= 的優先級很低,比大多數運算子(包括比較運算子 !=)都低。這導致 while (c=getc(in) != EOF) 被解析為 while (c = (getc(in) != EOF)),將比較結果賦給 c,而非將字元賦給 c 再進行比較。正確應為 while ((c=getc(in)) != EOF)
– 位元運算子 (&, |) 的優先級高於邏輯運算子 (&&, ||),但低於算術運算子 (+, -)。這導致 h<<4 + l 被解析為 h << (4 + l),而非期望的 (h << 4) + l。同樣,flags & FLAG != 0 被解析為 flags & (FLAG != 0)

記住所有優先級很難,但可以記住一些關鍵點:
1. 所有邏輯運算子 (&&, ||) 的優先級都低於所有關係運算子 (<, >, ==, != 等)。
2. 位移運算子 (<<, >>) 的優先級高於關係運算子,但低於算術運算子。
3. 賦值運算子 (=, += 等) 的優先級非常低,僅高於逗號運算子。
4. 單元運算子(包括類型轉換)優先級很高,僅次於結構體成員訪問、陣列下標和函數呼叫。
5. 函數呼叫 ()、陣列下標 []、結構體成員訪問 .-> 具有最高的優先級並從左到右結合。
6. 單元運算子和賦值運算子從右到左結合。

遇到不確定的情況,使用括號是最安全的做法。

2.3 分號的影響 (Watch Those Semicolons!)

多餘的分號通常是無害的空語句或導致編譯錯誤,但有時影響很大。
ifwhile 後加上多餘的分號,例如 if (x[i] > big); big = x[i];,這將使得 if 條件後的語句變為空語句,導致 big = x[i]; 無論條件是否為真都會執行。
在結構體或聯合體定義後緊跟函數定義時,若忘記分號,編譯器可能將函數聲明為返回該結構體/聯合體類型,而非默認的 int

2.4 switch 語句的貫穿 (The Switch Statement)

C 語言 switch 語句的一個獨特之處在於其 case 標籤可以「貫穿」(fall through)。如果一個 case 的最後沒有 break 語句,執行將繼續到下一個 case 的語句。這既是優點(實現需要共享代碼塊的邏輯,如處理空白字元時,換行符需要特殊處理後再與其他空白字元一起處理),也是缺點(忘記寫 break 是常見錯誤,導致執行了不應執行的代碼)。

2.5 函數呼叫 (Calling Functions)

與某些語言不同,C 語言要求函數呼叫必須帶有參數列表,即使沒有參數也需要寫空括號 ()。僅僅寫函數名 f; 是計算函數的地址,但不會執行函數。

2.6 懸空 else 問題 (The Dangling else Problem)

這是一個常見的語法陷阱,在 C 中同樣存在:else 總是與最近的、未匹配的 if 結合。
例如,程式碼 if (x == 0) if (y == 0) error(); else { ... } 按縮排看似乎 else 與外層 if 匹配,但在語法上它與內層 if 匹配。當 x 不等於 0 時,什麼都不會發生。若想讓 else 與外層 if 匹配,必須使用大括號明確分組:
c
if (x == 0) {
if (y == 0)
error();
} else {
z = x + y;
f(&z);
}

3. 連結陷阱 (Linkage)

C 程式通常由獨立編譯的單元組成,再由連結器 (linker) 組合。編譯器通常只看一個檔案,無法偵測跨檔案的錯誤。lint 等工具可以幫助檢查這些錯誤。

3.1 外部變數類型檢查 (You Must Check External Types Yourself)

如果在兩個不同的檔案中以不同的類型聲明了同一個外部變數名(如一個檔案 int n;,另一個檔案 long n;),這在 C 中是無效的。但許多連結器不進行類型檢查,程式可能在某些系統上因 intlong 大小相同而「偶然」工作,但在其他系統上則因存儲方式不同而失敗。
另一個例子是 char filename[] = "...";char *filename;。前者是字元陣列名(在某些上下文中衰退為指標),後者是指標變數。它們的存儲和行為方式不同,不能同時存在於不同檔案中。
為避免此類問題,應將外部聲明放在頭檔中,確保同一外部對象的聲明只出現一次。

4. 語義分析陷阱 (Semantic Pitfalls)

即使程式碼符合語法,其含義也可能與預期不同,或導致未定義的行為。

4.1 表達式求值順序 (Expression Evaluation Sequence)

C 語言只對少數運算子(&&, ||, ?:, ,)規定了操作數的求值順序。對於大多數運算子(包括賦值運算子),操作數的求值順序是未定義的。

例如,以下程式碼 intended 將陣列 x 的前 n 個元素複製到陣列 y
c
i = 0;
while (i < n)
y[i] = x[i++];

問題在於,無法保證 y[i] 的地址在 i 遞增之前被計算。在某些實作上可能工作,但在其他實作上則可能失敗,因為 i++ 的副作用可能在計算 y[i] 的地址之後發生。
同樣,y[i++] = x[i]; 也失敗,因為 x[i] 的索引 i 可能在 y[i++] 計算完成前就遞增了。

安全的做法是將賦值和遞增分開:
c
i = 0;
while (i < n) {
y[i] = x[i];
i++;
}

或使用 for 迴圈:
c
for (i = 0; i < n; i++)
y[i] = x[i];

涉及帶有副作用的表達式時,必須注意運算子的求值順序是否被明確定義。

4.2 &&||&| 的區別

這再次強調了詞法陷阱中的一個語義問題。
– 位元運算子 (&, |) 將操作數視為位元序列,按位元進行運算。
– 邏輯運算子 (&&, ||) 將操作數視為「真」(非零)或「假」(零),結果為 1(真)或 0(假)。更重要的是,&&|| 具有短路求值的特性:如果左操作數已經能確定結果,右操作數將不被求值。

例如,while (i < tabsize && tab[i] != x) 中,如果 i 達到 tabsize,則 i < tabsize 為假,tab[i] != x 將不被求值,避免了訪問越界的陣列元素 tab[tabsize]。如果將 && 錯誤地替換為 &,由於 & 需要對兩個操作數都求值,即使 i 等於 tabsizetab[i] 也會被訪問,導致潛在的記憶體錯誤。此外,&&& 在操作數非 0 或 1 時的行為也可能不同(如 10 && 12 結果是 1,而 10 & 12(二進制 1010 & 1100)結果是 8)。

4.3 陣列下標從零開始 (Subscripts Start from Zero)

C 語言的陣列下標從 0 開始。一個聲明為 a[10] 的陣列包含 10 個元素,其有效下標是 0 到 9。試圖訪問 a[10] 是越界行為,會導致未定義的行為,可能破壞記憶體中跟隨陣列的其他資料。將迴圈條件寫成 i <= 10 而非 i < 10 來遍歷 10 個元素的陣列是一個常見錯誤。

4.4 函數實參不總是自動轉換 (C Doesn’t Always Cast Actual Parameters)

C 語言對函數參數的類型轉換有簡單的規則:
1. 小於 int 的整數值轉換為 int
2. 小於 double 的浮點值轉換為 double
其他值不自動轉換。確保函數參數類型正確是程式設計師的責任。

如果呼叫函數 sqrt(期望 double 參數),而傳遞整數常量 2,這是不正確的,應傳遞 2.0 (double)。此外,如果函數返回類型不是 int,則在使用其返回值前必須進行聲明。未聲明的函數默認返回 intsqrt 返回 double,所以需要聲明如 double sqrt();

更嚴重的例子是使用 scanfscanf("%d", &c); 期望一個指向 int 的指標,但如果 c 被聲明為 char,實際傳遞的是指向 char 的指標。scanf 無法偵測類型不匹配,會將一個 int 值寫入 char 的存儲空間,由於 int 通常大於 char,這會覆寫 c 周圍的記憶體,可能破壞其他變數(如迴圈計數器 i),導致意外行為(如無限迴圈)。

4.5 指標不是陣列 (Pointers are not Arrays)

雖然在許多情況下,陣列名可以「衰退」為指向其第一個元素的指標,但指標變數和陣列變數本質上不同。聲明 char *r; 創建了一個指標變數 r,但它並未指向任何有效的記憶體區域。你需要為它分配記憶體,例如使用 malloc,才能安全地將資料(如字串)複製到 r 指向的位置。

錯誤範例:
c
char *r;
strcpy(r, s); // 錯誤:r 未初始化,未指向有效記憶體
strcat(r, t);

使用 malloc 時,需要計算所需的總大小,並記住字串結尾的空字元也需要空間,因此需要 strlen(s) + strlen(t) + 1 大小。同時應檢查 malloc 是否成功(返回非 NULL 指標)。

4.6 提喻法謬誤 (Eschew Synecdoche)

混淆指標本身和它指向的數據是 C 中常見的錯誤,尤其是在處理字串時。
c
char *p, *q;
p = "xyz";

此處,p 的值並非字串 “xyz” 本身,而是指向一個包含 ‘x’, ‘y’, ‘z’, ‘\0’ 的匿名陣列的首元素地址。當執行 q = p; 時,只是將 p 的地址賦給 qpq 都指向同一塊記憶體。字串內容本身並未被複製。因此,如果接著執行 q[1] = 'Y';,那麼 pq 都會指向記憶體內容為 “xYz” 的區域。記住:複製指標不複製其指向的內容

4.7 空指標不是空字串 (The Null Pointer is Not the Null String)

整數常量 0 在轉換為指標類型時,保證產生一個空指標 (null pointer),它不等於任何有效的指標。通常使用宏 NULL 表示這個值。空指標絕不能被解引用(即不能訪問它指向的記憶體)。
if (p == NULL) 是合法的,但 if (strcmp(p, NULL) == 0) 是不合法的,因為 strcmp 會嘗試讀取其參數指向的記憶體。類似地,printf(p);printf("%s", p);p 是空指標時也是未定義行為。
空字串 "" 是一個指向包含單一空字元 \0 的匿名陣列的有效指標,可以安全地用於字串函數。空指標 NULL 則不然。

4.8 整數溢位 (Integer Overflow)

C 語言規範對整數溢位/下溢的處理方式:
– 如果任一操作數是 unsigned,結果是無符號的,並定義為模 2^n(n 為字長)。
– 如果兩個操作數都是 signed,結果是未定義的。

這意味著 if (a + b < 0) 來檢查兩個非負的有符號整數 ab 相加是否溢位是不可靠的。一旦 a + b 溢位,結果是未定義的,編譯器可以產生任何程式碼,結果值可能不小於零。
檢查有符號整數加法是否溢位的可移植方法之一是利用無符號算術的良好定義性:
c
if ((int) ((unsigned) a + (unsigned) b) < 0)
complain(); // 發生溢位

或者檢查 a > INT_MAX - b (如果 b > 0)。

4.9 位移運算子 (Shift Operators)

位移運算子有兩個常見問題:
1. 右移有符號整數時,被移走的位元如何填充?是零還是符號位?
2. 位移計數的允許範圍?
答案:
1. 如果被位移的是 unsigned 類型,右移時左邊用零填充。如果是有符號類型,填充方式是實作定義的(零填充或符號位擴展)。如果關注填充位,應使用 unsigned 類型。
2. 如果被位移項的位元長度是 n,位移計數必須 >= 0< n。試圖位移 n 或更多位元是未定義行為。

右移有符號整數通常不等同於除以 2 的冪,尤其對於負數。

5. 函式庫函數 (Library Functions)

標準 C 函式庫是 C 程式的重要組成部分,但也存在一些易錯的行為。

5.1 getc 返回整數 (Getc Returns an Integer)

getchar()getc() 函數返回一個 int,而不是 char。這是因為它們需要能夠表示所有可能的字元值, 以及 一個額外的特殊值 EOF(End Of File)。EOF 的值通常是一個負數,以區別於字元值。如果將返回值賦給一個 char 變數,則 EOF 的值可能會被截斷或轉換,導致無法正確判斷檔案結束,或者將某個合法字元誤判為 EOF
正確的讀取字元迴圈應該將返回值賦給 int 變數:
“`c

include

int c; // 必須是 int
while ((c = getchar()) != EOF)
putchar(c);
“`

5.2 緩衝輸出與記憶體分配 (Buffered Output and Memory Allocation)

標準函式庫通常會緩衝輸出,以提高效率。setbuf 函數允許程式設計師指定緩衝區。如果使用局部變數(堆疊分配)作為緩衝區傳遞給 setbuf,當函數退出時,局部變數會被釋放,而函式庫可能在稍後(例如程式結束時)才嘗試刷新緩衝區,這將導致使用已被釋放的記憶體,造成錯誤。
傳遞給 setbuf 的緩衝區必須在整個使用期間都有效,通常是聲明為 static 或動態分配(並且在使用完畢前不釋放)。

6. 前置處理器 (The Preprocessor)

C 前置處理器在編譯前對程式碼進行文本替換和條件編譯。宏是前置處理器的主要功能,但也引入了陷阱。

6.1 巨集不是函數 (Macros are not Functions)

宏只是文本替換,不具備函數的屬性(如類型檢查、參數求值一次)。定義類似函數的宏時,如果不小心,可能會導致參數帶有副作用時被多次求值,產生非預期的結果。
經典例子是 max 宏:
“`c

define max(a,b) ((a)>(b)?(a):(b))

``
如果呼叫
max(x[i++], y)x[i++]可能會被求值兩次(一次比較,一次賦值),導致i` 被錯誤地遞增兩次。正確的做法是避免在宏參數中使用帶副作用的表達式,或者將宏替換為真正的函數。

為了防範優先級問題,宏參數必須使用足夠的括號括起來,如上述 max 宏定義所示。宏展開後可能生成非常長的表達式。

6.2 巨集不是類型定義 (Macros are not Type Definitions)

使用 #define 為類型命名不如使用 typedef 安全,尤其是在涉及指標時。
“`c

define T1 struct foo *

typedef struct foo *T2;
然後聲明:c
T1 a, b; // 展開為 struct foo * a, b; 聲明 a 是指標,b 是結構體
T2 c, d; // 聲明 c 和 d 都是指標
``typedef` 創建了一個真正的類型別名,行為更符合直覺。

7. 可移植性陷阱 (Portability Pitfalls)

C 程式廣泛應用於不同平台,但 C 語言規範的一些方面留給實作定義 (implementation-defined) 或未指定行為 (unspecified behavior),導致程式在不同編譯器或系統上表現不同。

7.1 識別字命名限制 (What’s in a Name?)

C 語言規範允許編譯器只考慮識別字的前幾個字元(例如前 8 個字元)是唯一的。外部識別字(函數名、外部變數名)通常還受連結器限制,可能不區分大小寫或長度更短。這意味著 print_fieldsprint_float 在某些系統上可能被視為同一個名稱,導致衝突。將庫函數名(如 malloc)重新定義為自己的函數(如 Malloc)在忽略大小寫的系統上會導致無限遞歸。

7.2 整數的大小 (How Big is an Integer?)

C 標準只規定了整數類型 (char, short, int, long) 的最小範圍和相對大小(sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long)),具體位元長度是實作定義的。一個 int 至少能存儲陣列下標的最大值。這意味著不能依賴 intlong 有特定的位元長度(如 16 位或 32 位)。如果需要特定範圍的整數,使用 typedef 來定義一個依賴於實作的類型,或使用 C99 引入的定寬整數類型(如 <stdint.h> 中的 int32_t)可以提高可移植性。

7.3 字元是否有符號 (Are Characters Signed or Unsigned?)

char 轉換為更大的整數類型時,如果 char 的最高位是 1,結果取決於實作將 char 視為有符號還是無符號。有些實作將 char 默認為有符號,進行符號擴展;有些則默認為無符號,用零填充。這會影響處理位元值高於 127 的字元時的行為。如果關心此行為,應明確使用 signed charunsigned char。將 char c 轉換為無符號整數的安全方法是 (unsigned char)c,而非 (unsigned)c

7.5 除法如何截斷 (How Does Division Truncate?)

當對負數執行整數除法和取餘數運算時,結果的符號和截斷行為在 C 標準中有一部分是實作定義的。雖然 q*b + r == a 這個關係總成立,但 qr 的符號取決於實作。例如,-3 / 2 的結果可以是 -1(餘數 -1)或 -2(餘數 1)。如果餘數用於陣列索引且需要非負,必須在計算後進行調整。

7.6 隨機數的範圍 (How Big is a Random Number?)

rand() 函數返回一個偽隨機的非負整數,其最大值由 RAND_MAX 宏定義。C 標準只要求 RAND_MAX 至少為 32767。不同系統上的 rand() 實作可能返回不同範圍的值,導致依賴特定範圍的程式碼不可移植。

7.7 大小寫轉換 (Case Conversion)

touppertolower 函數(或宏)的行為在不同系統上可能有所不同,特別是對於非字母輸入的處理。有些實作(如早期基於宏的版本)可能對非字母輸入返回「垃圾」值,而另一些(如基於函數的更健壯版本)則返回原輸入。標準 C 函式庫提供了 isupperislower 函數來檢查字元是否為字母,安全的使用方式應先檢查再轉換:isupper(c) ? tolower(c) : c

7.8 free 後再 realloc (Free First, then Reallocate)

某些舊的 C 實作允許在 free 一塊記憶體後立即對同一個指標呼叫 realloc,甚至允許在 free 後訪問(但不修改)被釋放區域的內容。這允許了某些特定的程式設計技巧(如在釋放鏈結串列元素時訪問 p->next)。然而,標準 C 並不保證這種行為,現代實作通常會立即將被釋放的記憶體標記為可用或清零,導致這種技巧失敗。可移植的程式碼不應依賴 free 後還能訪問或重新分配同一個指標指向的區域。

7.9 可移植性問題範例 (An Example of Portability Problems)

文章以一個將 long 轉換為十進制並輸出每個字元的函數 printnum 為例,演示了多種可移植性陷阱:
1. 字元到數字的轉換: n % 10 + '0' 依賴於字元集編碼(數字 ‘0’ 到 ‘9’ 必須連續)。可移植的寫法是使用查找表:"0123456789"[n % 10]
2. 處理負數的溢位:long n 設為 -n 可能在 2 的補碼系統中導致溢位,因為最小的負數通常沒有對應的絕對值相等的正數。更安全的做法是使用無符號類型,或者始終使用負數進行計算,並調整除法和取餘的結果以確保正確。
3. 負數的整數除法和取餘: n/10n%10 對於負數的行為是實作定義的。為確保餘數非負以便用於查找表,需要額外的檢查和調整。

這個例子總結了字元集依賴、整數表示範圍和算術運算行為在可移植性方面的挑戰。

總結來說,C 語言的陷阱與缺陷源於其低層次的特性、靈活的語法、有限的內建檢查以及標準對某些行為的寬鬆定義。認識到這些潛在問題並遵循安全的程式設計實踐(如使用括號明確優先級、檢查邊界條件、使用 typedef 管理類型大小、小心處理指標和記憶體、避免宏的副作用、注意函式庫函數的規格、使用 lint 等工具)對於編寫健壯、可移植的 C 程式至關重要。