本檔收錄本單元的練習題、完整解題思路與參考解答。 強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」; 真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 exercises/ 資料夾,可直接編譯:

cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_wc.c && /tmp/out

所有解答都自己先建立測試檔(寫到 /tmp/)再讀取,結束時清理,不依賴任何外部檔案。


練習 1:wc 雛形 —— 統計字元數、單字數、行數(難度:基礎)

題目

寫程式統計一個文字檔的字元數、單字數、行數(自建測試檔), 輸出可對照系統指令 wc 的結果。

解題思路

  1. 三個計數,一趟掃描:逐字元讀(fgetc),同時維護三個計數器: - 字元數:每讀到一個位元組就 +1(含換行,與 wc -c 的口徑一致); - 行數:數 '\n' 的個數; - 單字數:不能數空白("a b" 有兩個空白但只有 2 個字)—— 要用第 04 單元練過的狀態機:記住「目前在單字裡嗎」, 只在「從空白區踏進單字區」的瞬間 +1。
"the  fox\n"
 ↑進入單字(+1)  ↑空白  ↑進入單字(+1)   -> 2 個單字
  1. fgetc 的型別陷阱(本單元最經典):它回傳 int,不是 char—— 因為它要能表達 256 種位元組加上 EOF 共 257 種值。 用 char c 接的話:char 無號的平台迴圈永不結束、 有號的平台位元組 0xFF 被誤判成 EOF。永遠寫 int c;。
  2. 迴圈收尾的儀式:fgetc 回 EOF 有兩種原因——真的讀完(feof) 或 I/O 錯誤(ferror)。讀完後檢查 ferror(fp) 區分兩者, 這是可靠檔案程式與玩具程式的分野。
  3. 流程:建測試檔(fopen "w" + fputs)→ 關檔 → 重開 "r" 統計 → remove() 清理。開檔後立刻檢查 NULL。

參考解答

/*
 * ex1_wc.c
 * --------
 * 練習 1:wc 雛形 —— 統計字元數、單字數、行數(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_wc.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

#define TESTFILE "/tmp/ex1_wc_test.txt"

/* 先自建測試檔,讓程式不依賴外部檔案 */
static void create_test_file(void)
{
    FILE *fp = fopen(TESTFILE, "w");
    if (fp == NULL) {
        perror("fopen(w)");
        exit(EXIT_FAILURE);
    }
    fputs("the quick brown fox\n", fp);
    fputs("jumps over\n", fp);
    fputs("\n", fp);                       /* 空行:0 個單字 */
    fputs("the lazy dog\n", fp);
    fclose(fp);
}

/*
 * 一趟掃描同時算三個數:
 * - 字元數:每讀到一個位元組 +1(含換行,與 wc -c 相同)
 * - 行數 :數 '\n' 的個數
 * - 單字數:狀態機——從「空白區」踏進「單字區」的那一刻 +1
 */
static void count_file(const char *path, long *chars, long *words, long *lines)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror("fopen(r)");
        exit(EXIT_FAILURE);
    }

    *chars = *words = *lines = 0;
    int in_word = 0;
    int c;                                 /* 必須用 int 接 fgetc! */

    while ((c = fgetc(fp)) != EOF) {
        (*chars)++;
        if (c == '\n')
            (*lines)++;
        if (isspace(c)) {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            (*words)++;                    /* 單字的第一個字元 */
        }
    }

    /* 收尾檢查:跳出迴圈是因為讀完,還是 I/O 錯誤? */
    if (ferror(fp)) {
        fprintf(stderr, "讀取 %s 時發生 I/O 錯誤\n", path);
        fclose(fp);
        exit(EXIT_FAILURE);
    }
    fclose(fp);
}

int main(void)
{
    create_test_file();

    long chars, words, lines;
    count_file(TESTFILE, &chars, &words, &lines);

    printf("檔案:%s\n", TESTFILE);
    printf("  行數 :%ld\n", lines);
    printf("  單字數:%ld\n", words);
    printf("  字元數:%ld\n", chars);
    printf("(可對照系統指令:wc %s)\n", TESTFILE);

    remove(TESTFILE);                      /* 清理測試檔 */
    return 0;
}

