本檔收錄 5 題由易到難的練習。建議流程:先蓋住解答自己寫,卡住再看 「解題思路」,寫完對照「參考解答」。每題完整程式碼在 exercises/:

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

所有解答都採「雙模式」設計:不帶引數執行時用內建的模擬 argv 測資 展示結果;帶引數時則解析真正的命令列引數——兩條路共用同一套解析 邏輯,這正是把核心邏輯抽成函式的理由。


練習 1:完整傾印 argc / argv(難度:基礎)

題目

寫一個 args_dump 程式,逐行印出 argc、每個 argv[i] 的索引、 內容與長度,並驗證 argv[argc] == NULL 的標準保證。 模擬測資需包含:程式名稱、一般引數、含空白的引數(模擬 shell 引號)、 長得像選項的引數、數字引數。

執行範例:

argc = 5
  argv[0] = "args_dump"  (len=9)
  argv[1] = "hello"  (len=5)
  ...

解題思路

  1. argc/argv 的契約:argc 是引數個數(含程式名稱), argv[0] 是程式名稱,argv[1..argc-1] 是引數, 且 C 標準(5.1.2.2.1)保證 argv[argc] == NULL。
  2. 雙模式架構:把傾印邏輯抽成 dump_args(int, char *[]), main 只負責決定餵真引數還是模擬引數。模擬 argv 就是一個 char * 陣列,最後一格要親手放 NULL 才符合標準佈局。
  3. 「含空白的引數」在測什麼:shell 分詞發生在程式啟動前, "two words" 經過引號保護後是一個引數——程式看到的 argv 已經分好詞,裡面可以有任何字元。
  4. 邊界情況:引數長度用 strlen 印出,證明引數是普通的 C 字串,沒有長度上限以外的特殊結構。

參考解答

/*
 * ex1_args_dump.c — 練習 1:完整傾印 argc / argv
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_args_dump.c
 * 執行:/tmp/out                    (用內建模擬引數示範)
 *       /tmp/out foo "bar baz" -x   (也支援真的命令列引數)
 *
 * 逐行印出 argc、每個 argv[i] 的索引、內容與長度,
 * 並驗證 argv[argc] == NULL 的標準保證。
 */
#include <stdio.h>
#include <string.h>

/* 核心邏輯獨立成函式:真實與模擬 argv 共用同一份程式碼 */
static void dump_args(int argc, char *argv[])
{
    printf("argc = %d\n", argc);
    for (int i = 0; i < argc; i++)
        printf("  argv[%d] = \"%s\"  (len=%zu)\n",
               i, argv[i], strlen(argv[i]));

    /* C 標準保證 argv[argc] 是 NULL——很多走訪寫法依賴這一點 */
    printf("  argv[argc] %s NULL\n",
           argv[argc] == NULL ? "==" : "!=");
}

int main(int argc, char *argv[])
{
    if (argc > 1) {                     /* 真的從命令列拿到引數 */
        printf("=== real command-line arguments ===\n");
        dump_args(argc, argv);
        return 0;
    }

    /* 沒帶引數:用寫死的模擬 argv 示範(模仿 shell 會傳的樣子) */
    printf("=== simulated argv (run with args to use real ones) ===\n");
    char *fake_argv[] = {
        "args_dump",            /* argv[0]:程式名稱 */
        "hello",
        "two words",            /* shell 引號包起來的引數:含空白 */
        "-v",
        "42",
        NULL,                   /* argv[argc] 必為 NULL */
    };
    int fake_argc = 5;

    dump_args(fake_argc, fake_argv);
    return 0;
}

程式碼講解

  • dump_args 對真引數與假引數一視同仁——它只依賴 argc/argv 的 契約,不在乎資料來源。這種「注入測資」的結構讓命令列程式 變得可測試,是本單元所有解答的共同骨架。
  • 模擬 argv 的最後一格 NULL 不是裝飾:argv[argc] == NULL 的驗證行讀的就是它,少放會印出 !=(或未定義行為)。
  • -v 放在模擬測資裡提醒你:對「不解析選項」的程式而言, -v 就只是一個普通字串,選項語意是解析器賦予的。
  • 易錯處:把 fake_argc 寫成 6(把 NULL 也算進去)。 argc 計數不含結尾的 NULL。

