學習目標

  • 理解 char 與字元編碼(ASCII)的關係:字元就是小整數。
  • 掌握 getchar / putchar 逐字元 I/O,會寫「讀到 EOF 為止」的標準迴圈。
  • 理解 C 字串的本質:以 '\0' 結尾的 char 陣列,沒有內建的字串型別。
  • 重現 K&R 的四支經典程式:字元計數、行計數、單字計數(wc)、找最長行。
  • 能自己實作 strlen、字串複製等基本操作,理解標準函式庫背後的原理。

對照 K&R 章節

  • K&R §1.5 Character Input and Output(§1.5.1 檔案複製、§1.5.2 字元計數、§1.5.3 行計數、§1.5.4 單字計數)
  • K&R §1.9 Character Arrays(最長行程式、getline/copy)

觀念講解

1. 字元就是小整數(ASCII)

char 存的是字元的編碼值。ASCII 是最通行的編碼:

字元 編碼 字元 編碼
'0'~'9' 48~57 空格 ' ' 32
'A'~'Z' 65~90 換行 '\n' 10
'a'~'z' 97~122 tab '\t' 9
空字元 '\0' 0

三個保證與慣用技巧:

  • '0'~'9' 連續 → c - '0' 得到數字值。
  • ASCII 中大小寫各自連續、相差 32 → c - 'a' + 'A' 轉大寫。
  • '\0' 的值就是 0 → 判斷字串結尾可寫 s[i] != '\0' 或簡寫 s[i]。

2. getchar / putchar 與 EOF

int c;                              /* 必須是 int,不是 char! */

while ((c = getchar()) != EOF) {    /* K&R 最著名的一行 */
    putchar(c);
}
  • getchar() 每次讀入一個字元;輸入耗盡時回傳特殊值 EOF(定義在 <stdio.h>,通常是 -1)。
  • c 必須宣告為 int:EOF 在所有合法字元值之外,若用 char 接,EOF 可能與某個字元混淆,或者(unsigned char 平台)永遠不等於 EOF 造成無窮迴圈。
  • (c = getchar()) != EOF:先指定、再比較。括號必要,因為 != 優先於 =。
  • 終端機下手動送 EOF:macOS/Linux 按 Ctrl-D,Windows 按 Ctrl-Z 加 Enter。
  • 用管線或重導測試最方便:echo "hello" | ./prog、./prog < file.txt。

3. C 字串 = '\0' 結尾的 char 陣列

char s[] = "hi";        /* 編譯器自動加 '\0':{ 'h', 'i', '\0' },長度 3! */
索引 0 1 2
內容 'h' 'i' '\0'
  • 字串常值 "hi" 佔 3 個 byte:兩個字元加一個終止符。
  • '\0' 是「哨兵」:所有字串函式靠它知道字串在哪裡結束。
  • 忘了 '\0',printf("%s")、strlen 等會一路讀到記憶體深處——經典災難。
  • 區分:'a' 是字元常數(int 值 97);"a" 是字串(兩個 byte:'a'、'\0')。

4. K&R 四大經典程式的核心

字元計數(§1.5.2):

long nc = 0;
while (getchar() != EOF)
    ++nc;

行計數(§1.5.3):數換行字元就是數行數:

if (c == '\n')
    ++nl;

單字計數 wc(§1.5.4):狀態機的初體驗——用 IN/OUT 狀態記住「目前在單字內還是單字外」, 從外進內的瞬間就是「新單字開始」:

if (c == ' ' || c == '\n' || c == '\t')
    state = OUT;
else if (state == OUT) {
    state = IN;
    ++nw;                       /* 只在「進入單字」時 +1 */
}

最長行(§1.9):組合 getline(讀一行進陣列,回傳長度)與 copy(複製字串), 示範「函式 + 字元陣列」的協作。

5. 自己寫 strlen 與 copy

理解字串的最好方法是自己實作標準函式:

int my_strlen(const char s[])
{
    int i = 0;
    while (s[i] != '\0')        /* 一路數到哨兵 */
        ++i;
    return i;                   /* '\0' 不算在長度內 */
}

void my_strcpy(char to[], const char from[])
{
    int i = 0;
    while ((to[i] = from[i]) != '\0')   /* 指定的值順便當條件:連 '\0' 一起搬 */
        ++i;
}