程式碼講解

  • 手算驗證:4 行內容共 9 個單字(4+2+0+3)、 字元數 = 20+11+1+13 = 45(每行含換行)——與程式輸出一致; 跑 wc 會看到 4 9 45。
  • count_file 用三個指標參數回傳三個值——第 01 單元的模式在真實程式中的樣子。
  • isspace(c):這裡的 c 已經是 int 且來自 fgetc(保證是 unsigned char 範圍或 EOF),可直接傳入;從 char 陣列取的字元才需要 (unsigned char) 轉型(第 04 單元的規矩)。
  • 空行的驗證價值:行數 +1、單字數 +0——狀態機對連續空白(含換行) 的處理是否正確,空行一測便知。
  • remove(TESTFILE):標準庫的刪檔函式(<stdio.h>), 測試檔用完就清,不留垃圾。

範例輸出

檔案:/tmp/ex1_wc_test.txt
  行數 :4
  單字數:9
  字元數:45
(可對照系統指令:wc /tmp/ex1_wc_test.txt)

練習 2:file_size —— 用 fseek/ftell 量檔案大小(難度:基礎)

題目

寫 long file_size(const char *path):回傳檔案大小(bytes), 任何失敗(不存在、無法定位)都回傳 -1,不可當掉。 用一個已知長度的檔案驗證,並測試不存在的檔案。

解題思路

  1. 原理一句話:把讀寫位置移到檔尾,問「現在位置在哪」—— 位置的數字正好就是檔案的總位元組數。
檔案(27 bytes):a b c ... z \n
位置 0 ────────────────────→ 位置 27(檔尾)
fseek(fp, 0, SEEK_END)  →  ftell(fp) 回 27
  1. 三步曲與各自的失敗點: - fopen(path, "rb"):不存在/沒權限 → NULL; - fseek(fp, 0L, SEEK_END):不可定位的串流(如管線)→ 非 0; - ftell(fp):失敗 → -1L。 每一步都檢查,任何失敗都先關檔再回 -1——資源不能因為錯誤路徑而洩漏 (與第 07 單元 malloc 錯誤路徑要 free 是同一個紀律)。
  2. 為什麼用 "rb"? 二進位模式保證「位置數字 = 位元組數」。 macOS 上 "r" 與 "rb" 無差別,但文字模式在 Windows 有換行翻譯, 量出來的大小可能與磁碟上不符——習慣成自然。
  3. 回傳型別 long:ftell 的原生回傳型別。README 練習題寫 int, 這裡升級為 long——2 GiB 以上的檔案 int 會溢位(macOS 的 long 是 64 位元)。

參考解答

/*
 * ex2_file_size.c
 * ---------------
 * 練習 2:file_size —— 用 fseek/ftell 量檔案大小(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_file_size.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TESTFILE "/tmp/ex2_size_test.bin"

/*
 * 回傳檔案大小(bytes),失敗回傳 -1。
 * 原理:跳到檔尾(SEEK_END, 位移 0),ftell 回報「目前位置」,
 *      此時的位置正好就是檔案總長度。
 */
long file_size(const char *path)
{
    FILE *fp = fopen(path, "rb");          /* 量大小用二進位模式最保險 */
    if (fp == NULL)
        return -1;                         /* 開不了(不存在、沒權限…) */

    if (fseek(fp, 0L, SEEK_END) != 0) {    /* fseek 失敗回非 0 */
        fclose(fp);
        return -1;
    }
    long size = ftell(fp);                 /* 檔尾的位置 = 大小;失敗回 -1 */
    fclose(fp);
    return size;
}

int main(void)
{
    /* 建立一個內容長度已知的測試檔:26 個字母 + 換行 = 27 bytes */
    FILE *fp = fopen(TESTFILE, "wb");
    if (fp == NULL) {
        perror("fopen");
        return EXIT_FAILURE;
    }
    const char *data = "abcdefghijklmnopqrstuvwxyz\n";
    fwrite(data, 1, strlen(data), fp);
    fclose(fp);

    printf("測試檔    :%s\n", TESTFILE);
    printf("實際寫入   :%zu bytes\n", strlen(data));
    printf("file_size 回報:%ld bytes\n\n", file_size(TESTFILE));

    /* 失敗路徑:不存在的檔案要回 -1,不能當掉 */
    printf("不存在的檔案 :file_size = %ld(-1 = 失敗)\n",
           file_size("/tmp/no_such_file_xyz"));

    remove(TESTFILE);
    return 0;
}