範例輸出

=== simulated argv (run with args to use real ones) ===
argc = 5
  argv[0] = "args_dump"  (len=9)
  argv[1] = "hello"  (len=5)
  argv[2] = "two words"  (len=9)
  argv[3] = "-v"  (len=2)
  argv[4] = "42"  (len=2)
  argv[argc] == NULL

練習 2:引數整數加總(難度:基礎)

題目

實作 sum 程式:把所有引數當十進位整數相加。要求用 strtol 做嚴格驗證——遇到非數字引數(如 abc、12x)時印警告到 stderr 並跳過,不中斷加總。負數要能正確處理。

模擬測資:10 20 abc -5 12x 30,預期 sum = 55。

解題思路

  1. 為什麼不用 atoi:atoi("abc") 回 0、atoi("12x") 回 12, 無法區分「錯誤」與「真的是 0/12」,也不偵測溢位。 strtol 提供完整的錯誤回報管道。
  2. strtol 的三件套檢查(背下來,處處用得到): - end == s:一個字元都沒消化 → 完全不是數字; - *end != '\0':消化了一部分就停 → 有非法尾巴("12x"); - errno == ERANGE:值超出 long 範圍 → 溢位。 呼叫前要先 errno = 0,因為 strtol 成功時不會清除 errno。
  3. 訊息流向:加總結果走 stdout(資料),警告走 stderr(診斷)。 這樣 ./sum ... | tail -1 之類的管線不會被警告汙染。
  4. 邊界情況:-5 是合法數字(strtol 處理正負號); 從 argv[1] 開始掃(跳過程式名稱)。

參考解答

/*
 * ex2_sum_args.c — 練習 2:把引數當整數相加(strtol 嚴格驗證)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_sum_args.c
 * 執行:/tmp/out                (用內建模擬引數示範)
 *       /tmp/out 10 20 abc 30   (也支援真的命令列引數)
 *
 * 遇到非數字引數:印警告到 stderr 並跳過,不中斷加總。
 */
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>

/*
 * strtol 的完整檢查三件套:
 *   1. end == s        -> 完全不是數字("abc")
 *   2. *end != '\0'    -> 有尾巴("12x")
 *   3. errno == ERANGE -> 溢位("99999999999999999999")
 */
static int parse_int(const char *s, long *out)
{
    char *end;

    errno = 0;
    long v = strtol(s, &end, 10);

    if (end == s || *end != '\0')
        return 0;                       /* 不是完整的十進位數字 */
    if (errno == ERANGE)
        return 0;                       /* 超出 long 範圍 */
    *out = v;
    return 1;
}

static long sum_args(int argc, char *argv[])
{
    long sum = 0;

    for (int i = 1; i < argc; i++) {    /* 從 1 開始:跳過程式名稱 */
        long v;
        if (parse_int(argv[i], &v)) {
            printf("  + %ld\n", v);
            sum += v;
        } else {
            /* 診斷訊息一律走 stderr,別汙染 stdout 的資料流。
             * 先沖掉 stdout 緩衝,兩條流的輸出順序才不會錯位。 */
            fflush(stdout);
            fprintf(stderr, "  warning: '%s' is not a number, skipped\n",
                    argv[i]);
        }
    }
    return sum;
}

int main(int argc, char *argv[])
{
    if (argc > 1) {
        printf("=== summing real arguments ===\n");
        printf("sum = %ld\n", sum_args(argc, argv));
        return 0;
    }

    printf("=== simulated: sum 10 20 abc -5 12x 30 ===\n");
    char *fake_argv[] = {
        "sum", "10", "20", "abc", "-5", "12x", "30", NULL,
    };
    printf("sum = %ld\n", sum_args(7, fake_argv));
    return 0;
}

