本檔收錄本單元的練習題、完整解題思路與參考解答。
強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」;
真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 exercises/ 資料夾,可直接編譯:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_next_id.c && /tmp/out
練習 1:next_id —— static 區域變數產生序號(難度:基礎)
題目
寫函式 int next_id(void):用 static 區域變數,每次呼叫回傳遞增的序號(1, 2, 3, …)。
另寫一個用普通區域變數的版本作對照,解釋為什麼它壞掉。
解題思路
- 需求分析:函式要「記得上次回傳到幾」——這需要一個跨呼叫存活的變數。 放全域可以,但汙染全域命名空間且人人可改; static 區域變數正是為此而生:作用域仍限函式內(外面看不到、改不到), 生命週期卻是整個程式。
- 兩行搞定:
static int id = 0; /* 程式啟動時初始化一次 */
return ++id; /* 每次呼叫在舊值上遞增 */
關鍵認知:static int id = 0; 的初始化不是每次呼叫都執行——
它發生在程式載入時(初始器必須是常數運算式的原因)。
3. 對照組:把 static 拿掉,id 變成 auto 變數——
每次呼叫重新配置、重新初始化為 0,永遠回傳 1。
兩個版本只差一個關鍵字,行為天差地遠,這就是「作用域 vs 生命週期是兩回事」
的最小示範。
參考解答
/*
* ex1_next_id.c
* -------------
* 練習 1:next_id —— 用 static 區域變數產生遞增序號(難度:基礎)
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_next_id.c
* 執行:/tmp/out
*/
#include <stdio.h>
/*
* static 區域變數:作用域仍限本函式,
* 但生命週期是「整個程式」——只初始化一次,跨呼叫保值。
*/
int next_id(void)
{
static int id = 0; /* 程式啟動時初始化一次,之後保留舊值 */
return ++id; /* 先加再回:1, 2, 3, ... */
}
/*
* 對照組:普通(auto)區域變數版本。
* 每次呼叫 id 都重新配置並初始化為 0——永遠回傳 1。
*/
int next_id_broken(void)
{
int id = 0; /* 每次呼叫都重新來過 */
return ++id;
}
int main(void)
{
printf("static 版(正確) :");
for (int i = 0; i < 5; i++)
printf("%d ", next_id());
printf("\n");
printf("auto 版(壞掉) :");
for (int i = 0; i < 5; i++)
printf("%d ", next_id_broken());
printf("\n");
/* 從別的地方呼叫,序號仍然接續——狀態在函式裡,不在呼叫端 */
printf("再叫一次 next_id() :%d(接續前面的 5)\n", next_id());
return 0;
}
程式碼講解
static int id = 0;:其實= 0可省略(static 物件保證清零), 但寫出來語意更明確。++id先加後回:第一次呼叫回 1。若寫id++會從 0 開始—— 一個字元的差異,靠測試釘住。- 「狀態藏在函式裡」的代價:不可重入、多執行緒下有資料競爭、 測試無法重設(沒有 reset 介面)。這些限制在練習 6 的 tokenizer 會再看到—— static 區域變數好用,但要知道你付了什麼。
- ID 產生器、呼叫計數器、一次性初始化(
static int initialized = 0;) 都是這個模式的日常應用。
範例輸出
static 版(正確) :1 2 3 4 5
auto 版(壞掉) :1 1 1 1 1
再叫一次 next_id() :6(接續前面的 5)
練習 2:線性同餘偽亂數產生器(難度:基礎)
題目
(K&R §2.7 的 rand 範例)寫一個偽亂數產生器:
用檔案層級的 static unsigned long seed 當內部狀態,
rand_next() 以線性同餘法(LCG)更新 seed 並回傳 0..32767 的值;
rand_seed(s) 重設種子。驗證「同一個種子必然重現同一串亂數」。
解題思路
- 偽亂數的本質:一條確定性的遞推數列,只是看起來亂。
LCG 的遞推式:
seed = seed * A + C(mod 2³²)。 K&R 的經典常數:A = 1103515245、C = 12345。 - mod 2³² 在哪裡? 不用寫!
unsigned long的溢位在 C 中定義為環繞 (wrap around),乘加自然對 2 的冪取模——這是少數「溢位是功能」的場合 (注意:有號溢位仍是未定義行為,這招只對 unsigned 成立)。 - 為什麼回傳
(seed / 65536) % 32768? LCG 的低位元品質很差(低 k 位有 2^k 的短週期);/65536先丟掉低 16 位、再取 15 位,回傳的是品質較好的高位段。 - 狀態的儲存:
static unsigned long seed——檔案層級 static:rand_next與rand_seed兩個函式都要碰它,所以不能放函式內; 放檔案層級並加 static,其他檔案就無法直接亂改 seed(封裝)。 - 可重現性是偽亂數的特性也是功能:模擬實驗要能重跑、除錯要能重現—— 測試「同種子同序列」就是在驗收這一點。
參考解答
/*
* ex2_prng.c
* ----------
* 練習 2:線性同餘偽亂數產生器 —— static 狀態(難度:基礎)
* 出處:K&R §2.7 的 rand/srand 範例
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_prng.c
* 執行:/tmp/out
*/
#include <stdio.h>
/*
* 檔案層級的 static:只有本檔案看得到 seed(封裝)。
* 線性同餘法(LCG):seed = seed * A + C(mod 2^32,靠 unsigned 溢位自動取模)
* 常數取自 K&R:A = 1103515245,C = 12345。
*/
static unsigned long seed = 1; /* 預設種子 */
/* 產生下一個偽亂數:0 .. 32767 */
int rand_next(void)
{
seed = seed * 1103515245 + 12345;
return (int)((unsigned)(seed / 65536) % 32768);
}
/* 重設種子:同一個種子 -> 同一串亂數(可重現性) */
void rand_seed(unsigned long s)
{
seed = s;
}
int main(void)
{
printf("種子 = 1(預設):");
for (int i = 0; i < 8; i++)
printf("%d ", rand_next());
printf("\n");
printf("種子 = 42 :");
rand_seed(42);
for (int i = 0; i < 8; i++)
printf("%d ", rand_next());
printf("\n");
/* 可重現性驗證:同一種子必然重現同一串 */
printf("種子重設回 42 :");
rand_seed(42);
for (int i = 0; i < 8; i++)
printf("%d ", rand_next());
printf("(與上一行相同)\n");
return 0;
}
程式碼講解
static unsigned long seed = 1;:預設種子 1 與 C 標準對rand()的規定一致 (未呼叫 srand 時如同 srand(1))。rand_next與rand_seed就是你自己的rand/srand—— 標準庫的實作骨架大同小異。- 輸出驗證:種子 42 的兩輪序列逐字相同——「亂」是對外觀說的, 對同一種子它是完全確定的函數。
- 這個 LCG 適合教學與簡單模擬;密碼學用途絕對不行 (可預測),遊戲/統計建議用更現代的 PCG、xoshiro 等演算法。
- 易錯處:把 seed 宣告成
int(有號溢位是未定義行為!)—— 無號型別是這個技巧合法性的前提。
範例輸出
種子 = 1(預設):16838 5758 10113 17515 31051 5627 23010 7419
種子 = 42 :19081 17033 15269 25461 13856 1093 13677 26500
種子重設回 42 :19081 17033 15269 25461 13856 1093 13677 26500 (與上一行相同)
練習 3:遮蔽(shadowing)bug 重演與修正(難度:基礎)
題目
故意寫一個有遮蔽 bug 的加總函式(迴圈內的 int sum 遮蔽外層的 sum,導致回傳 0),
用 cc -Wshadow 編譯觀察警告,然後修正。
另外重演 README 的多層遮蔽示範(全域 x → 函式層 x → 區塊層 x)。
解題思路
- 製造 bug:在迴圈本體「不小心」重新宣告
sum:
int sum = 0;
for (int i = 0; i < n; i++) {
int sum = a[i]; /* 多打了 int ——新變數遮蔽外層! */
}
return sum; /* 外層 sum 從沒被加過:回 0 */
內層 sum 每輪出生、賦值、死亡;外層 sum 毫髮無傷。
這種 bug 的可怕之處:完全合法,-Wall -Wextra 都不吭聲。
2. 用工具抓:-Wshadow 專門警告「內層宣告遮蔽外層同名變數」。
實際編譯的警告輸出(真實訊息):
ex3_shadowing.c:22:13: warning: declaration shadows a local variable [-Wshadow]
22 | int sum = a[i]; /* 遮蔽!本來想寫 sum += a[i]; */
| ^
ex3_shadowing.c:20:9: note: previous declaration is here
20 | int sum = 0;
- 修正:刪掉那個多餘的
int,讓迴圈直接累加到外層變數。 - 多層遮蔽的規則:名稱查找永遠找最內層的宣告;
離開區塊後,外層變數重新「可見」。合法、但同名多層是可讀性災難——
-Wshadow+ 好的命名習慣是預防之道。
參考解答
/*
* ex3_shadowing.c
* ---------------
* 練習 3:遮蔽(shadowing)bug 重演與修正(難度:基礎)
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_shadowing.c
* 抓蟲:cc -std=c11 -Wall -Wextra -Wshadow -c ex3_shadowing.c
* (-Wshadow 會對 buggy_sum 的內層 sum 發出警告)
* 執行:/tmp/out
*/
#include <stdio.h>
/*
* 壞掉的版本:迴圈裡不小心「重新宣告」了 sum,
* 內層 sum 遮蔽外層——每輪加進去的值跟著內層一起丟棄,
* 外層 sum 從頭到尾都是 0。
*/
int buggy_sum(const int *a, int n)
{
int sum = 0;
for (int i = 0; i < n; i++) {
int sum = a[i]; /* 遮蔽!本來想寫 sum += a[i]; */
(void)sum; /* 讓內層 sum「有被使用」,聚焦在遮蔽本身 */
}
return sum; /* 回傳的是外層的 0 */
}
/* 修正版:不重新宣告,直接累加到同一個 sum */
int fixed_sum(const int *a, int n)
{
int sum = 0;
for (int i = 0; i < n; i++)
sum += a[i];
return sum;
}
/* 順帶示範「合法但危險」的多層遮蔽(README 的例子) */
int x = 1; /* 全域 x */
void shadow_demo(void)
{
printf("全域 x = %d\n", x);
int x = 2; /* 函式層遮蔽全域 */
printf("函式層 x = %d\n", x);
{
int x = 3; /* 區塊層再遮蔽一次 */
printf("區塊層 x = %d\n", x);
}
printf("離開區塊後 x = %d(函式層的 x 又看得到了)\n", x);
}
int main(void)
{
int data[] = { 10, 20, 30, 40 };
printf("buggy_sum = %d(應為 100,卻是 0!)\n", buggy_sum(data, 4));
printf("fixed_sum = %d\n\n", fixed_sum(data, 4));
shadow_demo();
return 0;
}
程式碼講解
(void)sum;:把變數轉成 void 是「我知道它沒被進一步使用」的標準寫法, 抑制 unused 類警告,讓示範聚焦在遮蔽本身。buggy_sum輸出 0 而不是 100——bug 安靜地給出錯誤答案, 沒有 crash、沒有警告(除非開-Wshadow),是最難抓的一類。- 用
-Wshadow編譯本檔會得到 3 個警告:buggy_sum的 sum、shadow_demo的兩層 x——後兩個是「刻意示範」,實務上這種同名巢狀也該改名。 - 為什麼
-Wshadow不在-Wall裡?因為有些合法模式(如建構式風格的參數名 與成員同名)會誤傷;但在自己的專案裡開著它,划算。
範例輸出
buggy_sum = 0(應為 100,卻是 0!)
fixed_sum = 100
全域 x = 1
函式層 x = 2
區塊層 x = 3
離開區塊後 x = 2(函式層的 x 又看得到了)
練習 4:後序計算器 —— static 堆疊 + push/pop(難度:進階)
題目
(K&R §4.3 計算器的單檔縮影)用 static 全域的堆疊陣列與 push/pop 函式,
實作後序(RPN)運算式計算器 eval_postfix,
計算 "3 4 + 5 *"(即 (3+4)*5 = 35)等運算式。
需支援 + - * /、負數 token 與除零防護。
解題思路
- RPN 的計算規則只有兩條,天生適合堆疊: - 看到數字 → push; - 看到運算子 → pop 兩個運算元、計算、把結果 push 回去。 整條運算式掃完,棧頂就是答案。
"3 4 + 5 *":
3 -> 棧 [3]
4 -> 棧 [3, 4]
+ -> pop 4, 3 -> push 7 棧 [7]
5 -> 棧 [7, 5]
* -> pop 5, 7 -> push 35 棧 [35] <- 答案
- 堆疊的封裝(本單元重點):
val[]與sp宣告成 static 檔案層級——main或其他未來的檔案無法直接動sp,只能透過push/pop。 這正是 K&R §4.3 的設計:介面(函式)公開、狀態(變數)私有。 - pop 順序是陷阱:先 pop 出來的是右運算元!
5 1 2 + 4 * -中最後的-:pop 得 12(右)、5(左)→5 - 12 = -7。 順序反了會得 7,減法與除法會露餡(加法乘法可交換,測不出來)。 - 切 token:用
strtok(第 04 單元)以空白切割; strtok 會改寫字串,所以先把運算式複製進區域陣列。 - 負數 token 的判斷:
-3的第一個字元是-,不能只看isdigit(tok[0])—— 補一個「-後面跟著數字」的判斷,與「減號運算子」(單獨一個-)區分開。
參考解答
/*
* ex4_postfix_calc.c
* ------------------
* 練習 4:後序計算器 —— static 全域堆疊 + push/pop(難度:進階)
* 出處:K&R §4.3 計算器範例的單檔縮影
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_postfix_calc.c
* 執行:/tmp/out
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define MAXVAL 100
/* 檔案私有的堆疊狀態:外部檔案完全碰不到(static 的封裝力) */
static double val[MAXVAL];
static int sp = 0; /* 堆疊指標:下一個空位 */
/* 公開介面:只有 push/pop 能操作堆疊 */
void push(double v)
{
if (sp < MAXVAL)
val[sp++] = v;
else
fprintf(stderr, "錯誤:堆疊已滿,%g 被丟棄\n", v);
}
double pop(void)
{
if (sp > 0)
return val[--sp];
fprintf(stderr, "錯誤:堆疊是空的\n");
return 0.0;
}
/*
* 計算一條後序(RPN)運算式,token 以空白分隔。
* 規則:數字就 push;運算子就 pop 兩個、算完 push 回去。
* 注意 pop 的順序:先彈出的是「右運算元」。
*/
double eval_postfix(const char *expr)
{
char buf[128];
strncpy(buf, expr, sizeof buf - 1); /* strtok 要改寫字串,先複製 */
buf[sizeof buf - 1] = '\0';
for (char *tok = strtok(buf, " "); tok != NULL; tok = strtok(NULL, " ")) {
if (isdigit((unsigned char)tok[0]) ||
(tok[0] == '-' && isdigit((unsigned char)tok[1]))) {
push(atof(tok)); /* 數字(含負數):入棧 */
} else {
double right = pop(); /* 先彈出的是右運算元! */
double left = pop();
switch (tok[0]) {
case '+': push(left + right); break;
case '-': push(left - right); break;
case '*': push(left * right); break;
case '/':
if (right == 0.0) {
fprintf(stderr, "錯誤:除以零\n");
push(0.0);
} else {
push(left / right);
}
break;
default:
fprintf(stderr, "錯誤:未知運算子 %s\n", tok);
}
}
}
return pop(); /* 最後棧頂就是答案 */
}
static void calc(const char *expr)
{
printf("%-16s = %g\n", expr, eval_postfix(expr));
}
int main(void)
{
calc("3 4 + 5 *"); /* (3 + 4) * 5 = 35 */
calc("10 2 /"); /* 10 / 2 = 5 */
calc("1 2 3 4 + + +"); /* 1+2+3+4 = 10 */
calc("5 1 2 + 4 * - "); /* 5 - (1+2)*4 = -7 */
calc("-3 8 *"); /* 負數 token:-24 */
return 0;
}
程式碼講解
static double val[MAXVAL]; static int sp = 0;:狀態私有化的兩行。 若這個檔案日後拆出去當stack.c,任何main.c想寫sp = 0;都會吃連結錯誤(找不到符號)——static 把封裝從「君子協定」升級成「編譯器保證」。push/pop都帶邊界檢查:溢位丟棄並回報、下溢回 0 並回報—— K&R 原版也是這個策略(回報但不中止)。strncpy+ 手動補'\0':第 04 單元的安全慣例,因為 strtok 需要可寫副本。"5 1 2 + 4 * -"是驗證 pop 順序的關鍵測資:答案 -7 對了才算過關。- 這個計算器離 K&R §4.5 的完整版只差:從 stdin 讀取、支援浮點語法完整解析
(
getop函式)、模組拆檔——正是下一題的主題。
範例輸出
3 4 + 5 * = 35
10 2 / = 5
1 2 3 4 + + + = 10
5 1 2 + 4 * - = -7
-3 8 * = -24
練習 5:單檔模擬多檔模組 —— static 封裝(難度:進階)
題目
在單一檔案內,按「多檔模組」的紀律實作一個 counter 模組:
- 私有狀態(目前計數、歷史遞增次數)與私有小工具函式全部
static; - 公開介面四個函式:
counter_incr、counter_reset、counter_get、counter_total_incr; counter_reset只清目前計數、不清歷史統計;- 在檔尾註解寫出拆成
counter.h/counter.c/main.c三檔的做法(含 include guard)。
(本教程的解答 .c 需單檔可編譯,所以以單檔模擬;實際拆檔留給讀者自行操作,
拆檔後用 cc main.c counter.c 編譯。)
解題思路
- 模組 = 介面 + 隱藏的實作。C 沒有 class,但有等價的三件套:
-
.h(介面):只放公開函式的原型; -.c(實作):狀態變數與內部函式全部static(檔案私有); - 使用端:只 include.h,看不到也碰不到實作細節。 - 本題的狀態設計故意放了兩個變數:
count(可被 reset)與total_incr(歷史統計,reset 不歸零)——展示「模組內部可以有多個 互相配合的私有變數,外界只透過介面觀察」。 - 內部小工具也 static:
clamp_nonneg這種輔助函式若不加 static, 會變成全域符號,可能與別的檔案的同名函式撞名(連結錯誤或更糟——靜默取錯)。 紀律:不在 .h 宣告的函式一律 static。 - include guard 防什麼? 同一個
.h被(直接或間接)引入兩次時, 型別定義會重複而編譯錯誤。#ifndef/#define/#endif三件套讓第二次引入變成空操作。 - 驗收設計:incr 3 次 → reset → incr 2 次,
此時
count = 2、total_incr = 5——證明兩個狀態各自獨立運作。
參考解答
/*
* ex5_module_sim.c
* ----------------
* 練習 5:單檔模擬多檔模組 —— static 封裝 + 公開介面(難度:進階)
*
* 本檔在「一個檔案內」示範 counter 模組的封裝結構;
* 檔尾註解說明如何拆成 counter.h / counter.c / main.c 三個檔案。
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_module_sim.c
* 執行:/tmp/out
*/
#include <stdio.h>
/* ====================================================================
* 「counter 模組」的實作部分(拆檔時放進 counter.c)
* 狀態全部 static:其他檔案無法直接讀寫,只能走公開介面。
* ==================================================================== */
static int count = 0; /* 檔案私有:目前計數 */
static int total_incr = 0; /* 檔案私有:歷史累計遞增次數 */
/* 內部小工具也可以 static:連函式名都不對外曝光 */
static int clamp_nonneg(int v)
{
return (v < 0) ? 0 : v;
}
/* ---- 以下是公開介面(拆檔時原型放進 counter.h)---- */
void counter_incr(void)
{
count++;
total_incr++;
}
void counter_reset(void)
{
count = 0; /* 注意:total_incr 保留——重設不抹歷史 */
}
int counter_get(void)
{
return clamp_nonneg(count);
}
int counter_total_incr(void)
{
return total_incr;
}
/* ====================================================================
* 使用端(拆檔時放進 main.c,只 #include "counter.h")
* ==================================================================== */
int main(void)
{
for (int i = 0; i < 3; i++)
counter_incr();
printf("遞增 3 次後:count = %d\n", counter_get());
counter_reset();
printf("重設後 :count = %d\n", counter_get());
counter_incr();
counter_incr();
printf("再遞增 2 次 :count = %d\n", counter_get());
printf("歷史總遞增 :%d 次(reset 不影響)\n", counter_total_incr());
/* 封裝的重點:main 無法寫出 count = 999;(若拆成兩檔,
* 連結器根本找不到 static 的 count 符號)。 */
return 0;
}
/* ====================================================================
* 拆成三個檔案的做法:
*
* counter.h(介面 + include guard)
* ---------------------------------
* #ifndef COUNTER_H
* #define COUNTER_H
* void counter_incr(void);
* void counter_reset(void);
* int counter_get(void);
* int counter_total_incr(void);
* #endif
*
* counter.c(實作)
* -----------------
* #include "counter.h" // 讓編譯器核對原型
* static int count = 0; // 私有狀態不進 .h!
* static int total_incr = 0;
* ...(四個公開函式的定義)...
*
* main.c(使用端)
* ----------------
* #include <stdio.h>
* #include "counter.h" // 只需要介面
* int main(void) { ... }
*
* 編譯連結:cc -std=c11 -Wall -Wextra -o calc main.c counter.c
* ==================================================================== */
程式碼講解
- 檔案的版面順序本身就在教學:實作區(static 狀態 + 介面函式)在上、 使用端在下、拆檔說明在尾——拆檔時就是沿著這三段剪開。
counter.c要#include "counter.h"自己的介面:讓編譯器核對 「實作的簽名」與「介面承諾的簽名」一致——少了這行,兩邊不同步只有連結時 (甚至執行時)才爆炸。- 私有狀態絕不放進
.h。曾見過的錯誤:把static int count;放.h—— 每個 include 它的.c都會得到自己的一份 count,各改各的,bug 極難追。 - 延伸實驗(對應 README 練習 6):拆檔後在
main.c加上extern int count; count = 999;,連結會報 「Undefined symbols: _count」——static 讓符號從目的檔的公開符號表消失, 這就是「編譯器保證的封裝」。
範例輸出
遞增 3 次後:count = 3
重設後 :count = 0
再遞增 2 次 :count = 2
歷史總遞增 :5 次(reset 不影響)
練習 6:strtok 風格的切詞器 —— static 內部狀態(難度:挑戰)
題目
仿照 strtok 的介面寫 char *my_strtok(char *s, char sep):
- 第一次呼叫傳入字串,之後傳
NULL繼續切同一條字串; - 用 static 區域變數記住「上次切到哪」;
- 連續的分隔字元視為一個(跳過空 token);
- 會把分隔字元改寫成
'\0'(破壞性,與 strtok 相同)。
切一條 CSV 與一條路徑驗證,並在講解中說明這種設計的代價(不可重入)。
解題思路
- strtok 介面的核心謎題:第二次以後呼叫傳
NULL,函式怎麼知道要繼續切哪裡? 答案就是本單元的主角——static 區域變數:static char *next;在兩次呼叫之間保存「下次的起點」。 - 每次呼叫的流程:
- 傳入非 NULL → 新字串,重設
next = s; -next為 NULL 或指到結尾 → 沒東西了,回 NULL; - 跳過開頭連續的分隔字元(這樣",,"不會產生空 token); - 記下 token 起點,前進到下一個分隔字元或'\0'; - 若停在分隔字元:改寫成'\0'(讓回傳的 token 是合法字串)並把next移到它後面;若停在字串尾:next = NULL(下次回 NULL)。
"alice,30,taipei"
^tok1 ^ 改寫 ',' 成 '\0' 後回傳 "alice",next 指向 '3'
下次呼叫(NULL)從 next 繼續……
- 為什麼說這是「利與弊」的展示?
- 利:呼叫端超簡單——不用自己維護游標,for 迴圈兩行切完;
- 弊:狀態只有一份(static)→ 不能交錯切兩條字串、
不可重入、多執行緒直接資料競爭。這就是 strtok 惡名昭彰的原因,
也是 POSIX 提供
strtok_r(把狀態改成呼叫端傳入的參數)的動機。 - 破壞性的提醒:傳入的必須是可修改陣列(
char csv[] = ...), 字面值指標會在改寫分隔字元時 crash——第 04 單元的教訓再現。
參考解答
/*
* ex6_tokenizer.c
* ---------------
* 練習 6:strtok 風格的切詞器 —— static 內部狀態的利與弊(難度:挑戰)
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_tokenizer.c
* 執行:/tmp/out
*/
#include <stdio.h>
#include <stddef.h>
/*
* 仿 strtok 的介面:
* 第一次呼叫傳字串,之後傳 NULL 繼續切同一條字串。
* 「記得上次切到哪」靠 static 區域變數 next——
* 這正是 static「跨呼叫保值」的實戰用法,
* 也同時繼承了 strtok 的缺陷:不可重入、不能交錯切兩條字串。
*/
char *my_strtok(char *s, char sep)
{
static char *next = NULL; /* 跨呼叫記住「下次從哪裡繼續」 */
if (s != NULL) /* 新字串:重設進度 */
next = s;
if (next == NULL || *next == '\0')
return NULL; /* 沒東西可切了 */
/* 跳過開頭連續的分隔字元 */
while (*next == sep)
next++;
if (*next == '\0') {
next = NULL;
return NULL;
}
char *start = next; /* token 從這裡開始 */
/* 前進到下一個分隔字元或字串結尾 */
while (*next != '\0' && *next != sep)
next++;
if (*next != '\0') {
*next = '\0'; /* 把分隔字元改寫成結尾(破壞性!) */
next++; /* 下次從分隔字元之後繼續 */
} else {
next = NULL; /* 到字串尾:下次回 NULL */
}
return start;
}
int main(void)
{
/* 必須用可修改的陣列:my_strtok 會把分隔符改寫成 '\0' */
char csv[] = "alice,30,taipei,,engineer";
printf("逐一切出(分隔符 ','):\n");
int i = 1;
for (char *tok = my_strtok(csv, ','); tok != NULL;
tok = my_strtok(NULL, ',')) {
printf(" token %d:\"%s\"\n", i++, tok);
}
/* static 狀態的證據:切完後再呼叫仍回 NULL(狀態留在函式裡) */
printf("切完後再呼叫:%s\n",
my_strtok(NULL, ',') == NULL ? "NULL(狀態記得已切完)" : "??");
/* 換一條新字串:傳非 NULL 就重設進度 */
char path[] = "/usr/local/bin";
printf("\n切路徑(分隔符 '/'):\n");
for (char *tok = my_strtok(path, '/'); tok != NULL;
tok = my_strtok(NULL, '/')) {
printf(" \"%s\"\n", tok);
}
return 0;
}
程式碼講解
static char *next = NULL;:整支函式的「記憶」。它讓介面得以極簡 (第二個引數只是分隔字元),代價是全程式只有一條切割進度。",,"的行為:跳過連續分隔字元 →"taipei"之後直接切出"engineer", 沒有空 token——與標準 strtok 一致(想保留空欄位的 CSV 解析器需要不同設計)。- 兩條測試字串的切換靠「傳非 NULL 就重設」——但如果在切 CSV 切到一半時 開始切 path,CSV 的進度就永遠遺失了。試著把兩個迴圈交錯展開, 親身體會「不可重入」是什麼感覺。
- 修好它的方向(strtok_r 的做法):把
next從 static 搬成參數char **saveptr,狀態由呼叫端保管——每條字串一個 saveptr,互不干擾。 對照練習 3 of 08(itoa 用參數傳索引),是同一個「把狀態外部化」的思想。
範例輸出
逐一切出(分隔符 ','):
token 1:"alice"
token 2:"30"
token 3:"taipei"
token 4:"engineer"
切完後再呼叫:NULL(狀態記得已切完)
切路徑(分隔符 '/'):
"usr"
"local"
"bin"
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正確做法 |
|---|---|---|
| 用普通區域變數想跨呼叫保值 | 每次呼叫重新初始化,狀態全失 | static 區域變數 |
以為 static int id = 0; 每次呼叫都執行 |
誤判程式邏輯 | static 初始化只在程式啟動時做一次 |
迴圈內多打一個 int 重新宣告變數 |
遮蔽外層,安靜地算錯 | 開 -Wshadow;刪掉多餘的宣告 |
| LCG 的 seed 用有號 int | 有號溢位是未定義行為 | 用 unsigned(溢位定義為環繞) |
| RPN 運算子 pop 順序寫反 | 減法/除法結果錯(加法乘法測不出) | 先 pop 的是右運算元 |
私有狀態或 static 變數宣告放進 .h |
每個 .c 各一份,狀態分裂 | .h 只放原型;狀態留在 .c 並加 static |
| 內部輔助函式忘了加 static | 全域符號撞名,連結錯誤 | 不進 .h 的函式一律 static |