程式碼講解

  • file_size 是「資源+多失敗點」函式的標準骨架: 取得資源 → 每一步失敗都釋放資源後回錯誤碼 → 成功路徑最後釋放。 漏掉 fseek 失敗時的 fclose(fp) 就是檔案控制代碼洩漏。
  • ftell 失敗回 -1L,恰好與我們的錯誤碼一致——直接回傳即可,不用特判。
  • 測試檔用 fwrite(data, 1, strlen(data), fp) 寫: 「元素大小 1、個數 = 長度」是寫位元組串的慣用參數順序。
  • 工程備註:正式程式量檔案大小多用 stat()(系統呼叫,不用開檔), 但 fseek/ftell 是可攜的標準 C 做法,也是理解「串流位置」的好教材。

範例輸出

測試檔    :/tmp/ex2_size_test.bin
實際寫入   :27 bytes
file_size 回報:27 bytes

不存在的檔案 :file_size = -1(-1 = 失敗)

練習 3:加上行號輸出 —— cat -n 雛形(難度:基礎)

題目

讀文字檔,每行前面印 %6d: 格式的行號(cat -n 的雛形)。 要求正確處理「超過緩衝區長度的行」——超長行只能印一個行號。

解題思路

  1. 主體很簡單:fgets 逐行讀、印行號、原樣輸出 (fgets 保留 '\n',所以用 fputs 不用再補換行)。
  2. 真正的考點是超長行:fgets(line, 256, fp) 一次最多讀 255 個字元。 一行有 1000 個字元時會被拆成 4 段——如果每段都印行號,行號就數錯了。
  3. 判斷「這一段是不是行首」:看上一段有沒有以換行結束: - 上一段含 '\n' → 這一段是新行的開頭 → 印行號、行號 +1; - 上一段不含 '\n' → 這一段還是同一行的延續 → 不印行號。
實際的行: aaaa......aaaa\n     (1000 字元)
fgets 段: [255][255][255][235\n]
            ↑印行號   ↑↑↑不印(前段都沒 '\n')

用一個旗標 at_line_start 在迴圈間傳遞這個資訊, 每輪末尾以 strchr(line, '\n') != NULL 更新它。 4. 行號格式:%6d:——右對齊 6 格,與 cat -n 一致。

參考解答

/*
 * ex3_cat_n.c
 * -----------
 * 練習 3:加上行號輸出 —— cat -n 雛形(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_cat_n.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TESTFILE "/tmp/ex3_catn_test.txt"
#define MAXLINE 256

static void create_test_file(void)
{
    FILE *fp = fopen(TESTFILE, "w");
    if (fp == NULL) {
        perror("fopen(w)");
        exit(EXIT_FAILURE);
    }
    fputs("#include <stdio.h>\n", fp);
    fputs("\n", fp);
    fputs("int main(void)\n", fp);
    fputs("{\n", fp);
    fputs("    printf(\"hello\\n\");\n", fp);
    fputs("    return 0;\n", fp);
    fputs("}\n", fp);
    fclose(fp);
}

/*
 * 逐行讀取、每行前面印「%6d: 」。
 * 陷阱:fgets 一次最多讀 MAXLINE-1 個字元——
 * 超長的行會被拆成多段,若每段都印行號就會數錯。
 * 判斷方法:只有「上一段結尾是 '\n'」才代表新的一行開始。
 */
static void cat_n(const char *path)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror("fopen(r)");
        exit(EXIT_FAILURE);
    }

    char line[MAXLINE];
    int lineno = 0;
    int at_line_start = 1;                 /* 目前讀到的段落是否是行首 */

    while (fgets(line, sizeof line, fp) != NULL) {
        if (at_line_start)
            printf("%6d: ", ++lineno);     /* 只在真正的行首印行號 */
        fputs(line, stdout);               /* fgets 保留 '\n',原樣輸出 */
        at_line_start = (strchr(line, '\n') != NULL);
    }

    if (ferror(fp)) {
        fprintf(stderr, "讀取 %s 時發生 I/O 錯誤\n", path);
        fclose(fp);
        exit(EXIT_FAILURE);
    }
    fclose(fp);
}

int main(void)
{
    create_test_file();
    printf("=== %s(加行號) ===\n", TESTFILE);
    cat_n(TESTFILE);

    remove(TESTFILE);
    return 0;
}