注意 my_strcpy 的巧妙:to[i] = from[i] 的值恰好是剛搬的字元, 搬到 '\0'(值為 0,即「假」)時迴圈自然結束——'\0' 已經複製完才停。 另外:目的陣列必須夠大,C 不會幫你檢查——這是緩衝區溢位的根源,永遠要心中有數。

範例程式

檔案 內容 編譯與執行
01_char_ascii.c 字元與 ASCII:編碼表、大小寫轉換、數字字元轉數值 cc -std=c11 -Wall -Wextra -o 01_char_ascii 01_char_ascii.c && ./01_char_ascii
02_getchar_count.c getchar 讀 stdin 到 EOF:字元/行/單字計數(K&R wc),無輸入也能正常結束 cc -std=c11 -Wall -Wextra -o 02_getchar_count 02_getchar_count.c && echo "hello world" \| ./02_getchar_count
03_longest_line.c K&R §1.9 最長行程式:getline + copy,用寫死的多行文字示範 cc -std=c11 -Wall -Wextra -o 03_longest_line 03_longest_line.c && ./03_longest_line
04_my_string_funcs.c 自製 strlen / strcpy / strcat / 比較 / 反轉,並與 <string.h> 對照驗證 cc -std=c11 -Wall -Wextra -o 04_my_string_funcs 04_my_string_funcs.c && ./04_my_string_funcs

常見錯誤與陷阱

陷阱 1:用 char 接 getchar 的回傳值

char c;                             /* 錯:裝不下 EOF */
while ((c = getchar()) != EOF) ...  /* 可能無窮迴圈或提早結束 */

修正:int c;。getchar 的回傳型別本來就是 int,正是為了容納 EOF。

陷阱 2:字元陣列忘了留 '\0' 的位置

char name[5] = "hello";     /* 錯!"hello" 需要 6 bytes(5 字元 + '\0') */

clang 會警告初始化字串放不下終止符。修正:char name[6] 或直接 char name[] = "hello";。

陷阱 3:用 == 比較字串

char a[] = "cat", b[] = "cat";
if (a == b) { ... }         /* 恆假:比較的是兩個陣列的「位址」 */

修正:逐字元比較,或用 <string.h> 的 strcmp(相等時回傳 0:if (strcmp(a, b) == 0))。

陷阱 4:對字串常值動手腳

char *s = "hello";
s[0] = 'H';                 /* 未定義行為:字串常值可能放在唯讀記憶體 */

修正:需要修改就用陣列 char s[] = "hello";(複製一份到可寫的陣列); 指向字串常值的指標一律宣告成 const char *s。

陷阱 5:目的緩衝區不夠大

char small[4];
my_strcpy(small, "a very long string");   /* 溢位:寫爆別人的記憶體 */

C 不做邊界檢查。修正:確保目的地夠大(來源長度 + 1),實務上使用有長度上限的版本 (strncpy、snprintf——中階單元詳談)。

練習題

  1. 基本題:印出 'A'~'Z' 的 ASCII 對照表(字元與數值),每列 6 組。
  2. 大小寫轉換:不用 <ctype.h>,自己寫 to_upper(char c) 與 to_lower(char c)(非字母的字元原樣回傳),對字串 "Hello, World 123!" 逐字元轉換印出。
  3. 回文判斷:寫 int is_palindrome(const char s[]),判斷 "level"、"radar"、"hello"(提示:雙索引從頭尾夾擠,先用自製 strlen 求長度)。
  4. 移除空白(K&R 習題 1-18 精神):把字串中的連續空白壓縮成單一空白,如 "a b c" → "a b c"。
  5. 自製 atoi:寫 int my_atoi(const char s[]),把 "1234" 轉成整數 1234(提示:result = result * 10 + (s[i] - '0'));行有餘力處理前導空白與正負號。
  6. 挑戰題(單字直方圖,K&R 習題 1-13):統計一段寫死文字中每個「單字長度」的出現次數,畫出直方圖(需要 wc 的狀態機 + 陣列計數的組合)。

延伸閱讀

  • K&R 2/e §1.5、§1.9;習題 1-8 ~ 1-13、1-16 ~ 1-19
  • man ascii:終端機裡直接查 ASCII 表
  • <ctype.h>:isdigit、isalpha、toupper 等標準分類函式(下個階段的工具)
  • 《The Practice of Programming》§3:狀態機與文字處理
  • 下一單元預告:printf/scanf 完整格式化——把輸出入練到精通