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

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

練習 1:count_words —— 計算單字數(難度:基礎)

題目

寫函式 int count_words(const char *s):用 isspace 計算字串中的單字數 (單字 = 連續的非空白字元)。 測試至少涵蓋:一般句子、前後與中間有多餘空白、單一單字、空字串、全空白字串。

範例:count_words(" leading and trailing ") 應回傳 3。

解題思路

  1. 先定義清楚「一個單字」:一段連續的非空白字元。 數單字不能數「空白的個數 + 1」——連續空白、開頭結尾的空白都會讓這招失效。
  2. 正確的觀察:每個單字恰好有一個「起點」—— 也就是「前一格是空白(或字串開頭)、這一格不是空白」的轉折點。 數轉折點就等於數單字。
  3. 用狀態機實作(這是 K&R §1.6 字數統計 wc 的核心想法): - 狀態變數 in_word:目前是否走在單字裡面; - 掃描每個字元:
    • 是空白 → in_word = 0(離開單字);
    • 不是空白且 in_word == 0 → 新單字開始!count++,並設 in_word = 1;
    • 不是空白且已在單字內 → 什麼都不做。
  "  hi   you "
    ^ ^    ^
    | hi 的起點(+1)
    |      you 的起點(+1)    -> count = 2
  1. ctype 的安全慣例:isspace 的引數必須是 unsigned char 值或 EOF。 char 可能是有號的,遇到值為負的位元組(如 UTF-8 中文位元組)直接傳入是未定義行為, 所以一律寫 isspace((unsigned char)*s)。

參考解答

/*
 * ex1_count_words.c
 * -----------------
 * 練習 1:count_words —— 用 isspace 計算字串中的單字數(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_count_words.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <ctype.h>

/*
 * 單字 = 連續的非空白字元。
 * 狀態機想法(K&R §1.6 wc 的縮影):
 * 記住「前一個字元是否在單字內」,遇到「空白 -> 非空白」的轉折就 +1。
 */
int count_words(const char *s)
{
    int count = 0;
    int in_word = 0;    /* 0:目前在空白區;1:目前在單字內 */

    for (; *s != '\0'; s++) {
        if (isspace((unsigned char)*s)) {
            in_word = 0;               /* 離開單字 */
        } else if (!in_word) {
            in_word = 1;               /* 空白 -> 非空白:新單字開始 */
            count++;
        }
    }
    return count;
}

int main(void)
{
    const char *tests[] = {
        "hello world",                  /* 2 */
        "  leading and   trailing  ",   /* 3 */
        "one",                          /* 1 */
        "",                             /* 0 */
        "   \t \n  ",                   /* 0:全空白 */
        "a b c d e",                    /* 5 */
    };
    int n = (int)(sizeof tests / sizeof tests[0]);

    for (int i = 0; i < n; i++)
        printf("count_words(\"%s\") = %d\n", tests[i], count_words(tests[i]));

    return 0;
}

程式碼講解

  • 狀態機只有兩個狀態(in_word 0/1),卻正確處理了所有空白怪象: 多重空白只觸發一次「離開」、開頭空白不會誤加、結尾空白無影響。
  • isspace 涵蓋空格、\t、\n、\v、\f、\r——比手寫 *s == ' ' 完整得多。
  • 空字串:迴圈不執行,回 0;全空白:永遠走不到「轉折」分支,回 0。邊界自動正確。
  • 易錯處一:忘記 (unsigned char) 轉型——ASCII 純英文測資看不出問題, 遇到非 ASCII 位元組就可能出事,屬於「測試蓋不到的未定義行為」。
  • 易錯處二:想在「單字結束」時計數——字串結尾的單字後面沒有空白,會漏數最後一個。 在起點計數就沒有這個問題。

範例輸出