程式碼講解

  • at_line_start 初始為 1(檔案開頭一定是行首), 之後每輪由「這一段是否含換行」決定下一段的身分—— 兩行程式碼解決超長行問題,不需要動態緩衝區。
  • fgets(line, sizeof line, fp):直接用 sizeof line 而不是重複寫 MAXLINE——緩衝區大小改了也不會漏改。
  • fputs(line, stdout) 而不是 printf(line):後者把資料當格式字串, 行內若有 % 就出事(格式字串漏洞的雛形)。輸出外來資料一律 fputs 或 printf("%s", ...)。
  • 想驗證超長行邏輯:把 MAXLINE 改成 8 重編譯—— 每行被拆成多段,但行號仍然是 1..7 才算正確。

範例輸出

=== /tmp/ex3_catn_test.txt(加行號) ===
     1: #include <stdio.h>
     2: 
     3: int main(void)
     4: {
     5:     printf("hello\n");
     6:     return 0;
     7: }

練習 4:二進位學生資料庫 —— fwrite 全部、fseek 只讀第 k 筆(難度:進階)

題目

把 struct student 陣列用 fwrite 寫進二進位檔; 再寫 load_kth(k, &out):用 fseek 直接跳到第 k 筆讀出, 不讀整個檔案。讀超出範圍的筆數必須優雅失敗。

解題思路

  1. 二進位檔 = 磁碟上的陣列。固定大小的結構一筆接一筆, 第 k 筆(0 起算)的起始位移是純算術:
位移 = k * sizeof(struct student)

檔案:[ 第0筆 ][ 第1筆 ][ 第2筆 ][ 第3筆 ][ 第4筆 ]
      0        32       64       96       128     (每筆 32 bytes)
                                 ↑ fseek(fp, 3*32, SEEK_SET)

這就是「隨機存取」:檔案一百萬筆也只讀 32 bytes—— 資料庫索引的最底層原理。 2. 寫入:fwrite(a, sizeof a[0], n, fp)——元素大小、筆數分開傳, 回傳實際寫入的筆數,不等於 n 就是磁碟滿等錯誤。 3. 讀第 k 筆:fseek(SEEK_SET) 到位移 → fread(out, sizeof *out, 1, fp)。 k 超出範圍時 fseek 本身可能成功(檔案位置可以超過檔尾)—— 失敗是在 fread 讀到 0 筆時才顯現。所以用 fread 的回傳值判斷, 不能只信 fseek。 4. sizeof(struct student) 是 32 不是 28:int(4) + char[16] + double(8) = 28,但 double 要對齊 8 → name 後面補 4 bytes padding(第 05 單元)。 寫檔用 sizeof 而不是手算,padding 自動算對。 5. 可攜性警告:這種「直接把結構寫進檔案」只保證同一台機器、 同一編譯器能讀回——endianness 與 padding 在別的平台可能不同。

參考解答

/*
 * ex4_student_db.c
 * ----------------
 * 練習 4:二進位學生資料庫 —— fwrite 全部、fseek 只讀第 k 筆(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_student_db.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stdlib.h>

#define DBFILE "/tmp/ex4_students.bin"

struct student {
    int id;
    char name[16];
    double score;
};

/* 把整個結構陣列一次寫進二進位檔 */
static void save_all(const struct student *a, size_t n)
{
    FILE *fp = fopen(DBFILE, "wb");        /* 二進位寫入:wb */
    if (fp == NULL) {
        perror("fopen(wb)");
        exit(EXIT_FAILURE);
    }
    if (fwrite(a, sizeof a[0], n, fp) != n) {  /* 回傳實際寫入筆數 */
        fprintf(stderr, "寫入不完整\n");
        fclose(fp);
        exit(EXIT_FAILURE);
    }
    fclose(fp);
}

/*
 * 隨機存取的核心:第 k 筆(0 起算)的起始位移 = k * sizeof(struct student)。
 * fseek 直接跳過前面 k 筆,只 fread 一筆——檔案再大也只讀固定位元組數。
 * 成功回 0,失敗(k 超出範圍等)回 -1。
 */
static int load_kth(size_t k, struct student *out)
{
    FILE *fp = fopen(DBFILE, "rb");
    if (fp == NULL)
        return -1;

    if (fseek(fp, (long)(k * sizeof *out), SEEK_SET) != 0) {
        fclose(fp);
        return -1;
    }
    size_t got = fread(out, sizeof *out, 1, fp);   /* 讀 1 筆 */
    fclose(fp);
    return (got == 1) ? 0 : -1;            /* 短讀 = k 超過檔案範圍 */
}

