本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」, 寫完、編譯、執行後再對照「解題思路」與「參考解答」。 完整程式碼都在 exercises/ 資料夾,可直接編譯:

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

練習 1:printf 的各種數字面貌(難度:基礎)

題目

用 printf 印出:

  1. 整數 255 的十進位、八進位(含 0 前綴)、十六進位(含 0x 前綴,大小寫各一);
  2. 255.0 的 %e(科學記號)、%g(自動選擇)、%f(定點)三種表示;
  3. 加碼:找兩個數字示範 %g 什麼時候會切換成科學記號。

解題思路

  1. 前綴靠 # 旗標:%o 印 377、%#o 印 0377;%x 印 ff、%#x 印 0xff。 # 的意義是「替代形式(alternative form)」,讓輸出自帶進位法標記。
  2. 三種浮點格式的分工: - %f:定點,預設 6 位小數(255.000000)——適合金額等固定小數場景; - %e:一律科學記號(2.550000e+02)——適合數量級差很大的科學數據; - %g:自動選短的且去尾零(255)——適合「給人看」的一般輸出。
  3. %g 的切換規則:十進位指數 < −4 或 ≥ 精度(預設 6)時改用科學記號。 找兩個例子夾出邊界:2.55 × 10⁸(指數 8 ≥ 6 → 科學記號)、 0.0000255(指數 −5 < −4 → 科學記號)。
  4. 細節:格式字串裡要印出 % 本身,寫 %%。

參考解答

#include <stdio.h>

int main(void)
{
    int n = 255;
    double d = 255.0;

    printf("decimal      (%%d)  : %d\n", n);
    printf("octal        (%%#o) : %#o\n", n);     /* # 旗標:補上 0 前綴 */
    printf("hex          (%%#x) : %#x\n", n);     /* # 旗標:補上 0x 前綴 */
    printf("hex upper    (%%#X) : %#X\n", n);

    printf("scientific   (%%e)  : %e\n", d);      /* 一律科學記號 */
    printf("shortest     (%%g)  : %g\n", d);      /* 自動選 %f 或 %e、去尾零 */
    printf("fixed        (%%f)  : %f\n", d);      /* 對照:定點、6 位小數 */

    /* %g 什麼時候切換成科學記號?指數 < -4 或 >= 精度(預設 6)時 */
    printf("\n%%g examples:\n");
    printf("  %%g of 255000000.0 : %g\n", 255000000.0);
    printf("  %%g of 0.0000255   : %g\n", 0.0000255);

    return 0;
}

(完整檔案:exercises/ex1_printf_bases.c)

程式碼講解

  • %#X 連前綴都變大寫(0XFF)——大小寫由轉換字元統一控制。
  • %g 印 255.0 時輸出就是 255:小數點後全是零就整個省掉, 這是它適合「人類可讀輸出」的原因。
  • 同一個值、七種樣子——printf 的格式字串是「呈現層」,值本身從未改變 (與單元 02 練習 4 的觀念一脈相承)。

範例輸出