count_words("hello world") = 2
count_words("  leading and   trailing  ") = 3
count_words("one") = 1
count_words("") = 0
count_words("        
  ") = 0
count_words("a b c d e") = 5

(第五行的測試字串含 tab 與換行,印出來會看到跳格與斷行,屬正常現象。)


練習 2:to_title_case —— 標題化大小寫(難度:基礎)

題目

寫函式 void to_title_case(char *s):就地修改字串, 讓每個單字的首字母大寫、其餘字母一律小寫(用 <ctype.h>)。 非字母字元保持原樣。例如 "tHE c PROGRAMMING lANGUAGE" → "The C Programming Language"。

解題思路

  1. 沿用練習 1 的狀態機:判斷「單字的第一個字元」的邏輯完全相同—— 差別只在動作從「計數」變成「改字」: - 單字的第一個字元 → toupper; - 單字的其餘字元 → tolower; - 空白 → 重設狀態。
  2. toupper/tolower 的貼心設計:非字母的字元原樣回傳, 所以 "123abc" 的 '1' 丟給 toupper 不會出錯——狀態機不需要特別處理數字開頭的「單字」。
  3. 回傳值轉型:toupper 回傳 int,指派回 char 加明確轉型 (char), 在 -Wextra 下才乾淨。
  4. 測試資料必須用陣列:char t1[] = "hello world"; 把字面值複製進可修改的陣列。 寫成 char *t1 = "hello world"; 再就地修改是未定義行為(macOS 直接 crash)—— 這正是上上單元「char * vs char []」的重點。

參考解答

/*
 * ex2_title_case.c
 * ----------------
 * 練習 2:to_title_case —— 每個單字首字母大寫、其餘小寫(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_title_case.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <ctype.h>

/*
 * 就地修改字串:單字的第一個字母 -> 大寫,其餘字母 -> 小寫。
 * 和 count_words 一樣用「是否在單字內」的狀態機,
 * 差別在這次要「改字」而不是「數數」。
 */
void to_title_case(char *s)
{
    int in_word = 0;

    for (; *s != '\0'; s++) {
        if (isspace((unsigned char)*s)) {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            *s = (char)toupper((unsigned char)*s);   /* 單字第一個字元 */
        } else {
            *s = (char)tolower((unsigned char)*s);   /* 單字其餘字元 */
        }
    }
}

int main(void)
{
    /* 必須用可修改的陣列(字串字面值不可就地修改) */
    char t1[] = "hello world";
    char t2[] = "tHE c PROGRAMMING lANGUAGE";
    char t3[] = "  multiple   spaces  kept  ";
    char t4[] = "123abc mixed42cases";

    to_title_case(t1);
    to_title_case(t2);
    to_title_case(t3);
    to_title_case(t4);

    printf("\"%s\"\n", t1);
    printf("\"%s\"\n", t2);
    printf("\"%s\"\n", t3);
    printf("\"%s\"\n", t4);

    return 0;
}

程式碼講解

  • 參數是 char *s(不加 const):這個函式的本職就是修改字串。 函式簽名的 const 與否傳達了「會不會動你的資料」的契約。
  • "tHE c PROGRAMMING lANGUAGE" 的測試涵蓋「全大寫要壓小寫」與「單一字母的單字」; "123abc" 展示 toupper('1') == '1' 的原樣回傳特性——首「字元」是數字時單字照樣成立, 後面的 a 因為已在單字內所以壓成小寫。
  • 空白的數量與位置完全不動——狀態機只在乎「是否空白」,不重排版面。
  • 易錯處:對字面值指標呼叫本函式。編譯器不會攔(參數型別就是 char *), 執行期才爆炸。養成習慣:要修改的字串一律存在 char buf[]。

範例輸出

"Hello World"
"The C Programming Language"
"  Multiple   Spaces  Kept  "
"123abc Mixed42cases"

練習 3:starts_with —— 判斷字串開頭(難度:基礎)

題目

用 strncmp 實作 int starts_with(const char *s, const char *prefix): s 以 prefix 開頭回傳 1,否則回傳 0。 邊界情況:prefix 比 s 長、兩者完全相等、空 prefix、空 s。 另外示範一個實際應用:從檔名清單中過濾出以 "img_" 開頭的檔案。

解題思路

  1. 把題目翻譯成庫函式的語言:「s 的前 n 個字元 == prefix 的前 n 個字元」, 其中 n = strlen(prefix)。這正是 strncmp(s, prefix, n) == 0。
  2. 一行搞定:return strncmp(s, prefix, strlen(prefix)) == 0;
  3. 為什麼邊界都自動正確? - prefix 比 s 長:比到 s 的 '\0' 時兩邊不等('\0' vs 其他字元),回 0 ✓ - 完全相等:n 個字元全同,回 1 ✓ - 空 prefix:n = 0,strncmp 規定比 0 個字元一律回傳 0(相等),所以回 1 ✓ (「空字串是任何字串的前綴」在數學上也是合理定義) - 空 s、非空 prefix:第一個字元就是 '\0' vs 非 '\0',回 0 ✓
  4. strncmp 與 strcmp 的分工:strcmp 比整串,strncmp 只比前 n 個—— 「開頭是否為…」正是 strncmp 的招牌用途。

參考解答

/*
 * ex3_starts_with.c
 * -----------------
 * 練習 3:starts_with —— 用 strncmp 判斷字串開頭(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_starts_with.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <string.h>

/*
 * 若 s 以 prefix 開頭回傳 1,否則回傳 0。
 * strncmp(s, prefix, n) 只比較前 n 個字元——
 * 把 n 設成 prefix 的長度,正好就是「開頭是否為 prefix」。
 */
int starts_with(const char *s, const char *prefix)
{
    return strncmp(s, prefix, strlen(prefix)) == 0;
}

static void test(const char *s, const char *prefix)
{
    printf("starts_with(\"%s\", \"%s\") = %d\n",
           s, prefix, starts_with(s, prefix));
}

int main(void)
{
    test("https://example.com", "https://");   /* 1 */
    test("http://example.com", "https://");    /* 0 */
    test("hello", "hello world");              /* 0:prefix 比 s 長 */
    test("hello", "hello");                    /* 1:完全相等 */
    test("hello", "");                         /* 1:空前綴恆成立 */
    test("", "a");                             /* 0 */

    /* 實際應用:過濾出以 "img_" 開頭的檔名 */
    const char *files[] = { "img_001.png", "doc.txt", "img_002.png", "note.md" };
    printf("以 img_ 開頭的檔案:");
    for (int i = 0; i < 4; i++)
        if (starts_with(files[i], "img_"))
            printf("%s ", files[i]);
    printf("\n");

    return 0;
}

程式碼講解

  • strncmp(...) == 0:strncmp 回傳的是三態比較結果(<0 / 0 / >0), 不是布林值。判斷相等一定要 == 0;直接 if (strncmp(...)) 的語意剛好相反, 是新手最常踩的雷。
  • strlen(prefix) 每次呼叫都會重算長度。若同一個 prefix 要比對上萬個字串, 可以先算好長度傳進去;日常使用這樣寫最清晰。
  • 過濾檔名的迴圈展示了這個小工具的真實用途——URL scheme 檢查、副檔名/前綴過濾、 指令解析(if (starts_with(line, "GET ")))都是同一招。

範例輸出

starts_with("https://example.com", "https://") = 1
starts_with("http://example.com", "https://") = 0
starts_with("hello", "hello world") = 0
starts_with("hello", "hello") = 1
starts_with("hello", "") = 1
starts_with("", "a") = 0
以 img_ 開頭的檔案:img_001.png img_002.png 

練習 4:my_strstr —— 自己實作子字串搜尋(難度:進階)

題目

不使用任何庫函式,實作 char *my_strstr(const char *hay, const char *needle): 回傳 needle 在 hay 中第一次出現位置的指標;找不到回傳 NULL; 空 needle 依標準慣例回傳 hay 本身。 測試需涵蓋:一般命中、部分相符後需回頭重試(如在 "ababc" 找 "abc")、 整串相等、找不到、空 needle、needle 比 hay 長。

解題思路

  1. 樸素演算法(brute force):把 needle 依序「對齊」到 hay 的每個起點試比:
   hay:    a b a b c        needle: a b c
   對齊 0: a b c
           ✓ ✓ ✗            第 3 個字元 a != c,失敗
   對齊 1:   b a b
             ✗              第 1 個字元就不符,失敗
   對齊 2:     a b c
               ✓ ✓ ✓        needle 比完了 -> 命中,回傳 hay+2
  1. 雙層迴圈的結構: - 外層:hay 逐字前進,代表每個「對齊起點」; - 內層:從對齊點開始用兩個游標 h、n 同步前進, 比到 needle 結束(成功)或字元不符(失敗換下一個起點)。
  2. 內層迴圈的結束條件是精華:while (*h == *n && *n != '\0')—— 兩個條件的順序也有意義:先確認相等再看 needle 是否走完。 離開迴圈後只要檢查 *n == '\0':是 → needle 每個字元都對上了。
  3. 為什麼「部分相符」的測試重要? 在 "ababc" 找 "abc" 時, 對齊 0 會匹配到一半才失敗——錯誤的實作(例如比對失敗後 hay 忘了回頭)會漏掉真正的答案。 本實作的外層 hay++ 天然「回頭」:內層用的是另一組游標,不動外層變數。
  4. 空 needle 的特例放在最前面:標準規定 strstr(s, "") 回傳 s。
  5. 複雜度 O(len(hay) × len(needle))。教科書級的 KMP/Boyer-Moore 可到 O(n+m), 但實務上樸素版對短字串反而快——標準庫多半也是最佳化過的樸素版。

參考解答

/*
 * ex4_my_strstr.c
 * ---------------
 * 練習 4:my_strstr —— 不用庫函式實作子字串搜尋(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_my_strstr.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stddef.h>

/*
 * 在 hay 中找 needle 第一次出現的位置;找不到回傳 NULL。
 * 空 needle 依標準慣例回傳 hay 本身。
 *
 * 樸素演算法:把 needle 依序「對齊」到 hay 的每個位置試比。
 *   hay:    a b a b c
 *   試位 0: a b c        (第 3 字元 a!=c,失敗)
 *   試位 1:   b a b      (第 1 字元 b!=a... 失敗)
 *   試位 2:     a b c    (全部相符 -> 回傳 hay+2)
 */
char *my_strstr(const char *hay, const char *needle)
{
    if (*needle == '\0')                 /* 空 needle:立即命中 */
        return (char *)hay;

    for (; *hay != '\0'; hay++) {        /* 對齊到 hay 的每個位置 */
        const char *h = hay;             /* 從對齊點開始的游標 */
        const char *n = needle;

        while (*h == *n && *n != '\0') { /* 逐字比對到 needle 結束或不符 */
            h++;
            n++;
        }
        if (*n == '\0')                  /* needle 全部比完 -> 命中 */
            return (char *)hay;
    }
    return NULL;                         /* hay 掃完仍無 -> 不存在 */
}

static void test(const char *hay, const char *needle)
{
    char *p = my_strstr(hay, needle);
    if (p != NULL)
        printf("my_strstr(\"%s\", \"%s\") -> 索引 %td(\"%s\")\n",
               hay, needle, p - hay, p);
    else
        printf("my_strstr(\"%s\", \"%s\") -> NULL\n", hay, needle);
}

int main(void)
{
    test("hello, world", "world");   /* 索引 7 */
    test("ababc", "abc");            /* 索引 2:部分相符後回頭重試 */
    test("hello", "hello");          /* 索引 0:整串相等 */
    test("hello", "xyz");            /* NULL */
    test("hello", "");               /* 索引 0:空 needle */
    test("aaa", "aaaa");             /* NULL:needle 比 hay 長 */

    return 0;
}

程式碼講解

  • 內層游標 h、n 與外層的 hay、needle 分離,是「回頭重試」能運作的關鍵: 失敗時外層只前進一步,一切重來。
  • while (*h == *n && *n != '\0') 巧妙處理了「hay 先走完」的情況: *h 是 '\0' 而 *n 不是時,*h == *n 不成立,自動失敗—— 所以 "aaa" 找 "aaaa" 不會越界(h 停在 '\0' 上,從不跨過去)。
  • (char *)hay 的轉型:同 strchr 的說明,照抄標準庫介面的歷史包袱。
  • 易錯處一:內層直接動 hay/needle 本體——失敗後回不了頭。
  • 易錯處二:判斷成功用 *h == '\0'——那是「hay 走完」,不是「needle 走完」; 正確條件是 *n == '\0'。

範例輸出

my_strstr("hello, world", "world") -> 索引 7("world")
my_strstr("ababc", "abc") -> 索引 2("abc")
my_strstr("hello", "hello") -> 索引 0("hello")
my_strstr("hello", "xyz") -> NULL
my_strstr("hello", "") -> 索引 0("hello")
my_strstr("aaa", "aaaa") -> NULL

練習 5:my_atoi_strict —— 嚴格版字串轉整數(難度:進階)

題目

寫函式 int my_atoi_strict(const char *s, int *out),比 atoi 更嚴格:

  • 允許前導空白、至多一個正負號、之後至少一位數字;
  • 數字之後必須直接到字串結尾——出現任何其他字元都算失敗;
  • 超出 int 範圍算失敗;
  • 成功回傳 1 並把結果寫入 *out;失敗回傳 0 且 *out 保持不動。

測試需涵蓋:一般正負數、"0"、"12abc"(atoi 會靜靜回 12)、"abc"、空字串、 只有符號的 "-"、超出範圍的 "99999999999"。

解題思路

  1. 先想清楚 atoi 哪裡不好:atoi("abc") 回 0、atoi("0") 也回 0—— 呼叫端無法分辨「轉換失敗」與「值就是 0」。解法是把狀態與值分開: 回傳值報告成敗,結果經由指標參數送出(第 01 單元的模式再現)。
  2. 把格式規則翻成流水線,每一站驗一件事:
  3. while (isspace(...)) s++; —— 跳過前導空白;
  4. 看到 +/- 就記下符號並前進一格(至多一個);
  5. 此刻必須是數字:if (!isdigit(...)) return 0; —— 擋掉 ""、"-"、"abc";
  6. 逐位累積:val = val * 10 + (*s - '0');('7' - '0' == 7,字元轉數字的老招);
  7. 迴圈結束後 *s 必須是 '\0':擋掉 "12abc"。
  8. 溢位檢查:用 long(在 macOS 上 64 位元)累積,每步檢查 sign * val 是否超出 int 範圍 [-2147483648, 2147483647]。 注意檢查要在帶符號之後做——-2147483648 合法但 +2147483648 不合法。
  9. 「失敗時 *out 不動」的實作:把 *out = ... 放在所有檢查之後的最後一行。 任何一站失敗都在寫出之前 return——呼叫端的變數保持原值(用哨兵值 -999 驗證)。

參考解答

/*
 * ex5_atoi_strict.c
 * -----------------
 * 練習 5:my_atoi_strict —— 嚴格版字串轉整數(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_atoi_strict.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <ctype.h>

/*
 * 比 atoi 嚴格的轉換:
 *   - 允許前導空白、一個正負號、至少一位數字
 *   - 數字之後必須直接到字串結尾,出現任何其他字元都算失敗
 * 成功:回傳 1,結果經由 *out 寫出;失敗:回傳 0,*out 不動。
 * (atoi 對 "12abc" 會靜靜回 12、對 "abc" 回 0——錯誤完全吃掉,
 *   嚴格版把「格式錯誤」與「值為 0」區分開來。)
 */
int my_atoi_strict(const char *s, int *out)
{
    while (isspace((unsigned char)*s))    /* 1. 跳過前導空白 */
        s++;

    int sign = 1;
    if (*s == '+' || *s == '-') {         /* 2. 至多一個正負號 */
        if (*s == '-')
            sign = -1;
        s++;
    }

    if (!isdigit((unsigned char)*s))      /* 3. 至少要有一位數字 */
        return 0;

    long val = 0;                         /* 用 long 累積以便檢查溢位 */
    while (isdigit((unsigned char)*s)) {
        val = val * 10 + (*s - '0');
        if (sign * val < -2147483648L || sign * val > 2147483647L)
            return 0;                     /* 4. 超出 int 範圍算失敗 */
        s++;
    }

    if (*s != '\0')                       /* 5. 數字後有雜訊 -> 失敗 */
        return 0;

    *out = (int)(sign * val);             /* 全部檢查通過才寫出結果 */
    return 1;
}

static void test(const char *s)
{
    int value = -999;                     /* 哨兵值:失敗時應保持不動 */
    int ok = my_atoi_strict(s, &value);
    if (ok)
        printf("\"%s\" -> 成功,值 = %d\n", s, value);
    else
        printf("\"%s\" -> 失敗(value 未被改動,仍是 %d)\n", s, value);
}

int main(void)
{
    test("123");        /* 成功:123 */
    test("  -456");     /* 成功:前導空白 + 負號 */
    test("+789");       /* 成功:正號 */
    test("0");          /* 成功:0(與「失敗」明確區分) */
    test("12abc");      /* 失敗:數字後有雜訊(atoi 會回 12) */
    test("abc");        /* 失敗:完全不是數字(atoi 會回 0) */
    test("");           /* 失敗:空字串 */
    test("-");          /* 失敗:只有符號沒有數字 */
    test("99999999999");/* 失敗:超出 int 範圍 */

    return 0;
}

程式碼講解

  • 「回傳成敗 + 指標送值」是 C 錯誤處理的標準介面(fscanf 的回傳值、 BSD 的 strtonum 都是這個路數),比魔術回傳值(-1?0?)健壯得多。
  • 第 3 站的「至少一位數字」檢查一石三鳥:空字串、只有空白、只有符號全部在這裡擋下。
  • *s - '0':C 保證 '0'..'9' 的編碼連續,字元減 '0' 就是數位值。
  • 溢位檢查用 long(macOS 上 64 位元)當緩衝墊,累積過程不會先溢位再檢查。 嚴格可攜的寫法會用 <limits.h> 的 INT_MIN/INT_MAX 與 LONG_MAX 邊界技巧, 這裡為了聚焦流程用了明確字面值。
  • 哨兵值 -999 的測試手法:驗證「失敗路徑真的沒碰 *out」——這種契約要靠測試保護。

範例輸出

"123" -> 成功,值 = 123
"  -456" -> 成功,值 = -456
"+789" -> 成功,值 = 789
"0" -> 成功,值 = 0
"12abc" -> 失敗(value 未被改動,仍是 -999)
"abc" -> 失敗(value 未被改動,仍是 -999)
"" -> 失敗(value 未被改動,仍是 -999)
"-" -> 失敗(value 未被改動,仍是 -999)
"99999999999" -> 失敗(value 未被改動,仍是 -999)

練習 6:my_strlcpy —— BSD 風格的安全複製(難度:挑戰)

題目

實作 size_t my_strlcpy(char *dst, const char *src, size_t size)(BSD 的 strlcpy 規格):

  • 至多寫入 size - 1 個字元到 dst,永遠補 '\0'(size > 0 時);
  • 回傳 src 的完整長度(不含 '\0')——呼叫端用 回傳值 >= size 即可偵測截斷。

測試:空間充足、剛剛好、太小(截斷)、size == 1、空來源字串, 並示範「檢查回傳值偵測截斷」的典型使用模式。

解題思路

  1. 先搞懂 strlcpy 解決了 strncpy 的哪兩個毛病: - strncpy 在 src 太長時不補 '\0'——dst 變成沒有結尾的地雷; - strncpy 在 src 較短時會把剩餘空間全部填 0——無謂的浪費。 strlcpy 的契約:dst 永遠是合法字串 + 回傳值告訴你有沒有截斷。
  2. 實作三步: - 先取得 srclen = strlen(src)(這就是要回傳的值); - 計算實際可複製的字元數:n = min(srclen, size - 1)—— 取「來源長度」與「容量減一(留給 '\0')」的較小者; - memcpy(dst, src, n); dst[n] = '\0';——複製後無條件補結尾。
  3. size == 0 的防衛:連 '\0' 都放不下,什麼都不能寫(整段包在 if (size > 0))。
  4. 截斷偵測的原理:回傳的是「本來需要」的長度。 回傳值 >= size ⟺ srclen > size - 1 ⟺ 放不下 ⟺ 發生截斷。 呼叫端一行搞定:if (my_strlcpy(buf, src, sizeof buf) >= sizeof buf) 處理截斷;
  5. 測試手法:先用 memset(buf, '#', ...) 填滿哨兵字元, 複製後若 printf 正常停在複製的內容,就證明 '\0' 有補上(沒有就會一路印到 #)。

參考解答

/*
 * ex6_strlcpy.c
 * -------------
 * 練習 6:my_strlcpy —— BSD 風格的安全字串複製(難度:挑戰)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_strlcpy.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <string.h>

/*
 * 把 src 複製進 dst(容量 size bytes):
 *   - 至多寫入 size-1 個字元,永遠補 '\0'(size > 0 時)
 *   - 回傳 src 的完整長度:呼叫端用「回傳值 >= size」即可偵測截斷
 * 對照 strncpy 的兩大缺陷:可能不補 '\0'、且會把剩餘空間全部填 0(浪費)。
 */
size_t my_strlcpy(char *dst, const char *src, size_t size)
{
    size_t srclen = strlen(src);

    if (size > 0) {
        size_t n = (srclen < size - 1) ? srclen : size - 1;  /* 實際可複製數 */
        memcpy(dst, src, n);
        dst[n] = '\0';                    /* 無論如何補結尾 */
    }
    return srclen;                        /* 回傳「本來需要」的長度 */
}

static void test(const char *src, size_t size)
{
    char buf[16];

    /* 先填滿哨兵字元,觀察函式實際動到哪些位置 */
    memset(buf, '#', sizeof buf);

    size_t need = my_strlcpy(buf, src, size);

    printf("src=\"%s\", size=%zu -> dst=\"%s\", 回傳=%zu%s\n",
           src, size, buf, need,
           (need >= size) ? "(發生截斷!)" : "");
}

int main(void)
{
    test("hello", 16);      /* 空間充足:完整複製 */
    test("hello", 6);       /* 剛剛好:5 字元 + '\0' */
    test("hello", 4);       /* 太小:截斷成 "hel",回傳 5 提醒 */
    test("hello", 1);       /* 只放得下 '\0':dst 變空字串 */
    test("", 8);            /* 空字串來源 */

    /* 典型使用模式:檢查回傳值偵測截斷 */
    char small[8];
    const char *long_path = "/usr/local/lib/libfoo.dylib";
    if (my_strlcpy(small, long_path, sizeof small) >= sizeof small)
        printf("警告:\"%s\" 被截斷為 \"%s\"\n", long_path, small);

    return 0;
}

程式碼講解

  • n = (srclen < size - 1) ? srclen : size - 1;:一行的 min。 注意 size - 1 在 size == 0 時會下溢成極大值(size_t 無號)—— 這就是為什麼整段要包在 if (size > 0) 裡,順序不能倒。
  • memcpy 比逐字元迴圈快(大區塊時走 SIMD),且這裡長度已知,正是它的主場。
  • size == 1 的行為:複製 0 個字元、dst[0] = '\0'——dst 是合法的空字串。 對照 strncpy(dst, src, 1) 會留下 1 個字元且沒有結尾。
  • 回傳值設計的巧思:不回傳「實際複製了幾個」而回傳「本來需要幾個」, 讓截斷偵測變成一個比較運算,也讓呼叫端知道該配多大的緩衝區重試。
  • macOS 系統其實內建 strlcpy(BSD 家族),但它不在 C 標準中; glibc(Linux)長年拒收,直到 POSIX 2024 才納入——自己會寫一個仍然實用。

範例輸出

src="hello", size=16 -> dst="hello", 回傳=5
src="hello", size=6 -> dst="hello", 回傳=5
src="hello", size=4 -> dst="hel", 回傳=5(發生截斷!)
src="hello", size=1 -> dst="", 回傳=5(發生截斷!)
src="", size=8 -> dst="", 回傳=0
警告:"/usr/local/lib/libfoo.dylib" 被截斷為 "/usr/lo"

常見錯誤解法對照

錯誤寫法 症狀 正確做法
isspace(*s)(沒轉 unsigned char) 非 ASCII 位元組觸發未定義行為 isspace((unsigned char)*s)
數單字用「空白數 + 1」 連續/前後空白全部數錯 狀態機數「空白→非空白」轉折
if (strncmp(s, p, n)) 判斷「相等」 邏輯剛好相反 strncmp(...) == 0
my_strstr 內層直接動 hay 部分相符失敗後回不了頭 內層用獨立游標 h、n
用 atoi 卻想偵測錯誤 "abc" 與 "0" 都回 0,無法區分 回傳成敗 + 指標送值(或用 strtol)
strncpy(dst, src, sizeof dst) 就收工 src 過長時 dst 沒有 '\0' 手動補結尾,或改用 strlcpy/snprintf