int main(void)
{
    struct student class_a[] = {
        { 101, "Alice",  91.5 },
        { 102, "Bob",    78.0 },
        { 103, "Carol",  85.25 },
        { 104, "David",  66.0 },
        { 105, "Eve",    99.0 },
    };
    size_t n = sizeof class_a / sizeof class_a[0];

    save_all(class_a, n);
    printf("已將 %zu 筆學生資料寫入 %s\n", n, DBFILE);
    printf("每筆大小 = %zu bytes,檔案應為 %zu bytes\n\n",
           sizeof(struct student), n * sizeof(struct student));

    /* 隨機讀取:跳著讀第 3 筆、第 0 筆、第 4 筆(不必讀整個檔案) */
    size_t order[] = { 3, 0, 4 };
    for (size_t i = 0; i < 3; i++) {
        struct student s;
        if (load_kth(order[i], &s) == 0)
            printf("第 %zu 筆:id=%d name=%-6s score=%.2f\n",
                   order[i], s.id, s.name, s.score);
    }

    /* 邊界:讀不存在的第 99 筆必須優雅失敗 */
    struct student s;
    printf("\n讀第 99 筆:%s\n",
           load_kth(99, &s) == 0 ? "成功?!" : "失敗(超出範圍,正確行為)");

    remove(DBFILE);
    return 0;
}

程式碼講解

  • 輸出印證了 padding:sizeof(struct student) = 32(不是 4+16+8 = 28), 5 筆共 160 bytes——若手算 28 去 fseek,第 2 筆以後全部讀歪。
  • 讀取順序 3 → 0 → 4 是刻意亂跳的:證明 fseek 讓讀取與寫入順序完全脫鉤。
  • load_kth(99, …) 的失敗機制值得細看:fseek 到位移 3168 是成功的 (位置可以指向檔尾之後),fread 才回 0—— 「短讀(short read)靠 fread 回傳值偵測」是二進位 I/O 的鐵律。
  • fseek 的位移參數是 long:k * sizeof *out 是 size_t(無號), 明確轉 (long) 消除轉型警告,也提醒自己極大檔案要改用 fseeko。

範例輸出

已將 5 筆學生資料寫入 /tmp/ex4_students.bin
每筆大小 = 32 bytes,檔案應為 160 bytes

第 3 筆:id=104 name=David  score=66.00
第 0 筆:id=101 name=Alice  score=91.50
第 4 筆:id=105 name=Eve    score=99.00

讀第 99 筆:失敗(超出範圍,正確行為)

練習 5:CSV 讀取、解析、排序、另存(難度:進階)

題目

讀取 CSV(姓名,年齡,分數,姓名欄可含空白),解析進 struct row 陣列, 依分數由高到低排序(同分保持原順序),另存新 CSV,讀回新檔驗證。

解題思路

  1. 為什麼不用 strtok 切欄? 兩個理由: - strtok 會把連續分隔符當一個——CSV 的空欄位(a,,c)會被吃掉; - 姓名欄含空白(Alice Chen),若圖方便用空白+逗號當分隔符就全毀了。 改用 strchr 找逗號手動切:只認逗號、每個欄位的邊界完全受控。
"Alice Chen,30,88.5"
           ↑c1        ↑c2
 [--name--]  [age]  [score]
 name = line..c1 之間的文字(memcpy + 手動補 '\0')
 age  = sscanf(c1+1, "%d")
 score= sscanf(c2+1, "%lf")
  1. 逐行流程:fgets → strcspn(line, "\n") 去換行 → 跳過空行 → parse_row 成功才收進陣列,失敗印警告到 stderr 並跳過—— 壞資料不該讓整個程式停擺。
  2. 排序的「穩定」要求:同分的兩筆要保持原輸入順序。 選擇排序/快速排序都不穩定;插入排序天生穩定 (只在嚴格小於時才往後搬)。測資裡 Bob 與 David 同為 92 分, 輸出順序 Bob 在前(原順序)才算通過。
  3. 驗證方式:不印記憶體中的陣列,而是把新檔讀回來印—— 驗證的是「檔案裡真的寫對了」,這才是 I/O 題的驗收點。

參考解答

/*
 * ex5_csv_sort.c
 * --------------
 * 練習 5:CSV 讀取、解析、依分數排序後另存新檔(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_csv_sort.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define CSV_IN  "/tmp/ex5_in.csv"
#define CSV_OUT "/tmp/ex5_sorted.csv"
#define MAXROWS 32
#define MAXLINE 128

struct row {
    char name[32];         /* 欄位可含空白,如 "Mary Ann" */
    int age;
    double score;
};