程式碼講解

  • parse_int 把 strtol 的三件套包成「回傳成功與否 + 輸出參數」 的介面,呼叫端一行 if 就能用——驗證邏輯只寫一次。
  • errno = 0; 在 strtol 前面那行不能省:errno 是「黏性」的, 前一個函式留下的舊值會造成誤判。
  • fflush(stdout); 出現在寫 stderr 之前:stdout 接到管線時是 全緩衝、stderr 永遠不緩衝,不沖掉的話兩條流的訊息會錯位。
  • 易錯處:檢查順序上 *end != '\0' 必須在 end == s 之後想—— "abc" 兩個條件都中,但 ""(空字串)只有 end == s 中; 合併成一個 || 條件最穩。

範例輸出

=== simulated: sum 10 20 abc -5 12x 30 ===
  + 10
  + 20
  warning: 'abc' is not a number, skipped
  + -5
  warning: '12x' is not a number, skipped
  + 30
sum = 55

練習 3:minigrep 加上 -c 選項(難度:進階)

題目

為 mini grep 加上 -c 選項:只印出匹配行數、不印內容 (模仿 grep -c)。要求:

  • 用法 grep [-c] pattern file,引數錯誤回離開碼 2;
  • 模仿 grep 慣例:有匹配回 0、無匹配回 1;
  • 測試檔由程式自建於 /tmp/,結束時刪除;
  • 模擬測資跑兩次:一次不帶 -c、一次帶 -c。

解題思路

  1. 拆成兩層:minigrep(pattern, fname, count_only) 做事、 run(argc, argv) 解析引數。-c 只是把「印行」換成「數行」, 核心迴圈完全共用,差別集中在兩個 if。
  2. 匹配怎麼做:K&R 第 4 章手寫了 strindex;現代寫法直接用 strstr(line, pattern)——回傳非 NULL 即該行含 pattern。
  3. 計數與離開碼:讓 minigrep 回傳匹配行數,run 據此決定 離開碼(>0 → 0、==0 → 1)。把「事實」與「政策」分開: 函式回報事實(幾行匹配),呼叫端決定政策(離開碼)。
  4. 測試檔生命週期:make_test_file() 寫死五行文字 → 跑兩組模擬 argv → remove(TESTFILE) 清理。 測資設計成恰好 3 行含 the,讓 -c 的輸出(3)可預期。
  5. 邊界情況:開檔失敗回 -1 並印錯誤到 stderr; 行緩衝區 512 對測試檔綽綽有餘(真 grep 要處理任意長行)。

參考解答

/*
 * ex3_grep_count.c — 練習 3:minigrep 加上 -c(只印匹配行數)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_grep_count.c
 * 執行:/tmp/out                    (內建模擬引數,含 -c 與不含 -c 各跑一次)
 *       /tmp/out -c pattern file    (也支援真的命令列引數)
 *
 * 測試檔由程式自建於 /tmp/,結束時刪除。
 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define TESTFILE "/tmp/ex3_grep_input.txt"

/*
 * mini grep 核心:印出(或只計數)含 pattern 的行。
 * count_only 非零時模仿 grep -c:只輸出匹配行數。
 * 回傳匹配行數(呼叫端可據此決定離開碼,grep 慣例:無匹配回 1)。
 */
static int minigrep(const char *pattern, const char *fname, int count_only)
{
    FILE *fp = fopen(fname, "r");
    if (fp == NULL) {
        fprintf(stderr, "grep: cannot open %s\n", fname);
        return -1;
    }

    char line[512];
    int matches = 0;

    while (fgets(line, sizeof line, fp) != NULL) {
        if (strstr(line, pattern) != NULL) {   /* 子字串比對 */
            matches++;
            if (!count_only)
                fputs(line, stdout);           /* line 已含換行 */
        }
    }
    fclose(fp);

    if (count_only)
        printf("%d\n", matches);
    return matches;
}