decimal      (%d)  : 255
octal        (%#o) : 0377
hex          (%#x) : 0xff
hex upper    (%#X) : 0XFF
scientific   (%e)  : 2.550000e+02
shortest     (%g)  : 255
fixed        (%f)  : 255.000000

%g examples:
  %g of 255000000.0 : 2.55e+08
  %g of 0.0000255   : 2.55e-05

練習 2:對齊報表(難度:基礎)

題目

用寬度與 - 旗標印出三欄對齊的商品表: 名稱靠左 12 格、數量靠右 5 格、單價靠右 8 格(2 位小數)。 資料寫死至少四筆,其中一筆的名稱故意超過 12 個字元,觀察會發生什麼事。

解題思路

  1. 欄位規格翻成格式: - 名稱靠左 12 格 → %-12s(- 旗標 = 靠左); - 數量靠右 5 格 → %5d(預設靠右); - 單價靠右 8 格、2 位小數 → %8.2f(寬度 8 含小數點與小數位)。
  2. 表頭要用同一組寬度:printf("%-12s %5s %8s\n", "name", "qty", "price")—— 表頭也是字串,套同樣的欄寬才會跟資料對齊。分隔線同理。
  3. 關鍵觀察(本題的埋伏):"dark chocolate" 有 14 個字元 > 12。 printf 的寬度是最小寬度不是上限——欄位被撐開、該列右邊全部歪掉。 結論:寬度防「太短」不防「太長」。
  4. 想截斷怎麼辦:字串的精度才是上限——%.12s 最多印 12 個字元。 合起來 %-12.12s 就是「固定 12 格、超長截斷」的嚴格欄位。

參考解答

#include <stdio.h>

int main(void)
{
    /* 平行陣列存放商品資料(結構 struct 是中階單元的主題) */
    const char *names[]  = { "apple", "bread", "milk", "dark chocolate" };
    int         qtys[]   = { 12, 3, 25, 108 };
    double      prices[] = { 0.5, 32.0, 1.25, 99.9 };
    int n = (int)(sizeof qtys / sizeof qtys[0]);

    /* 表頭與資料用同一組寬度,欄位才會對齊:
     * %-12s 靠左 12 格、%5d 靠右 5 格、%8.2f 靠右 8 格含 2 位小數 */
    printf("%-12s %5s %8s\n", "name", "qty", "price");
    printf("%-12s %5s %8s\n", "------------", "-----", "--------");

    for (int i = 0; i < n; i++) {
        printf("%-12s %5d %8.2f\n", names[i], qtys[i], prices[i]);
    }

    /* 觀察:名稱超過 12 格時(dark chocolate 有 14 字元),
     * 寬度是「最小值」不是上限——欄位會被撐開而不是截斷 */
    printf("\nnote: width is a MINIMUM; long names push the row wider.\n");
    printf("to truncate, add precision: %%.12s -> [%.12s]\n",
           "dark chocolate");

    return 0;
}

(完整檔案:exercises/ex2_report.c)

程式碼講解

  • 輸出中前三筆完美對齊,第四筆(dark chocolate)把整列撐歪—— 這一筆「壞資料」正是本題的教材:報表程式必須決定「撐開還是截斷」。
  • %8.2f 的 8 是整個欄位寬度:32.00 佔 5 字元、前面補 3 個空格。 想「小數點對齊」,讓所有列用同一個寬度即可。
  • 平行陣列(三個陣列用同一個索引 i 綁在一起)是還沒學 struct 前的替代方案。

範例輸出

name           qty    price
------------ ----- --------
apple           12     0.50
bread            3    32.00
milk            25     1.25
dark chocolate   108    99.90

note: width is a MINIMUM; long names push the row wider.
to truncate, add precision: %.12s -> [dark chocola]

練習 3:動態寬度 %*d(難度:進階)

題目

用 %*d 寫函式 void print_right(int value, int width): 把 value 靠右印在 width 格寬的欄位裡(前後加 [] 標出邊界)。 用寬度 5、10、15 印同一個數字驗證。 加碼:試試負的寬度會發生什麼事;並示範一個實戰用法—— 先掃描資料找出最寬的數字,再用它當全體的欄寬。

解題思路

  1. %*d 的機制:格式裡的 * 是佔位符, printf 會多吃一個 int 引數當寬度——printf("%*d", width, value)。 引數順序:先寬度、後數值,與 * 和 d 在格式裡的順序一致。
  2. 為什麼需要它:寫死的 %10d 在編譯期就固定了; %*d 讓欄寬變成執行期的資料——正是「欄寬跟著資料走」的報表需求。
  3. 負寬度:標準規定負寬度等同「加上 - 旗標的正寬度」——靠左對齊。 一個參數同時編碼「寬度 + 對齊方向」。
  4. 實戰示範的演算法: - 第一趟:算每個數字的位數(x /= 10 數一次加一),記錄最大值; - 第二趟:全部用 %*d 配 max_width 印出——整欄剛好對齊、不多不少。 「兩趟走訪:先量再印」是排版程式的常用結構。

參考解答

#include <stdio.h>

/* 把 value 靠右印在 width 格寬的欄位裡(前後加 [] 標出欄位邊界) */
void print_right(int value, int width)
{
    /* %*d 的星號會「吃掉」下一個引數當寬度——
     * 寬度不再寫死在格式字串裡,而是執行期決定 */
    printf("width %2d: [%*d]\n", width, width, value);
}

int main(void)
{
    int value = 42;

    print_right(value, 5);
    print_right(value, 10);
    print_right(value, 15);

    /* 加碼:負寬度 = 靠左對齊(等同 - 旗標) */
    printf("\nnegative width means left-align:\n");
    printf("width -10: [%*d]\n", -10, value);

    /* 動態寬度的實戰:欄寬跟著資料走 */
    printf("\na practical use, aligning to the widest number:\n");
    int data[] = { 7, 1024, 99, 123456 };
    int n = (int)(sizeof data / sizeof data[0]);

    int max_width = 1;
    for (int i = 0; i < n; i++) {       /* 算最寬的數字有幾位 */
        int digits = 1;
        for (int x = data[i]; x >= 10; x /= 10) {
            digits++;
        }
        if (digits > max_width) {
            max_width = digits;
        }
    }

    for (int i = 0; i < n; i++) {
        printf("  %*d\n", max_width, data[i]);
    }

    return 0;
}

(完整檔案:exercises/ex3_dynamic_width.c)

程式碼講解

  • printf("width %2d: [%*d]\n", width, width, value):width 傳了兩次—— 第一次是印給人看的標籤,第二次餵給 *。引數與洞的對應要逐一數清楚。
  • 數位數的內層迴圈:x >= 10 時不斷除 10——7 是 1 位、123456 是 6 位。 (用 %d 的輸出長度反推位數也可以,但直接算不依賴字串。)
  • 精度同樣可以動態化:%.*f 讓小數位數也變成引數——舉一反三。

範例輸出

width  5: [   42]
width 10: [        42]
width 15: [             42]

negative width means left-align:
width -10: [42        ]

a practical use, aligning to the widest number:
       7
    1024
      99
  123456

練習 4:日期解析器(難度:進階)

題目

寫函式 void parse_date(const char *input),用 sscanf 解析日期字串, 同時支援 "2026/08/01"(斜線)與 "2026-08-01"(連字號)兩種格式。 測資包含:兩種正常格式、"garbage"、格式對但值不合理的 "2026-13-40"、 少一段的 "2026/08"。全部靠回傳值判斷成敗,失敗的要明確回報。

解題思路

  1. sscanf 是「從字串讀」的 scanf:格式字串裡的普通字元(/、-) 要求輸入逐字匹配——這正是拿來區分兩種日期格式的工具。
  2. 回傳值是唯一的成敗依據:sscanf(input, "%d/%d/%d", &y, &m, &d) 回傳成功轉換的項目數。三個 %d 全中才是 3; "2026/08" 只中 2、"garbage" 中 0——用 == 3 判斷,不能只判 > 0。
  3. 兩種格式的嘗試順序:先試斜線、失敗再試連字號, 用 || 短路串接:第一個成功就不試第二個。 (對 "2026-08-01" 試斜線格式時,%d 會把 2026 讀走、 遇到 - 與格式裡的 / 不符而停,回傳 1 ≠ 3 → 正確落到第二個格式。)
  4. 解析成功 ≠ 值合理:"2026-13-40" 三個數字都讀得到(回傳 3), 但 13 月 40 日不存在——語法檢查之後還要語意檢查(月 1~12、日 1~31)。 這個「兩層驗證」的意識比本題本身更重要。
  5. 測資設計:每條路徑一筆——兩種成功、格式錯、值錯、段數不足。

參考解答

#include <stdio.h>

/* 嘗試解析一個日期字串:支援 YYYY/MM/DD 與 YYYY-MM-DD 兩種格式。
 * 成功印出解析結果、失敗印出錯誤訊息。 */
void parse_date(const char *input)
{
    int y, m, d;

    /* 先試斜線格式;sscanf 回傳「成功轉換的項目數」,== 3 才是全額成功 */
    if (sscanf(input, "%d/%d/%d", &y, &m, &d) == 3 ||
        sscanf(input, "%d-%d-%d", &y, &m, &d) == 3) {

        /* 解析成功不代表值合理:再做範圍檢查 */
        if (m >= 1 && m <= 12 && d >= 1 && d <= 31) {
            printf("[%s] -> year=%d month=%d day=%d\n", input, y, m, d);
        } else {
            printf("[%s] -> parsed but out of range (m=%d d=%d)\n",
                   input, m, d);
        }
    } else {
        printf("[%s] -> not a date, skipped\n", input);
    }
}

int main(void)
{
    const char *inputs[] = {
        "2026/08/01",       /* 斜線格式 */
        "2026-08-01",       /* 連字號格式 */
        "garbage",          /* 完全不是日期 */
        "2026-13-40",       /* 格式對但值不合理 */
        "2026/08",          /* 少一段:sscanf 只回 2 */
    };
    int n = (int)(sizeof inputs / sizeof inputs[0]);

    for (int i = 0; i < n; i++) {
        parse_date(inputs[i]);
    }

    return 0;
}

(完整檔案:exercises/ex4_date_parser.c)

程式碼講解

  • || 的短路特性讓「格式嘗試鏈」乾淨俐落:成功即止、失敗續試。 要支援第三種格式(如 "2026.08.01")就再串一個。
  • 注意一個微妙點:第一個 sscanf 失敗時 y 可能已被寫入(部分成功)—— 所以絕不能在確認回傳值前使用任何變數。
  • %d 對 "08" 讀出 8:十進位轉換不會把 0 開頭當八進位(那是 %i 的行為), 日期解析用 %d 才安全。

範例輸出

[2026/08/01] -> year=2026 month=8 day=1
[2026-08-01] -> year=2026 month=8 day=1
[garbage] -> not a date, skipped
[2026-13-40] -> parsed but out of range (m=13 d=40)
[2026/08] -> not a date, skipped

練習 5:安全的整數輸入(難度:挑戰)

題目

寫一個「從 stdin 讀一個整數」的完整流程:

  1. scanf("%d") 失敗時,清掉該行並提示重試,最多 3 次;
  2. 讀到 EOF 立刻放棄(不再空等);
  3. 無輸入(< /dev/null)也要能正常結束、exit 0。

用三種方式驗證: printf "abc\n42\n" | ./prog(先錯後對)、./prog < /dev/null(立刻 EOF)、 printf "x\ny\nz\n" | ./prog(三次全錯)。

解題思路

  1. scanf 的三種回傳值就是三條路: - 1:成功——設旗標、跳出; - EOF:輸入耗盡——再重試也不會有新輸入,直接放棄; - 0:這行開頭不是數字——進入「清理重試」流程。
  2. 為什麼必須清行(本題的靈魂):%d 轉換失敗時, 壞資料 "abc" 原封不動留在緩衝區。不清掉就重試, 下一輪 scanf 讀到的還是同一坨——瞬間吃完三次機會(實際上是無窮迴圈的成因)。 清行的標準寫法:while ((ch = getchar()) != '\n' && ch != EOF) ;。
  3. 清行也可能撞到 EOF:輸入的最後一行沒有換行就結束時, 清行迴圈以 EOF 收場——這也要當成「放棄」處理,否則下一輪 scanf 空轉。
  4. 控制結構:for 跑三次嘗試 + got_value 旗標記錄成敗, 迴圈後統一收尾(成功印值、失敗印訊息)。成敗兩條路都要有明確輸出。
  5. exit code 的選擇:示範程式選擇「怎樣都 return 0」; 實務工具則常在讀不到時 return 1 讓 shell 腳本能判斷——註解裡說明即可。

參考解答

#include <stdio.h>

#define MAX_RETRIES 3

int main(void)
{
    int value = 0;
    int got_value = 0;      /* 旗標:是否成功讀到整數 */

    for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
        printf("enter an integer (attempt %d of %d): ", attempt, MAX_RETRIES);

        int rc = scanf("%d", &value);   /* 回傳成功項目數,EOF 表示輸入耗盡 */

        if (rc == 1) {
            got_value = 1;              /* 成功:跳出重試迴圈 */
            break;
        }

        if (rc == EOF) {
            printf("\nend of input reached, giving up.\n");
            break;                      /* EOF:再重試也不會有輸入了 */
        }

        /* rc == 0:這一行的開頭不是數字。
         * 壞資料還留在緩衝區,必須清掉整行,否則下一輪 scanf
         * 又讀到同一坨垃圾——無窮迴圈的經典成因 */
        int ch;
        while ((ch = getchar()) != '\n' && ch != EOF) {
            ;                           /* 丟棄到行尾 */
        }
        printf("that was not an integer, please try again.\n");

        if (ch == EOF) {                /* 清行清到 EOF:同樣放棄 */
            printf("end of input reached, giving up.\n");
            break;
        }
    }

    if (got_value) {
        printf("you entered: %d\n", value);
    } else {
        printf("no valid integer was read.\n");
    }

    return 0;   /* 不論讀到與否都正常結束(示範程式的選擇) */
}

(完整檔案:exercises/ex5_safe_input.c)

程式碼講解

  • 三種測試情境覆蓋三條路: abc\n42\n 走「失敗 → 清行 → 成功」;/dev/null 走「第一次就 EOF」; x\ny\nz\n 走「三次全失敗、機會用完」。全部 exit 0。
  • 清行迴圈的 ch 必須是 int(要容納 EOF)——單元 08 陷阱 1 在這裡真實上場。
  • 這一小段「檢查回傳值 + 清緩衝 + 上限重試 + EOF 放棄」就是 互動式輸入的完整防禦套路,值得整段背下來。

範例輸出

printf "abc\n42\n" | ./prog:

enter an integer (attempt 1 of 3): that was not an integer, please try again.
enter an integer (attempt 2 of 3): you entered: 42

./prog < /dev/null:

enter an integer (attempt 1 of 3): 
end of input reached, giving up.
no valid integer was read.

printf "x\ny\nz\n" | ./prog(三次全錯):

enter an integer (attempt 1 of 3): that was not an integer, please try again.
enter an integer (attempt 2 of 3): that was not an integer, please try again.
enter an integer (attempt 3 of 3): that was not an integer, please try again.
no valid integer was read.

練習 6:迷你 CSV 解析(難度:挑戰)

題目

給定寫死的字串陣列(每筆是「姓名,年齡,身高」的 CSV 列):

{ "Alice,30,1.65", "Bob,25,1.80", "broken line", "Carol,41,1.72", "Dave,not-a-number,2" }

用 sscanf 逐筆拆出姓名(提示:%[^,])、年齡(int)、身高(double), 跳過格式錯誤的資料列,最後印出有效筆數與平均年齡。

解題思路

  1. 姓名欄的難題:%s 遇空白才停,但 CSV 的分隔符是逗號—— %s 會把 "Alice,30,1.65" 整串吞掉。 解法是掃描集(scanset) %[^,]:「讀取不是逗號的字元,直到遇到逗號」。
  2. 永遠加寬度限制:%19[^,]——姓名緩衝區 char name[20], 最多讀 19 個字元、留一格給 '\0'。沒有寬度限制的 %[^,] 與 %s 同罪:緩衝區溢位。
  3. 完整格式:"%19[^,],%d,%lf"—— 掃描集吃姓名、字面逗號要求分隔符就位、%d 吃年齡、%lf 吃身高 (scanf 家族讀 double 要用 %lf,printf 用 %f——經典不對稱)。
  4. 成敗判準:回傳值 == 3 才算整列成功。 "broken line":%19[^,] 能吃下整行,但後面沒有逗號 → 回傳 1; "Dave,not-a-number,2":姓名成功、%d 對上 not-a-number 失敗 → 回傳 1。 兩者都被 == 3 擋下——一個判準攔住所有壞法。
  5. 統計:total_age 與 valid 只在成功分支累加; 平均前檢查 valid > 0(除以零防線),輸出 (double)total_age / valid。

參考解答

#include <stdio.h>

int main(void)
{
    /* 每個元素是一列 CSV 資料:姓名,年齡,身高 */
    const char *lines[] = {
        "Alice,30,1.65",
        "Bob,25,1.80",
        "broken line",          /* 沒有逗號:解析必失敗 */
        "Carol,41,1.72",
        "Dave,not-a-number,2",  /* 年齡欄壞掉:只成功 1 項 */
    };
    int n = (int)(sizeof lines / sizeof lines[0]);

    int total_age = 0;
    int valid = 0;

    printf("%-8s %4s %7s\n", "name", "age", "height");
    printf("%-8s %4s %7s\n", "--------", "----", "-------");

    for (int i = 0; i < n; i++) {
        char   name[20];
        int    age;
        double height;

        /* %19[^,]:讀「逗號以外的字元」至多 19 個(留 1 格給 '\0'),
         * 之後的 ,%d,%lf 依序吃掉年齡與身高。
         * 回傳值必須是 3 才代表整列解析成功。 */
        if (sscanf(lines[i], "%19[^,],%d,%lf", name, &age, &height) == 3) {
            printf("%-8s %4d %7.2f\n", name, age, height);
            total_age += age;
            valid++;
        } else {
            printf("skipped bad line: \"%s\"\n", lines[i]);
        }
    }

    if (valid > 0) {
        printf("\nvalid rows : %d of %d\n", valid, n);
        printf("average age: %.1f\n", (double)total_age / valid);
    } else {
        printf("\nno valid rows.\n");
    }

    return 0;
}

(完整檔案:exercises/ex6_mini_csv.c)

程式碼講解

  • sscanf(..., name, &age, &height):name 是陣列不加 &(自動退化成位址), age、height 是普通變數必須加 &——README 陷阱 2 的正反兩面同框。
  • 平均年齡 (30 + 25 + 41) / 3 = 32.0,輸出吻合; 兩筆壞資料各以自己的方式失敗,都被同一個 == 3 攔截。
  • 侷限說明:%[^,] 無法處理「空欄位」(連續兩個逗號會失敗)與含逗號的引號欄位—— 工業級 CSV 要逐字元寫小解析器;本題的重點是 sscanf 回傳值的防禦性使用。

範例輸出

name      age  height
-------- ---- -------
Alice      30    1.65
Bob        25    1.80
skipped bad line: "broken line"
Carol      41    1.72
skipped bad line: "Dave,not-a-number,2"

valid rows : 3 of 5
average age: 32.0

常見錯誤解法對照

錯誤寫法 症狀 正解
printf("%d", 3.14) 型別錯配 垃圾輸出;-Wformat 警告 格式與型別一一對應
scanf("%d", x) 忘記 & 未定義行為 &x(陣列除外)
不檢查 scanf/sscanf 回傳值 用到沒被寫入的變數 if (rc == 期望項目數)
scanf 失敗後直接重試 壞資料留在緩衝區,無窮迴圈 先清行再重試
scanf("%s", name) 無寬度 緩衝區溢位 %19s / %19[^,]
scanf 讀 double 用 %f 讀進 float 大小的記憶體,值全毀 scanf 家族用 %lf
以為寬度會截斷字串 長資料撐歪整列 寬度是最小值;截斷用精度 %.12s