static void create_test_csv(void)
{
    FILE *fp = fopen(CSV_IN, "w");
    if (fp == NULL) {
        perror("fopen(w)");
        exit(EXIT_FAILURE);
    }
    fputs("Alice Chen,30,88.5\n", fp);     /* 名字含空白 */
    fputs("Bob,25,92.0\n", fp);
    fputs("Carol Wu,35,79.25\n", fp);
    fputs("David,28,92.0\n", fp);          /* 與 Bob 同分:測穩定性 */
    fclose(fp);
}

/*
 * 解析一行 "name,age,score":
 * 用 strchr 找逗號手動切欄——比 strtok 更能掌控
 * (strtok 會跳過連續分隔符,空欄位會出錯),
 * 且 name 欄含空白也完全沒問題(只認逗號)。
 * 成功回 1,格式錯誤回 0。
 */
static int parse_row(const char *line, struct row *r)
{
    const char *c1 = strchr(line, ',');    /* 第一個逗號 */
    if (c1 == NULL)
        return 0;
    const char *c2 = strchr(c1 + 1, ','); /* 第二個逗號 */
    if (c2 == NULL)
        return 0;

    size_t namelen = (size_t)(c1 - line);
    if (namelen == 0 || namelen >= sizeof r->name)
        return 0;
    memcpy(r->name, line, namelen);
    r->name[namelen] = '\0';

    if (sscanf(c1 + 1, "%d", &r->age) != 1)
        return 0;
    if (sscanf(c2 + 1, "%lf", &r->score) != 1)
        return 0;
    return 1;
}

/* 讀整個 CSV 進 rows[],回傳筆數 */
static int load_csv(const char *path, struct row rows[], int max)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror("fopen(r)");
        exit(EXIT_FAILURE);
    }
    char line[MAXLINE];
    int n = 0;
    while (n < max && fgets(line, sizeof line, fp) != NULL) {
        line[strcspn(line, "\n")] = '\0';  /* 去掉行尾換行 */
        if (line[0] == '\0')
            continue;                      /* 跳過空行 */
        if (parse_row(line, &rows[n]))
            n++;
        else
            fprintf(stderr, "警告:略過格式錯誤的行:%s\n", line);
    }
    fclose(fp);
    return n;
}

/* 依 score 由高到低排序(插入排序:穩定,同分保持原順序) */
static void sort_by_score_desc(struct row rows[], int n)
{
    for (int i = 1; i < n; i++) {
        struct row key = rows[i];
        int j = i - 1;
        while (j >= 0 && rows[j].score < key.score) {
            rows[j + 1] = rows[j];
            j--;
        }
        rows[j + 1] = key;
    }
}

static void save_csv(const char *path, const struct row rows[], int n)
{
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        perror("fopen(w)");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < n; i++)
        fprintf(fp, "%s,%d,%.2f\n", rows[i].name, rows[i].age, rows[i].score);
    fclose(fp);
}

int main(void)
{
    create_test_csv();

    struct row rows[MAXROWS];
    int n = load_csv(CSV_IN, rows, MAXROWS);
    printf("讀入 %d 筆:\n", n);
    for (int i = 0; i < n; i++)
        printf("  %-10s 年齡 %2d  分數 %.2f\n",
               rows[i].name, rows[i].age, rows[i].score);

    sort_by_score_desc(rows, n);
    save_csv(CSV_OUT, rows, n);

    /* 讀回新檔驗證(而不是直接印記憶體中的陣列) */
    printf("\n依分數排序後(%s 內容):\n", CSV_OUT);
    FILE *fp = fopen(CSV_OUT, "r");
    if (fp == NULL) {
        perror("fopen");
        return EXIT_FAILURE;
    }
    char line[MAXLINE];
    while (fgets(line, sizeof line, fp) != NULL)
        printf("  %s", line);
    fclose(fp);

    remove(CSV_IN);
    remove(CSV_OUT);
    return 0;
}