/* 解析引數:支援 -c 選項,其後接 pattern 與檔名 */
static int run(int argc, char *argv[])
{
    int count_only = 0;
    int i = 1;

    if (i < argc && strcmp(argv[i], "-c") == 0) {
        count_only = 1;
        i++;
    }
    if (argc - i != 2) {
        fprintf(stderr, "usage: %s [-c] pattern file\n", argv[0]);
        return 2;                              /* 用法錯誤慣例:離開碼 2 */
    }

    int n = minigrep(argv[i], argv[i + 1], count_only);
    return n > 0 ? 0 : 1;                      /* grep 慣例:無匹配回 1 */
}

/* 建立寫死內容的測試檔 */
static void make_test_file(void)
{
    FILE *fp = fopen(TESTFILE, "w");
    if (fp == NULL) {
        perror("fopen");
        exit(1);
    }
    fputs("the quick brown fox\n"
          "jumps over the lazy dog\n"
          "pack my box with five dozen jugs\n"
          "the rain in spain\n"
          "hello world\n", fp);
    fclose(fp);
}

int main(int argc, char *argv[])
{
    if (argc > 1)                       /* 真實引數模式:檔案由使用者給 */
        return run(argc, argv);

    /* 模擬模式:自建測試檔,示範兩種用法 */
    make_test_file();

    printf("=== simulated: grep the %s ===\n", TESTFILE);
    char *fake1[] = { "grep", "the", TESTFILE, NULL };
    int rc1 = run(3, fake1);
    printf("(exit status would be %d)\n", rc1);

    printf("\n=== simulated: grep -c the %s ===\n", TESTFILE);
    char *fake2[] = { "grep", "-c", "the", TESTFILE, NULL };
    int rc2 = run(4, fake2);
    printf("(exit status would be %d)\n", rc2);

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

程式碼講解

  • count_only 只影響兩處:匹配時不印行、結尾多印總數。 選項的實作成本與它對程式結構的侵入度成正比——好的分層讓 加選項變成小改動。
  • fputs(line, stdout) 而非 printf("%s", line):line 是資料 不是格式字串;若用 printf(line) 而行內剛好有 %,輕則亂印 重則當機(格式字串漏洞)。
  • 離開碼三態(0 有匹配/1 無匹配/2 用法錯誤)完全照真 grep 的 慣例——寫工具時遵循既有慣例,使用者與腳本才「猜得到」行為。
  • 匹配行數 jumps over the lazy dog 也算:strstr 找的是 子字串,the 出現在行中任何位置都中。
  • 易錯處:忘記 remove(TESTFILE),/tmp 會累積垃圾檔; 或在 run 提前 return 的路徑忘了 fclose(本解答的 fclose 在 minigrep 內,所有路徑都會經過)。

範例輸出

=== simulated: grep the /tmp/ex3_grep_input.txt ===
the quick brown fox
jumps over the lazy dog
the rain in spain
(exit status would be 0)

=== simulated: grep -c the /tmp/ex3_grep_input.txt ===
3
(exit status would be 0)

練習 4:getopt 版 head(難度:進階)

題目

用 POSIX 的 getopt 寫一個 head 複製品:支援 -n N(預設 10), 從自建的 /tmp 測試檔(12 行)讀取並印出前 N 行。要求:

  • -n 的值用 strtol 嚴格驗證(拒絕 -n abc、負數);
  • 非法選項或缺檔名時印 usage 並回離開碼 2;
  • 模擬測資示範 head -n 3 檔案。

解題思路

  1. getopt 的心智模型:它是一台小狀態機,每呼叫一次吐出一個 選項字元;optstring "n:" 的冒號宣告「-n 帶值」,值放進全域 optarg;掃完所有選項回 -1,此時 optind 指向第一個非選項 引數(檔名)。
  2. getopt 免費送你的東西:-n 5 與 -n5 兩種寫法、 選項合併(-ab = -a -b)、非法選項自動印錯誤訊息並回 '?'。 手寫解析器要自己處理這些——這是練習 5 的主題。
  3. 值的驗證是你的事:getopt 只把字串塞進 optarg, -n abc 的 abc 要自己用 strtol 三件套驗證(複習練習 2)。
  4. head 主體:while (printed < n && fgets(...))—— 兩個停止條件(讀滿 N 行、檔案讀完)自然合成一個條件式; 檔案不足 N 行時印到檔尾為止,不是錯誤。
  5. 邊界情況:-n 0 合法(印 0 行);n < 0 拒絕; 缺檔名(optind >= argc)回用法錯誤。

參考解答

/*
 * ex4_head_getopt.c — 練習 4:用 getopt 實作 head 複製品(-n N,預設 10)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_head_getopt.c
 * 執行:/tmp/out              (內建模擬引數:-n 3 讀自建測試檔)
 *       /tmp/out -n 5 file    (也支援真的命令列引數)
 *
 * getopt 是 POSIX 函式(unistd.h),處理:選項合併、-n5 與 -n 5
 * 兩種寫法、非法選項的錯誤訊息。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>     /* getopt, optarg, optind */
#include <errno.h>

#define TESTFILE "/tmp/ex4_head_input.txt"
#define DEFAULT_N 10

/* 印出檔案前 n 行 */
static int head(const char *fname, long n)
{
    FILE *fp = fopen(fname, "r");
    if (fp == NULL) {
        fprintf(stderr, "head: cannot open %s: %s\n",
                fname, strerror(errno));
        return 1;
    }

    char line[512];
    long printed = 0;

    while (printed < n && fgets(line, sizeof line, fp) != NULL) {
        fputs(line, stdout);
        printed++;
    }
    fclose(fp);
    return 0;
}

/*
 * 引數解析 + 執行。getopt 用法重點:
 *   - "n:" 的冒號表示 -n 需要一個值(getopt 會放進 optarg)
 *   - 回傳 '?' 表示非法選項或缺值
 *   - 解析結束後 optind 指向第一個「非選項」引數
 */
static int run(int argc, char *argv[])
{
    long n = DEFAULT_N;
    int opt;

    while ((opt = getopt(argc, argv, "n:")) != -1) {
        switch (opt) {
        case 'n': {
            char *end;
            errno = 0;
            n = strtol(optarg, &end, 10);
            if (end == optarg || *end != '\0' || errno == ERANGE || n < 0) {
                fprintf(stderr, "head: invalid line count '%s'\n", optarg);
                return 2;
            }
            break;
        }
        default:                        /* getopt 已印過錯誤訊息 */
            fprintf(stderr, "usage: %s [-n N] file\n", argv[0]);
            return 2;
        }
    }

    if (optind >= argc) {               /* 沒給檔名 */
        fprintf(stderr, "usage: %s [-n N] file\n", argv[0]);
        return 2;
    }
    return head(argv[optind], n);
}

static void make_test_file(void)
{
    FILE *fp = fopen(TESTFILE, "w");
    if (fp == NULL) {
        perror("fopen");
        exit(1);
    }
    for (int i = 1; i <= 12; i++)
        fprintf(fp, "line %02d of the test file\n", i);
    fclose(fp);
}

int main(int argc, char *argv[])
{
    if (argc > 1)                       /* 真實引數模式 */
        return run(argc, argv);

    /* 模擬模式:-n 3 讀自建的 12 行測試檔 */
    make_test_file();

    printf("=== simulated: head -n 3 %s ===\n", TESTFILE);
    char *fake[] = { "head", "-n", "3", TESTFILE, NULL };
    int rc = run(4, fake);

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

程式碼講解

  • while ((opt = getopt(...)) != -1) 加 switch 是 getopt 的 標準骨架,幾乎每個 Unix 工具的 main 都長這樣。
  • case 'n' 裡的大括號讓我們能在 case 內宣告 char *end (C 的 case 標籤後不能直接接宣告,包個區塊即可)。
  • default: 同時接住 '?'(非法選項)與其他意外值;getopt 自己 會往 stderr 印 illegal option,我們只補 usage。
  • 注意 getopt 使用全域狀態(optind),同一個行程重複解析 不同 argv 需要重設 optind(macOS 還要 optreset); 本程式每次執行只解析一組,避開這個坑。
  • 易錯處:忘記檢查 optind >= argc 就取 argv[optind]—— 沒給檔名時會解參考 NULL。

範例輸出

=== simulated: head -n 3 /tmp/ex4_head_input.txt ===
line 01 of the test file
line 02 of the test file
line 03 of the test file

練習 5:手寫長選項解析器(難度:挑戰)

題目

不用 getopt,手寫支援長選項的解析器,接受:

  • --number、--invert:布林選項;
  • --file=NAME:帶值選項(= 語法,空值報錯);
  • --:之後的引數全部視為位置參數(即使長得像選項);
  • 未知選項報錯並回離開碼 2。

模擬測資需涵蓋:一般解析、-- 停止解析、未知選項被拒絕三種情境。

解題思路

  1. 先定資料結構:解析結果收進 struct options(旗標+file 值), 位置參數另外收集索引。解析器只填結構,不做業務邏輯—— 這讓它可以被三組測資重複呼叫驗證。
  2. 分類每個引數,順序很重要:
    已越過 "--"?────── 是 ──> 位置參數
         │否
    不以 '-' 開頭?──── 是 ──> 位置參數
         │否(單獨 "-" 慣例上代表 stdin,也歸位置參數)
    是 "--"?────────── 是 ──> 設旗標,本身不算引數
         │否
    逐一比對已知長選項;都不中 -> 未知選項,報錯
  3. --file=NAME 的解析:strncmp(a, "--file=", 7) 比對前綴, 值就是 a + 7——指進原字串即可,不需要複製 (argv 的生命週期涵蓋整個程式)。記得擋掉空值 --file=。
  4. -- 的語意:它是「選項區」與「位置參數區」的分界線。 rm -- -rf 能刪掉名為 -rf 的檔案,靠的就是這個機制—— 實作上只是一個布林旗標 seen_dashdash。
  5. 錯誤處理:未知選項立即返回 2(fail fast), 不要「跳過繼續」——拼錯的選項被默默忽略是最難查的 bug。

參考解答

/*
 * ex5_long_options.c — 練習 5:手寫長選項解析器
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_long_options.c
 * 執行:/tmp/out                                  (內建三組模擬引數)
 *       /tmp/out --number --file=log.txt a b      (也支援真的命令列引數)
 *
 * 支援:
 *   --number         布林選項
 *   --invert         布林選項
 *   --file=NAME      帶值選項(= 語法)
 *   --               之後全部視為位置參數(即使長得像選項)
 */
#include <stdio.h>
#include <string.h>

struct options {
    int         number;         /* --number 出現次數(當布林用) */
    int         invert;         /* --invert */
    const char *file;           /* --file=NAME 的值,未給則 NULL */
};

/*
 * 解析引數。回傳 0 成功、2 用法錯誤。
 * 位置參數的索引收集進 pos[](呼叫端提供空間),個數寫進 *npos。
 */
static int parse_args(int argc, char *argv[], struct options *opt,
                      int pos[], int *npos)
{
    int seen_dashdash = 0;      /* 是否已越過 "--" */

    memset(opt, 0, sizeof *opt);
    *npos = 0;

    for (int i = 1; i < argc; i++) {
        const char *a = argv[i];

        /* "--" 之後:一律當位置參數(處理檔名以 - 開頭的唯一安全法) */
        if (seen_dashdash || a[0] != '-' || strcmp(a, "-") == 0) {
            pos[(*npos)++] = i;
            continue;
        }
        if (strcmp(a, "--") == 0) {
            seen_dashdash = 1;
            continue;
        }

        /* 至此必為 "--xxx" 形式的長選項 */
        if (strcmp(a, "--number") == 0) {
            opt->number = 1;
        } else if (strcmp(a, "--invert") == 0) {
            opt->invert = 1;
        } else if (strncmp(a, "--file=", 7) == 0) {
            if (a[7] == '\0') {                 /* --file= 後面是空的 */
                fprintf(stderr, "error: --file requires a value\n");
                return 2;
            }
            opt->file = a + 7;                  /* 指進原字串,零複製 */
        } else if (strcmp(a, "--file") == 0) {
            fprintf(stderr, "error: use --file=NAME\n");
            return 2;
        } else {
            fprintf(stderr, "error: unknown option '%s'\n", a);
            return 2;
        }
    }
    return 0;
}

/* 印出解析結果 */
static void report(int argc, char *argv[])
{
    struct options opt;
    int pos[64];
    int npos;

    printf("command:");
    for (int i = 0; i < argc; i++)
        printf(" %s", argv[i]);
    printf("\n");
    fflush(stdout);     /* 讓 stderr 的錯誤訊息不會與 stdout 錯位 */

    if (parse_args(argc, argv, &opt, pos, &npos) != 0) {
        printf("  -> parse failed (usage error)\n\n");
        return;
    }

    printf("  number = %s\n", opt.number ? "on" : "off");
    printf("  invert = %s\n", opt.invert ? "on" : "off");
    printf("  file   = %s\n", opt.file != NULL ? opt.file : "(none)");
    printf("  positional:");
    if (npos == 0)
        printf(" (none)");
    for (int i = 0; i < npos; i++)
        printf(" \"%s\"", argv[pos[i]]);
    printf("\n\n");
}

int main(int argc, char *argv[])
{
    if (argc > 1) {                     /* 真實引數模式 */
        report(argc, argv);
        return 0;
    }

    /* 模擬模式:三組代表性測資 */
    printf("=== case 1: options + value + positional ===\n");
    char *c1[] = { "tool", "--number", "--file=data.txt", "in.c", NULL };
    report(4, c1);

    printf("=== case 2: -- stops option parsing ===\n");
    /* "--invert" 在 -- 之後:必須被當成檔名而非選項 */
    char *c2[] = { "tool", "--number", "--", "--invert", "-x", NULL };
    report(5, c2);

    printf("=== case 3: unknown option is rejected ===\n");
    char *c3[] = { "tool", "--vebrose", NULL };      /* 打錯字 */
    report(2, c3);

    return 0;
}

程式碼講解

  • 分類邏輯的判斷順序是本題核心:seen_dashdash 的檢查必須 最先做,否則 -- 之後的 --invert 又會被當成選項—— case 2 測的就是這條路。
  • opt->file = a + 7; 零複製的前提是 argv 活得比 options 久 (對命令列程式恆成立)。若 options 要存活超過 argv (例如塞進背景執行緒),就必須 strdup。
  • 單獨的 "-" 被歸為位置參數:Unix 慣例它代表 stdin (如 cat -),不是選項。
  • --file 不帶 = 的形式被明確拒絕並提示正確語法——比籠統的 unknown option 更友善。要支援 --file NAME(空白分隔)也不難: 取 argv[++i] 即可,留作延伸。
  • 易錯處:用 strcmp(a, "--file=") == 0 比對帶值選項—— 永遠不會中,因為後面還有值;帶值選項要用 strncmp 比前綴。

範例輸出

=== case 1: options + value + positional ===
command: tool --number --file=data.txt in.c
  number = on
  invert = off
  file   = data.txt
  positional: "in.c"

=== case 2: -- stops option parsing ===
command: tool --number -- --invert -x
  number = on
  invert = off
  file   = (none)
  positional: "--invert" "-x"

=== case 3: unknown option is rejected ===
command: tool --vebrose
error: unknown option '--vebrose'
  -> parse failed (usage error)

常見錯誤解法對照

錯誤寫法 症狀 正解
用 atoi 解析引數 abc 變 0、12x 變 12,無法報錯 strtol 三件套(練習 2、4)
警告訊息印到 stdout 管線下游收到垃圾資料 診斷一律走 stderr(練習 2)
printf(line) 印檔案內容 行內有 % 時亂印/當機 fputs(line, stdout)(練習 3)
忘記檢查 optind >= argc 缺檔名時解參考 NULL 取位置參數前先檢查(練習 4)
帶值長選項用 strcmp 全等比對 --file=x 永遠比不中 strncmp 比前綴(練習 5)
忽略 -- 慣例 無法操作 - 開頭的檔名 seen_dashdash 旗標(練習 5)