本檔收錄本單元的練習題、完整解題思路與參考解答。
強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」;
真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 exercises/ 資料夾,可直接編譯:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_wc.c && /tmp/out
所有解答都自己先建立測試檔(寫到 /tmp/)再讀取,結束時清理,不依賴任何外部檔案。
練習 1:wc 雛形 —— 統計字元數、單字數、行數(難度:基礎)
題目
寫程式統計一個文字檔的字元數、單字數、行數(自建測試檔),
輸出可對照系統指令 wc 的結果。
解題思路
- 三個計數,一趟掃描:逐字元讀(
fgetc),同時維護三個計數器: - 字元數:每讀到一個位元組就 +1(含換行,與wc -c的口徑一致); - 行數:數'\n'的個數; - 單字數:不能數空白("a b"有兩個空白但只有 2 個字)—— 要用第 04 單元練過的狀態機:記住「目前在單字裡嗎」, 只在「從空白區踏進單字區」的瞬間 +1。
"the fox\n"
↑進入單字(+1) ↑空白 ↑進入單字(+1) -> 2 個單字
fgetc的型別陷阱(本單元最經典):它回傳int,不是char—— 因為它要能表達 256 種位元組加上 EOF 共 257 種值。 用char c接的話:char無號的平台迴圈永不結束、 有號的平台位元組 0xFF 被誤判成 EOF。永遠寫int c;。- 迴圈收尾的儀式:
fgetc回 EOF 有兩種原因——真的讀完(feof) 或 I/O 錯誤(ferror)。讀完後檢查ferror(fp)區分兩者, 這是可靠檔案程式與玩具程式的分野。 - 流程:建測試檔(
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,不可當掉。
用一個已知長度的檔案驗證,並測試不存在的檔案。
解題思路
- 原理一句話:把讀寫位置移到檔尾,問「現在位置在哪」—— 位置的數字正好就是檔案的總位元組數。
檔案(27 bytes):a b c ... z \n
位置 0 ────────────────────→ 位置 27(檔尾)
fseek(fp, 0, SEEK_END) → ftell(fp) 回 27
- 三步曲與各自的失敗點:
-
fopen(path, "rb"):不存在/沒權限 → NULL; -fseek(fp, 0L, SEEK_END):不可定位的串流(如管線)→ 非 0; -ftell(fp):失敗 → -1L。 每一步都檢查,任何失敗都先關檔再回 -1——資源不能因為錯誤路徑而洩漏 (與第 07 單元 malloc 錯誤路徑要 free 是同一個紀律)。 - 為什麼用
"rb"? 二進位模式保證「位置數字 = 位元組數」。 macOS 上"r"與"rb"無差別,但文字模式在 Windows 有換行翻譯, 量出來的大小可能與磁碟上不符——習慣成自然。 - 回傳型別
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 的雛形)。
要求正確處理「超過緩衝區長度的行」——超長行只能印一個行號。
解題思路
- 主體很簡單:
fgets逐行讀、印行號、原樣輸出 (fgets保留'\n',所以用fputs不用再補換行)。 - 真正的考點是超長行:
fgets(line, 256, fp)一次最多讀 255 個字元。 一行有 1000 個字元時會被拆成 4 段——如果每段都印行號,行號就數錯了。 - 判斷「這一段是不是行首」:看上一段有沒有以換行結束:
- 上一段含
'\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 筆讀出,
不讀整個檔案。讀超出範圍的筆數必須優雅失敗。
解題思路
- 二進位檔 = 磁碟上的陣列。固定大小的結構一筆接一筆, 第 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,讀回新檔驗證。
解題思路
- 為什麼不用
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")
- 逐行流程:
fgets→strcspn(line, "\n")去換行 → 跳過空行 →parse_row成功才收進陣列,失敗印警告到 stderr 並跳過—— 壞資料不該讓整個程式停擺。 - 排序的「穩定」要求:同分的兩筆要保持原輸入順序。 選擇排序/快速排序都不穩定;插入排序天生穩定 (只在嚴格小於時才往後搬)。測資裡 Bob 與 David 同為 92 分, 輸出順序 Bob 在前(原順序)才算通過。
- 驗證方式:不印記憶體中的陣列,而是把新檔讀回來印—— 驗證的是「檔案裡真的寫對了」,這才是 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)。
解題思路
- 核心迴圈三件事:
fgets讀一行、行號 +1、strstr找關鍵字, 命中就印。strstr(第 04 單元實作過)回傳子字串位置或 NULL—— 這裡只需要「有沒有」,位置本身不用。 - 命令列引數:
main(int argc, char *argv[])——argv[1]是關鍵字、argv[2]是路徑(argv是指標陣列,第 03 單元)。argc == 3走真實模式;否則自建測試檔示範(維持本教程「零依賴」慣例)。 - 錯誤處理的分流:
- 錯誤訊息 →
stderr(perror印出「路徑: 系統原因」); - 搜尋結果 →stdout。 這樣mygrep foo file > hits.txt時錯誤仍會出現在螢幕上。 小細節:混印 stdout 與 stderr 前先fflush(stdout)—— stdout 有緩衝而 stderr 沒有,不沖的話重導向時順序會錯亂。 - 結束碼學 grep:找到命中回 0、沒找到回 1——
讓 shell 腳本可以寫
if mygrep err log.txt; then …。 - 邊界:最後一行可能沒有換行——印完檢查
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) |