程式碼講解

  • parse_row 的防禦鏈:兩個逗號都要在(缺一即格式錯誤)、 姓名非空且塞得進緩衝區、兩個數字都要能解析—— 每一關失敗都回 0,呼叫端統一跳過並警告。解析外部資料的正確姿勢。
  • line[strcspn(line, "\n")] = '\0';:去除行尾換行的慣用一行—— strcspn 回傳「第一個換行的位置或字串長度」,兩種情況寫 '\0' 都正確 (最後一行沒換行也不會出錯)。
  • 穩定性驗證成功:Bob 與 David 同為 92 分,輸出中 Bob(原本在前)仍在前。 若換成不穩定的排序,這個順序沒有保證。
  • while (rows[j].score < key.score) 用嚴格小於: 等分時不搬動——這正是插入排序穩定性的來源,改成 <= 就破功。
  • 本題串起了五個單元:字串解析(04)、結構陣列(05)、 排序(03 的字串排序同款邏輯)、指標參數(01)、檔案 I/O(11)。

範例輸出

讀入 4 筆:
  Alice Chen 年齡 30  分數 88.50
  Bob        年齡 25  分數 92.00
  Carol Wu   年齡 35  分數 79.25
  David      年齡 28  分數 92.00

依分數排序後(/tmp/ex5_sorted.csv 內容):
  Bob,25,92.00
  David,28,92.00
  Alice Chen,30,88.50
  Carol Wu,35,79.25

練習 6:簡易 grep(難度:挑戰)

題目

實作 mygrep 關鍵字 檔案:逐行讀取,含關鍵字的行印出「行號: 內容」 (用 strstr 搜尋)。要求: 支援命令列引數(不帶引數時用內建測試檔示範); 開檔失敗用 perror 回報到 stderr;仿真正的 grep 設計結束碼 (找到 0、沒找到 1、錯誤 2 以外本題用 EXIT_FAILURE)。

解題思路

  1. 核心迴圈三件事:fgets 讀一行、行號 +1、strstr 找關鍵字, 命中就印。strstr(第 04 單元實作過)回傳子字串位置或 NULL—— 這裡只需要「有沒有」,位置本身不用。
  2. 命令列引數:main(int argc, char *argv[])—— argv[1] 是關鍵字、argv[2] 是路徑(argv 是指標陣列,第 03 單元)。 argc == 3 走真實模式;否則自建測試檔示範(維持本教程「零依賴」慣例)。
  3. 錯誤處理的分流: - 錯誤訊息 → stderr(perror 印出「路徑: 系統原因」); - 搜尋結果 → stdout。 這樣 mygrep foo file > hits.txt 時錯誤仍會出現在螢幕上。 小細節:混印 stdout 與 stderr 前先 fflush(stdout)—— stdout 有緩衝而 stderr 沒有,不沖的話重導向時順序會錯亂。
  4. 結束碼學 grep:找到命中回 0、沒找到回 1—— 讓 shell 腳本可以寫 if mygrep err log.txt; then …。
  5. 邊界:最後一行可能沒有換行——印完檢查 strchr(line, '\n'), 沒有就補一個,輸出才不會黏行。

參考解答

/*
 * ex6_mygrep.c
 * ------------
 * 練習 6:簡易 grep —— 逐行搜尋關鍵字,印行號與內容(難度:挑戰)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_mygrep.c
 * 執行:/tmp/out
 * (支援命令列:/tmp/out 關鍵字 檔案;不帶引數時用內建示範)
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TESTFILE "/tmp/ex6_grep_test.txt"
#define MAXLINE 512

/*
 * 核心:逐行 fgets,用 strstr 找關鍵字,
 * 命中就印「行號:內容」。回傳命中行數;開檔失敗回 -1。
 */
static int mygrep(const char *keyword, const char *path)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        fflush(stdout);                     /* 先沖出 stdout,避免與 stderr 順序錯亂 */
        fprintf(stderr, "mygrep: ");
        perror(path);                       /* 印出「路徑: 系統錯誤原因」 */
        return -1;
    }

    char line[MAXLINE];
    int lineno = 0;
    int hits = 0;

    while (fgets(line, sizeof line, fp) != NULL) {
        lineno++;
        if (strstr(line, keyword) != NULL) {
            hits++;
            printf("%4d: %s", lineno, line);
            /* fgets 保留了 '\n';最後一行可能沒有換行,補上 */
            if (strchr(line, '\n') == NULL)
                putchar('\n');
        }
    }

    if (ferror(fp)) {
        fprintf(stderr, "mygrep: 讀取 %s 時發生 I/O 錯誤\n", path);
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return hits;
}

