本檔收錄本單元的練習題、完整解題思路與參考解答。 強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」; 真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 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, …)。 另寫一個用普通區域變數的版本作對照,解釋為什麼它壞掉。

解題思路

  1. 需求分析:函式要「記得上次回傳到幾」——這需要一個跨呼叫存活的變數。 放全域可以,但汙染全域命名空間且人人可改; static 區域變數正是為此而生:作用域仍限函式內(外面看不到、改不到), 生命週期卻是整個程式。
  2. 兩行搞定:
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) 重設種子。驗證「同一個種子必然重現同一串亂數」。

解題思路

  1. 偽亂數的本質:一條確定性的遞推數列,只是看起來亂。 LCG 的遞推式:seed = seed * A + C(mod 2³²)。 K&R 的經典常數:A = 1103515245、C = 12345。
  2. mod 2³² 在哪裡? 不用寫!unsigned long 的溢位在 C 中定義為環繞 (wrap around),乘加自然對 2 的冪取模——這是少數「溢位是功能」的場合 (注意:有號溢位仍是未定義行為,這招只對 unsigned 成立)。
  3. 為什麼回傳 (seed / 65536) % 32768? LCG 的低位元品質很差(低 k 位有 2^k 的短週期); /65536 先丟掉低 16 位、再取 15 位,回傳的是品質較好的高位段。
  4. 狀態的儲存:static unsigned long seed——檔案層級 static: rand_next 與 rand_seed 兩個函式都要碰它,所以不能放函式內; 放檔案層級並加 static,其他檔案就無法直接亂改 seed(封裝)。
  5. 可重現性是偽亂數的特性也是功能:模擬實驗要能重跑、除錯要能重現—— 測試「同種子同序列」就是在驗收這一點。

參考解答

/*
 * 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)。

解題思路

  1. 製造 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;
  1. 修正:刪掉那個多餘的 int,讓迴圈直接累加到外層變數。
  2. 多層遮蔽的規則:名稱查找永遠找最內層的宣告; 離開區塊後,外層變數重新「可見」。合法、但同名多層是可讀性災難—— -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 與除零防護。

解題思路

  1. 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]  <- 答案
  1. 堆疊的封裝(本單元重點):val[] 與 sp 宣告成 static 檔案層級—— main 或其他未來的檔案無法直接動 sp,只能透過 push/pop。 這正是 K&R §4.3 的設計:介面(函式)公開、狀態(變數)私有。
  2. pop 順序是陷阱:先 pop 出來的是右運算元! 5 1 2 + 4 * - 中最後的 -:pop 得 12(右)、5(左)→ 5 - 12 = -7。 順序反了會得 7,減法與除法會露餡(加法乘法可交換,測不出來)。
  3. 切 token:用 strtok(第 04 單元)以空白切割; strtok 會改寫字串,所以先把運算式複製進區域陣列。
  4. 負數 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 編譯。)

解題思路

  1. 模組 = 介面 + 隱藏的實作。C 沒有 class,但有等價的三件套: - .h(介面):只放公開函式的原型; - .c(實作):狀態變數與內部函式全部 static(檔案私有); - 使用端:只 include .h,看不到也碰不到實作細節。
  2. 本題的狀態設計故意放了兩個變數:count(可被 reset)與 total_incr(歷史統計,reset 不歸零)——展示「模組內部可以有多個 互相配合的私有變數,外界只透過介面觀察」。
  3. 內部小工具也 static:clamp_nonneg 這種輔助函式若不加 static, 會變成全域符號,可能與別的檔案的同名函式撞名(連結錯誤或更糟——靜默取錯)。 紀律:不在 .h 宣告的函式一律 static。
  4. include guard 防什麼? 同一個 .h 被(直接或間接)引入兩次時, 型別定義會重複而編譯錯誤。#ifndef/#define/#endif 三件套讓第二次引入變成空操作。
  5. 驗收設計: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 與一條路徑驗證,並在講解中說明這種設計的代價(不可重入)。

解題思路

  1. strtok 介面的核心謎題:第二次以後呼叫傳 NULL,函式怎麼知道要繼續切哪裡? 答案就是本單元的主角——static 區域變數: static char *next; 在兩次呼叫之間保存「下次的起點」。
  2. 每次呼叫的流程: - 傳入非 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 繼續……
  1. 為什麼說這是「利與弊」的展示? - 利:呼叫端超簡單——不用自己維護游標,for 迴圈兩行切完; - 弊:狀態只有一份(static)→ 不能交錯切兩條字串、 不可重入、多執行緒直接資料競爭。這就是 strtok 惡名昭彰的原因, 也是 POSIX 提供 strtok_r(把狀態改成呼叫端傳入的參數)的動機。
  2. 破壞性的提醒:傳入的必須是可修改陣列(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