static void create_test_file(void)
{
    FILE *fp = fopen(TESTFILE, "w");
    if (fp == NULL) {
        perror("fopen(w)");
        exit(EXIT_FAILURE);
    }
    fputs("#include <stdio.h>\n", fp);
    fputs("\n", fp);
    fputs("/* pointer demo */\n", fp);
    fputs("int main(void)\n", fp);
    fputs("{\n", fp);
    fputs("    int x = 42;\n", fp);
    fputs("    int *p = &x;      /* pointer to x */\n", fp);
    fputs("    printf(\"%d\\n\", *p);\n", fp);
    fputs("    return 0;\n", fp);
    fputs("}\n", fp);
    fclose(fp);
}

int main(int argc, char *argv[])
{
    /* 帶引數就是真正的 grep 用法:mygrep 關鍵字 檔案 */
    if (argc == 3) {
        int hits = mygrep(argv[1], argv[2]);
        if (hits < 0)
            return EXIT_FAILURE;
        return (hits > 0) ? 0 : 1;          /* 仿 grep:沒找到回 1 */
    }

    /* 不帶引數:用內建測試檔示範 */
    create_test_file();

    printf("=== mygrep \"pointer\" %s ===\n", TESTFILE);
    int hits = mygrep("pointer", TESTFILE);
    printf("(共 %d 行命中)\n\n", hits);

    printf("=== mygrep \"int\" %s ===\n", TESTFILE);
    hits = mygrep("int", TESTFILE);
    printf("(共 %d 行命中)\n\n", hits);

    printf("=== 搜尋不存在的檔案 ===\n");
    mygrep("foo", "/tmp/no_such_file_xyz");

    remove(TESTFILE);
    return 0;
}

程式碼講解

  • 搜 "int" 的結果值得玩味:命中 5 行,其中第 3 行 /* pointer demo */ 與第 8 行 printf(...) 並沒有 int 這個「單字」—— 但 pointer 與 printf 都含有子字串 int! strstr 是子字串搜尋,不是單字搜尋——真正的 grep 加 -w 才是 整字比對(要自己檢查前後字元是否為字母數字)。
  • printf("%4d: %s", lineno, line):line 自帶 fgets 保留的換行, 格式字串不再放 \n——否則命中行之間會多出空行。
  • 一支程式兩種模式(argc == 3 走真實 grep、否則走示範) 讓它既能自動驗收又能真的拿來用: cc -o /tmp/mygrep ex6_mygrep.c && /tmp/mygrep pointer 某檔案。
  • 開檔失敗時 perror(path) 印出的 No such file or directory 來自系統的 errno——比自己寫「開檔失敗」多了原因(沒權限?不存在?), 免費的診斷資訊不要浪費。

範例輸出

=== mygrep "pointer" /tmp/ex6_grep_test.txt ===
   3: /* pointer demo */
   7:     int *p = &x;      /* pointer to x */
(共 2 行命中)

=== mygrep "int" /tmp/ex6_grep_test.txt ===
   3: /* pointer demo */
   4: int main(void)
   6:     int x = 42;
   7:     int *p = &x;      /* pointer to x */
   8:     printf("%d\n", *p);
(共 5 行命中)

=== 搜尋不存在的檔案 ===
mygrep: /tmp/no_such_file_xyz: No such file or directory

常見錯誤解法對照

錯誤寫法 症狀 正確做法
不檢查 fopen 回傳值 fp 為 NULL 時下一個操作當掉 開檔後立刻 if (fp == NULL) { perror(...); ... }
char c; while ((c = fgetc(fp)) != EOF) 無窮迴圈或 0xFF 誤判成 EOF int c;(fgetc 回傳 int)
忘記 fclose 緩衝資料沒落地、控制代碼洩漏 一個 fopen 對一個 fclose(錯誤路徑也要關)
用 "w" 開想保留的檔案 內容瞬間清空 附加用 "a"、讀寫用 "r+"
手算結構大小去 fseek padding 沒算到,讀出錯位的資料 一律用 sizeof(struct ...)
只看 fseek 成功與否判斷第 k 筆存在 位置可超過檔尾,fseek 照樣成功 用 fread 回傳的筆數判斷短讀
用 strtok 切可能有空欄位的 CSV 連續逗號被當一個,欄位錯位 strchr 找逗號手動切欄
printf(line) 輸出檔案內容 行內的 % 引發未定義行為 fputs(line, stdout) 或 printf("%s", line)
錯誤訊息印到 stdout 重導向後錯誤被吞進檔案 錯誤一律 stderr(perror/fprintf)