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

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

編譯標準:練習 1–4 用 -std=c11;練習 5 必須 -std=c2x(C23); 練習 6 刻意設計成 c99、c11、c2x 三種標準都能編譯(輸出不同)。


練習 1:C89 → C99 重構(難度:基礎)

題目

下面是一段 C89 風格的統計函式(變數全擠在開頭、/* */ 註解、 0/1 當布林、輸出參數)。把它重構成 C99/C11 風格: 宣告貼近使用處、for 內宣告、bool、// 註解、 指定初始化器、回傳 struct、snprintf。

static int analyze(int *data, int n, int *min_out, int *max_out,
                   double *avg_out)
{
    int i;                              /* 全部變數擠在開頭 */
    int mn;
    int mx;
    long sum;
    int found_negative;                 /* 0/1 當布林 */

    if (n <= 0)
        return 0;
    mn = data[0]; mx = data[0]; sum = 0; found_negative = 0;
    for (i = 0; i < n; i++) {
        if (data[i] < mn) mn = data[i];
        if (data[i] > mx) mx = data[i];
        if (data[i] < 0)  found_negative = 1;
        sum += data[i];
    }
    *min_out = mn; *max_out = mx; *avg_out = (double)sum / n;
    return !found_negative;
}

解題思路

  1. 重構不是翻譯,是逐項升級。把 C89 的每個「不得已」 對應到 C99 的解法: - 變數擠開頭 → 宣告貼近首次使用:int mn = data[0]; 一行完成宣告與初始化,「未初始化空窗期」消失; - 迴圈變數外宣告 → for (int i = ...):i 的生命週期 縮到迴圈內,迴圈外誤用直接編譯錯誤; - 0/1 int → bool/true/false:型別即文件; - long sum → int64_t:溢位邊界跨平台一致。
  2. 三個輸出參數 → 回傳 struct:C89 也能回傳 struct, 但配上 C99 的複合字面值+指定初始化器才真正好寫:
return (struct stats){ .min = mn, .max = mx, ... };

呼叫端從「準備三個變數傳位址」變成一行接住全部結果。 3. 空輸入的處理順便升級:return (struct stats){ 0 } —— 全零的合法回傳值,比「回傳碼 + 半初始化的輸出參數」乾淨。 4. snprintf 取代 sprintf:長度上限保護是肌肉記憶, sizeof summary 直接當第二引數。

參考解答

/*
 * 練習 1:C89 -> C99 重構
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_c89_to_c99.c
 * 執行:/tmp/out
 *
 * 原始 C89 版(重構前)保留在下方註解中對照。
 * 重構重點:宣告貼近使用處、for 內宣告、bool、//、
 *           snprintf、指定初始化器。
 */
#include <stdio.h>
#include <stdbool.h>    // C99:有名有姓的布林
#include <stdint.h>     // C99:固定寬度整數

/*
 * ===== 重構前的 C89 版本(原樣保留,供對照) =====
 *
 * #define MAXN 8
 *
 * static int analyze(int *data, int n, int *min_out, int *max_out,
 *                    double *avg_out)
 * {
 *     int i;                              // 全部變數擠在開頭
 *     int mn;
 *     int mx;
 *     long sum;
 *     int found_negative;                 // 0/1 當布林
 *
 *     if (n <= 0)
 *         return 0;
 *     mn = data[0];
 *     mx = data[0];
 *     sum = 0;
 *     found_negative = 0;
 *     for (i = 0; i < n; i++) {           // i 在外面宣告
 *         if (data[i] < mn) mn = data[i];
 *         if (data[i] > mx) mx = data[i];
 *         if (data[i] < 0)  found_negative = 1;
 *         sum += data[i];
 *     }
 *     *min_out = mn;
 *     *max_out = mx;
 *     *avg_out = (double)sum / n;
 *     return !found_negative;
 * }
 */

/* ===== 重構後的 C99/C11 版本 ===== */

struct stats {
    int    min;
    int    max;
    double avg;
    bool   all_non_negative;    // bool 取代 0/1 int
};

// 回傳整個 struct:C89 也可以,但配合指定初始化器更清爽
static struct stats analyze(const int *data, int n)
{
    if (n <= 0)
        return (struct stats){ 0 };     // 複合字面值:全零的 stats

    // 宣告貼近第一次使用處,初始值一目了然
    int mn = data[0];
    int mx = data[0];
    int64_t sum = 0;                    // 固定寬度:溢位邊界明確
    bool has_negative = false;

    for (int i = 0; i < n; i++) {       // C99:i 只活在迴圈裡
        if (data[i] < mn)
            mn = data[i];
        if (data[i] > mx)
            mx = data[i];
        if (data[i] < 0)
            has_negative = true;
        sum += data[i];
    }

    return (struct stats){              // 指定初始化器:欄位名自我說明
        .min = mn,
        .max = mx,
        .avg = (double)sum / n,
        .all_non_negative = !has_negative,
    };
}

int main(void)
{
    int data[] = { 7, -3, 42, 0, 15, -8, 23, 4 };
    int n = (int)(sizeof data / sizeof data[0]);

    struct stats s = analyze(data, n);

    // snprintf 取代 sprintf:長度上限保護
    char summary[80];
    snprintf(summary, sizeof summary,
             "min=%d max=%d avg=%.2f non-negative=%s",
             s.min, s.max, s.avg, s.all_non_negative ? "yes" : "no");

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

    // 邊界:空陣列
    struct stats empty = analyze(NULL, 0);
    printf("empty input -> min=%d max=%d avg=%.2f\n",
           empty.min, empty.max, empty.avg);

    return 0;
}

程式碼講解

  • 原版 C89 程式整段保留在註解中——重構題的驗收方式 就是「兩版並排逐行對照」。
  • analyze 的參數升級成 const int *data:函式不改資料, 用 const 說出來(C89 也有 const,但現代風格會堅持用)。
  • 回傳的 struct 以指定初始化器填寫,沒列的欄位自動為 0 ——欄位順序調整也不會錯位,這是位置初始化做不到的。
  • (int)(sizeof data / sizeof data[0]):陣列長度慣用式, cast 成 int 消除 signed/unsigned 比較警告。
  • 易錯處:宣告位置自由不等於亂放——原則是 「最小作用域、宣告即初始化」,不是「想到哪寫到哪」。

範例輸出

data: 7 -3 42 0 15 -8 23 4
min=-8 max=42 avg=10.00 non-negative=no
empty input -> min=0 max=0 avg=0.00

練習 2:指定初始化器 — 月份天數表(難度:基礎)

題目

用指定初始化器建 int days[13]:days[1]~days[12] 是各月天數、 索引 0 不用(對照 K&R §5.7 的 daytab)。以此實作 day_of_year(year, month, day)(含閏年與無效日期處理), 再示範指定初始化器的稀疏查表用法。

解題思路

  1. K&R 原版的痛點:{0, 31, 28, 31, ...} 位置初始化, 讀的人要自己數「第 3 個是幾月」;而且為了閏年開了 兩排表(daytab[2][13])。
  2. 指定初始化器的升級:[1] = 31, [2] = 28, ... 索引就是月份,所見即所得;[0] 沒列——沒指定的元素 自動零初始化,正好表達「不用」。
  3. 兩排表 → 一排表 + 修正函式:閏年只影響 2 月, days[month] + (month == 2 && is_leap(year)) 一個運算式 解決,資料少一半、真相只有一份。
  4. day_of_year:第 1 到 month-1 月的天數和 + day。 驗收點設計成「跨 2 月」的日期(3 月 1 日): 平年 60、閏年 61,一眼看出閏年邏輯生效。
  5. 邊界:月份超界、日超過該月天數 → 回 -1 (2025-02-29 必須被拒——2025 不是閏年)。
  6. 稀疏查表是指定初始化器的殺手級應用: [‘a’] = 1, [‘e’] = 1, ... 256 格的表只寫 5 格, 其餘自動為 0——C89 得寫滿整排或用執行期迴圈填。

參考解答

/*
 * 練習 2:指定初始化器 — 月份天數表(對照 K&R §5.7)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_days_table.c
 * 執行:/tmp/out
 *
 * 重點:int days[13] 以指定初始化器建表(索引 0 不用),
 *       與 K&R daytab 的「位置初始化」寫法對照;
 *       用 day_of_year 驗證表的正確性。
 */
#include <stdio.h>
#include <stdbool.h>

/*
 * K&R §5.7 的原版寫法(位置初始化,讀者要自己數位置):
 *
 *   static char daytab[2][13] = {
 *       {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31},
 *       {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
 *   };
 */

/* C99 指定初始化器版:索引即月份,一眼對上;[0] 沒列 -> 自動為 0 */
static const int days[13] = {
    [1] = 31,  [2] = 28,  [3] = 31,  [4] = 30,
    [5] = 31,  [6] = 30,  [7] = 31,  [8] = 31,
    [9] = 30, [10] = 31, [11] = 30, [12] = 31,
};

static bool is_leap(int year)
{
    /* K&R §2.5 的閏年條件 */
    return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}

/* 某月天數:2 月依閏年 +1,不用第二張表 */
static int days_in_month(int year, int month)
{
    if (month < 1 || month > 12)
        return 0;
    return days[month] + (month == 2 && is_leap(year) ? 1 : 0);
}

/* K&R §5.7 的 day_of_year,改用單一表 + 閏年修正 */
static int day_of_year(int year, int month, int day)
{
    if (month < 1 || month > 12 || day < 1 ||
        day > days_in_month(year, month))
        return -1;                      /* 無效日期 */

    int yday = day;
    for (int m = 1; m < month; m++)
        yday += days_in_month(year, m);
    return yday;
}

int main(void)
{
    printf("month lengths (designated initializers, index = month):\n  ");
    for (int m = 1; m <= 12; m++)
        printf("%d:%d ", m, days[m]);
    printf("\n  days[0] = %d (unlisted -> zero-initialized)\n\n", days[0]);

    printf("day_of_year checks:\n");
    printf("  2025-01-01 -> %3d (expect 1)\n", day_of_year(2025, 1, 1));
    printf("  2025-03-01 -> %3d (expect 60:  31+28+1)\n",
           day_of_year(2025, 3, 1));
    printf("  2024-03-01 -> %3d (expect 61:  31+29+1, leap)\n",
           day_of_year(2024, 3, 1));
    printf("  2025-12-31 -> %3d (expect 365)\n", day_of_year(2025, 12, 31));
    printf("  2024-12-31 -> %3d (expect 366, leap)\n",
           day_of_year(2024, 12, 31));

    printf("\ninvalid dates are rejected:\n");
    printf("  2025-02-29 -> %d (not a leap year)\n",
           day_of_year(2025, 2, 29));
    printf("  2025-13-01 -> %d (no month 13)\n", day_of_year(2025, 13, 1));

    /* 指定初始化器的另一個招牌用法:稀疏查表 */
    static const int vowel[256] = {
        ['a'] = 1, ['e'] = 1, ['i'] = 1, ['o'] = 1, ['u'] = 1,
    };
    const char *word = "modern";
    int nv = 0;
    for (const char *p = word; *p; p++)
        nv += vowel[(unsigned char)*p];
    printf("\nsparse table bonus: \"%s\" has %d vowels\n", word, nv);

    return 0;
}

程式碼講解

  • 表宣告成 static const:唯讀資料進唯讀段, 誤寫會被作業系統攔下。
  • days_in_month 把「查表+閏年修正+範圍檢查」封成一個 函式——day_of_year 的迴圈與驗證都呼叫它, 閏年邏輯不重複。
  • 驗收輸出直接把預期值印在旁邊(expect 61: 31+29+1, leap) ——測試程式的可讀性等於它的價值。
  • 母音表的 (unsigned char)*p cast:char 可能是有號的, 負值當索引是 UB——查表老規矩。
  • 易錯處:指定初始化與位置初始化混用時,位置從 「最後一個指定的索引 +1」繼續——混用容易迷路, 建議全用指定式。

範例輸出

month lengths (designated initializers, index = month):
  1:31 2:28 3:31 4:30 5:31 6:30 7:31 8:31 9:30 10:31 11:30 12:31 
  days[0] = 0 (unlisted -> zero-initialized)

day_of_year checks:
  2025-01-01 ->   1 (expect 1)
  2025-03-01 ->  60 (expect 60:  31+28+1)
  2024-03-01 ->  61 (expect 61:  31+29+1, leap)
  2025-12-31 -> 365 (expect 365)
  2024-12-31 -> 366 (expect 366, leap)

invalid dates are rejected:
  2025-02-29 -> -1 (not a leap year)
  2025-13-01 -> -1 (no month 13)

sparse table bonus: "modern" has 2 vowels

練習 3:彈性陣列成員 — 一次配置的字串物件(難度:進階)

題目

用彈性陣列成員(flexible array member)實作 struct string { size_t len; char data[]; } 與建構函式 string_new(const char *)、串接函式 string_concat。 與「指標成員+兩次 malloc」的傳統寫法並排比較: 配置次數、記憶體佈局、釋放方式。

解題思路

  1. 問題:「header + 變長資料」是 C 最常見的物件形狀 (封包、字串、訊息)。傳統寫法用指標成員:
寫法 A(兩塊):  [len | data*] ----------> [ 實際字元... ]
寫法 B(一塊):  [len | 實際字元........ ]

A 要兩次 malloc、兩次 free、指標可能懸空; B 一塊搞定——這就是 C99 彈性陣列成員的存在理由。 2. 語法規則:char data[]; 必須是最後一個成員、 struct 至少要有一個其他成員;sizeof(struct string) 不含 data(輸出裡驗證:8 位元組,恰好是 len)。 3. 配置公式:malloc(sizeof *s + len + 1)—— header 的大小 + 字元數 + '\0'。錯誤處理照舊。 4. 驗證「真的只有一塊」:印出 header 位址與 data 位址, B 的 data 緊跟在 header 後(差 8 位元組 = offsetof), A 的兩個位址天差地遠。 5. 操作示範:string_concat 配一塊新的、兩段 memcpy (第二段連 '\0' 一起搬)——FAM 物件的慣用操作模式。 6. 何時仍要用 A:資料要原地變長(B 變長就要整塊 realloc,header 位址會變);或 struct 本身要放進陣列 (FAM struct 不能當陣列元素)。取捨要講清楚。

參考解答

/*
 * 練習 3:彈性陣列成員 — 一次配置的字串物件
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_flex_string.c
 * 執行:/tmp/out
 *
 * 重點:struct string { size_t len; char data[]; } 的
 *       「header + 變長資料」單塊配置,
 *       對照「struct + 另外 malloc 的指標」的雙塊寫法。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>

/* ---------- 寫法 A:指標成員(兩次 malloc) ---------- */

struct string_ptr {
    size_t len;
    char  *data;            /* 指向「另一塊」記憶體 */
};

static struct string_ptr *string_ptr_new(const char *src)
{
    struct string_ptr *s = malloc(sizeof *s);       /* 第 1 次 */
    if (s == NULL)
        return NULL;
    s->len = strlen(src);
    s->data = malloc(s->len + 1);                   /* 第 2 次 */
    if (s->data == NULL) {
        free(s);
        return NULL;
    }
    memcpy(s->data, src, s->len + 1);
    return s;
}

static void string_ptr_free(struct string_ptr *s)
{
    if (s != NULL) {
        free(s->data);      /* 兩塊都要釋放,順序:先內後外 */
        free(s);
    }
}

/* ---------- 寫法 B:彈性陣列成員(一次 malloc) ---------- */

struct string {
    size_t len;
    char   data[];          /* C99 彈性陣列成員:必須是最後一個成員 */
};

static struct string *string_new(const char *src)
{
    size_t len = strlen(src);
    /* sizeof *s 不含 data[];一次配置 header + 資料 + '\0' */
    struct string *s = malloc(sizeof *s + len + 1);
    if (s == NULL)
        return NULL;
    s->len = len;
    memcpy(s->data, src, len + 1);
    return s;
}

/* 串接:回傳新的 string(展示 FAM 物件當值傳遞的模式) */
static struct string *string_concat(const struct string *a,
                                    const struct string *b)
{
    struct string *s = malloc(sizeof *s + a->len + b->len + 1);
    if (s == NULL)
        return NULL;
    s->len = a->len + b->len;
    memcpy(s->data, a->data, a->len);
    memcpy(s->data + a->len, b->data, b->len + 1);  /* 連 '\0' 一起 */
    return s;
}

int main(void)
{
    printf("sizeof(struct string_ptr) = %zu (len + pointer)\n",
           sizeof(struct string_ptr));
    printf("sizeof(struct string)     = %zu (len only -- data[] adds 0)\n\n",
           sizeof(struct string));

    /* --- 寫法 A --- */
    struct string_ptr *p = string_ptr_new("two mallocs");
    printf("A: \"%s\" (len=%zu)\n", p->data, p->len);
    printf("   header at %p, data at %p -- two separate blocks\n",
           (void *)p, (void *)p->data);

    /* --- 寫法 B --- */
    struct string *q = string_new("one malloc");
    printf("B: \"%s\" (len=%zu)\n", q->data, q->len);
    printf("   header at %p, data at %p -- data follows header\n",
           (void *)q, (void *)q->data);
    printf("   gap = %td bytes (= offsetof data)\n\n",
           (char *)q->data - (char *)q);

    /* --- FAM 的操作示範:串接 --- */
    struct string *hello = string_new("hello, ");
    struct string *world = string_new("modern C");
    struct string *both = string_concat(hello, world);
    printf("concat: \"%s\" (len=%zu)\n\n", both->data, both->len);

    printf("why B wins:\n");
    printf("  - 1 malloc + 1 free instead of 2 + 2 (fewer leaks possible)\n");
    printf("  - header and data share one cache line region (locality)\n");
    printf("  - no dangling data pointer to keep in sync\n");
    printf("why A still exists:\n");
    printf("  - data can be resized without moving the header\n");
    printf("  - the struct itself can live in an array (FAM structs "
           "cannot)\n");

    /* 全部釋放:B 系列一塊一 free */
    string_ptr_free(p);
    free(q);
    free(hello);
    free(world);
    free(both);
    return 0;
}

程式碼講解

  • string_ptr_new 的錯誤路徑:第二次 malloc 失敗要把 第一塊 free 掉再回 NULL——兩塊式寫法的隱形成本 就是這種「半成品回滾」。
  • string_new 沒有這個問題:一次配置,成敗一翻兩瞪眼。
  • memcpy(s->data, src, len + 1):+1 連結尾 '\0' 一起搬——len 已知時 memcpy 比 strcpy 快也更明確。
  • string_concat 第二段 memcpy(s->data + a->len, b->data, b->len + 1):目的位移用 a 的長度、來源連 '\0'—— 接縫處的 off-by-one 是這種函式的著名埋伏點。
  • 釋放對稱:A 用 string_ptr_free(先內後外兩次 free)、 B 直接一次 free(q)——釋放程式碼的行數就是複雜度的 誠實指標。
  • 易錯處:對 FAM struct 做 sizeof 算配置量時把 data 算進去 ——data 貢獻 0,公式必須手動加上資料長度。

範例輸出

sizeof(struct string_ptr) = 16 (len + pointer)
sizeof(struct string)     = 8 (len only -- data[] adds 0)

A: "two mallocs" (len=11)
   header at 0x102a994f0, data at 0x102a99500 -- two separate blocks
B: "one malloc" (len=10)
   header at 0x102a996b0, data at 0x102a996b8 -- data follows header
   gap = 8 bytes (= offsetof data)

concat: "hello, modern C" (len=15)

why B wins:
  - 1 malloc + 1 free instead of 2 + 2 (fewer leaks possible)
  - header and data share one cache line region (locality)
  - no dangling data pointer to keep in sync
why A still exists:
  - data can be resized without moving the header
  - the struct itself can live in an array (FAM structs cannot)

(指標位址隨執行環境而異。)


練習 4:_Generic — print_any(x)(難度:進階)

題目

用 _Generic 寫 print_any(x) 巨集:依 x 的型別自動選對 printf 格式,至少支援 int / unsigned / long / double / float / char / bool / char * / const char * / void *。 另寫一個 type_name(x) 巨集回傳型別名字串。 用實驗展示 _Generic 匹配「不含隱式轉換」的特性。

解題思路

  1. _Generic 是編譯期的型別 switch:
_Generic((x), int: A, double: B, default: C)

依 (x) 的型別選出一個運算式,其餘分支不會被評估。 選函式再統一 (x) 呼叫——print_any(x) 展開成 _Generic((x), int: print_int, ...)(x)。 2. 為什麼選「函式」不是選「格式字串」:每個型別的 printf 引數轉換不同(float 要升 double、char 要 %c); 選函式讓每個分支有自己的完整呼叫。 3. 匹配不含轉換——本題最重要的觀念,實驗逐一驗證: - 3.14f 是 float,不會「順便」匹配 double: 兩個型別都要列; - 'A' 的型別是 int(C 的字元常量不是 char!), 要 (char)'A' 才走 char 分支; - C11 <stdbool.h> 的 true 是巨集 1(int)—— print_any(true) 印出 int: 1,(bool)true 才是 bool; - char * 與 const char * 是不同型別,各列一項。 4. default 的取捨:本解答刻意不放 default—— 塞進不支援的型別直接編譯錯誤,比執行期印垃圾好。 type_name 版本則放 default(回 "other")示範另一種選擇。 5. type_name(1 + 1.0) 回 double:_Generic 看的是 運算式經過一般算術轉換後的結果型別—— 「不含轉換」指的是分支匹配,不是控制運算式的求值型別。

參考解答

/*
 * 練習 4:_Generic — print_any(x) 依型別自選 printf 格式
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_generic_print.c
 * 執行:/tmp/out
 *
 * 重點:_Generic 是「編譯期的型別 switch」——
 *       依運算式的型別選一個分支,其他分支不會被生成。
 *       注意匹配不含隱式轉換:float 與 double 是不同項。
 */
#include <stdio.h>
#include <stdbool.h>

/*
 * print_any(x):印出「型別名: 值」。
 * 每個分支都是一個完整的 printf 呼叫——
 * _Generic 選的是「運算式」,不只是格式字串,
 * 所以每個分支可以用不同的引數轉換(例如 %c 要 int)。
 */
#define print_any(x) _Generic((x),                                     \
        int:          print_int,                                       \
        unsigned int: print_uint,                                      \
        long:         print_long,                                      \
        double:       print_double,                                    \
        float:        print_float,                                     \
        char:         print_char,                                      \
        bool:         print_bool,                                      \
        char *:       print_str,                                       \
        const char *: print_cstr,                                      \
        void *:       print_ptr)(x)
/* 刻意「不」放 default:塞進不支援的型別會直接編譯錯誤——
   錯誤在編譯期爆開,比執行期印垃圾好得多。 */

static void print_int(int v)            { printf("int:          %d\n", v); }
static void print_uint(unsigned v)      { printf("unsigned int: %u\n", v); }
static void print_long(long v)          { printf("long:         %ld\n", v); }
static void print_double(double v)      { printf("double:       %f\n", v); }
static void print_float(float v)        { printf("float:        %f\n", (double)v); }
static void print_char(char v)          { printf("char:         '%c'\n", v); }
static void print_bool(bool v)          { printf("bool:         %s\n", v ? "true" : "false"); }
static void print_str(char *v)          { printf("char *:       \"%s\"\n", v); }
static void print_cstr(const char *v)   { printf("const char *: \"%s\"\n", v); }
static void print_ptr(void *v)          { printf("void *:       %p\n", v); }

/* 只取型別名的版本:分支是字串常量,不是函式 */
#define type_name(x) _Generic((x),                                     \
        int: "int", unsigned int: "unsigned int", long: "long",        \
        double: "double", float: "float", char: "char",                \
        bool: "bool", char *: "char *", const char *: "const char *",  \
        void *: "void *", default: "other")

int main(void)
{
    int n = 42;

    printf("=== print_any dispatches on type ===\n");
    print_any(42);                  /* int */
    print_any(42u);                 /* unsigned int */
    print_any(42L);                 /* long */
    print_any(3.14);                /* double */
    print_any(3.14f);               /* float:不會「順便」匹配 double */
    print_any('A');                 /* 注意:字元常量的型別其實是 int! */
    print_any((char)'A');           /* 這才是 char */
    print_any(true);                /* 陷阱:C11 stdbool 的 true 是巨集 1(int)! */
    print_any((bool)true);          /* cast 之後才是 bool */
    print_any("hello");             /* 字串字面值 -> char * */
    print_any((const char *)"ro");  /* const char * 是另一個型別 */
    print_any((void *)&n);          /* void * */

    printf("\n=== type_name inspects without printing values ===\n");
    printf("type_name(1 + 1)     = %s\n", type_name(1 + 1));
    printf("type_name(1 + 1.0)   = %s (usual conversions applied)\n",
           type_name(1 + 1.0));
    printf("type_name('x')       = %s (character constants are int!)\n",
           type_name('x'));
    printf("type_name(\"s\")       = %s\n", type_name("s"));
    printf("type_name(3.14f)     = %s\n", type_name(3.14f));
    printf("type_name(&n)        = %s (int * hits default)\n",
           type_name(&n));

    return 0;
}

程式碼講解

  • 巨集的反斜線續行排成一欄——_Generic 分支多, 排版直接決定可維護性。
  • print_float 內部 (double)v 再印:可變參數的預設 提升本來就會把 float 升成 double,顯式 cast 讓 -Wdouble-promotion 之類的警告安靜。
  • 實驗輸出的三個「陷阱行」:print_any('A') → int: 65、 print_any(true) → int: 1、type_name('x') → int ——字元常量與 stdbool 巨集的真面目,考試常考。
  • type_name(&n) 走 default(int * 沒列)—— 指標型別族繁不及備載,default 兜底是 type_name 這種 「診斷用」巨集的合理選擇。
  • 易錯處:以為 _Generic 會做隱式轉換而少列型別—— 「float 進來卻沒 float 分支」在沒有 default 時是 編譯錯誤(幸運),在有 default 時走錯分支(不幸)。

範例輸出

=== print_any dispatches on type ===
int:          42
unsigned int: 42
long:         42
double:       3.140000
float:        3.140000
int:          65
char:         'A'
int:          1
bool:         true
char *:       "hello"
const char *: "ro"
void *:       0x16f349638

=== type_name inspects without printing values ===
type_name(1 + 1)     = int
type_name(1 + 1.0)   = double (usual conversions applied)
type_name('x')       = int (character constants are int!)
type_name("s")       = char *
type_name(3.14f)     = float
type_name(&n)        = other (int * hits default)

(void * 的位址隨執行環境而異。)


練習 5:C23 權限旗標系統(難度:挑戰)

題目

(需 -std=c2x) 用 C23 特性重寫位元旗標式的權限系統:

  • enum perm : unsigned char(底層型別)+二進位字面值定義旗標;
  • constexpr 定義 PERM_ALL 與角色數(可當陣列大小);
  • static_assert(關鍵字版)驗證 enum 大小、旗標不重疊、 旗標聯集恰為 PERM_ALL;
  • [[nodiscard]]、nullptr、數字分隔符、typeof 各用一次。

實作 grant / revoke / has_perm 並展示角色權限表。

解題思路

  1. 這題是「位元運算單元 × C23 語法」的合體: 邏輯照舊(| 授權、& ~ 撤銷、& 檢查), 語法全面換新——看新特性怎麼讓舊程式更嚴謹。
  2. 0b0001 二進位字面值:旗標定義所見即位元, C11 時代的 1 << 0、0x1 都是折衷方案。
  3. enum 底層型別 : unsigned char:C23 之前 enum 的 大小由編譯器決定(通常 int);指定後 sizeof(enum perm) == 1 成為可斷言的保證—— 封包格式、暫存器映射這些「大小敏感」場景的剛需。
  4. constexpr vs #define vs const: - #define:無型別、無作用域; - const int:C 裡不是常數運算式(不能當 檔案作用域的陣列大小——這是 C/C++ 的著名差異); - C23 constexpr:真正的編譯期常數,能當陣列大小、 能進 static_assert。N_ROLES 當 roles[] 大小就是實證。
  5. static_assert 驗證「設計不變量」:旗標不重疊 (READ & WRITE == 0)、聯集恰為全集——這些以前只能 靠 code review 的約定,現在編譯器每次都幫你查。
  6. [[nodiscard]]:has_perm 的回傳值被丟棄毫無意義 (純查詢函式),標上讓誤用變警告。
  7. nullptr 的型別是 nullptr_t,不像 NULL 可能是 0 或 (void*)0——在 _Generic 與可變參數裡行為 明確得多。

參考解答

/*
 * 練習 5:C23 權限旗標系統 — constexpr、enum 底層型別、
 *         二進位字面值、static_assert
 *
 * 編譯:cc -std=c2x -Wall -Wextra -o /tmp/out ex5_c23_perms.c
 * 執行:/tmp/out
 *
 * ★ 本檔需要 C23(Apple clang 用 -std=c2x)。
 * 重點:0b 字面值讓旗標「所見即位元」、enum 可指定底層型別
 *       (儲存大小可控)、constexpr 是真正的編譯期常數、
 *       static_assert 直接是關鍵字。
 */
#include <stdio.h>

/* enum 底層型別(C23):權限旗標塞進 1 個位元組 */
enum perm : unsigned char {
    PERM_NONE  = 0b0000,        /* 二進位字面值(C23):位元所見即所得 */
    PERM_READ  = 0b0001,
    PERM_WRITE = 0b0010,
    PERM_EXEC  = 0b0100,
    PERM_ADMIN = 0b1000,
};

/* constexpr(C23):真正的編譯期常數,可當陣列大小、可 static_assert */
constexpr unsigned char PERM_ALL = 0b1111;
constexpr int N_ROLES = 4;

/* static_assert 在 C23 是關鍵字(不再需要 _Static_assert 或 <assert.h>) */
static_assert(sizeof(enum perm) == 1, "perm must fit in one byte");
static_assert((PERM_READ | PERM_WRITE | PERM_EXEC | PERM_ADMIN) == PERM_ALL,
              "flags must cover PERM_ALL exactly");
static_assert((PERM_READ & PERM_WRITE) == 0, "flags must not overlap");

/* 角色權限表:指定初始化 + 數字分隔符示範放在 main */
struct role {
    const char *name;
    unsigned char perms;
};

static const struct role roles[N_ROLES] = {   /* constexpr 當陣列大小 */
    { .name = "guest",  .perms = PERM_READ },
    { .name = "editor", .perms = PERM_READ | PERM_WRITE },
    { .name = "runner", .perms = PERM_READ | PERM_EXEC },
    { .name = "admin",  .perms = PERM_ALL },
};

/* [[nodiscard]](C23 屬性):呼叫者丟棄回傳值會收到警告 */
[[nodiscard]] static bool has_perm(unsigned char perms, enum perm p)
{
    return (perms & p) != 0;    /* bool/true/false 在 C23 是關鍵字 */
}

static unsigned char grant(unsigned char perms, enum perm p)
{
    return perms | p;
}

static unsigned char revoke(unsigned char perms, enum perm p)
{
    return perms & (unsigned char)~p;
}

static void print_perms(const char *who, unsigned char perms)
{
    printf("  %-8s %c%c%c%c  (0b%d%d%d%d)\n", who,
           has_perm(perms, PERM_READ)  ? 'r' : '-',
           has_perm(perms, PERM_WRITE) ? 'w' : '-',
           has_perm(perms, PERM_EXEC)  ? 'x' : '-',
           has_perm(perms, PERM_ADMIN) ? 'a' : '-',
           has_perm(perms, PERM_ADMIN) ? 1 : 0,
           has_perm(perms, PERM_EXEC)  ? 1 : 0,
           has_perm(perms, PERM_WRITE) ? 1 : 0,
           has_perm(perms, PERM_READ)  ? 1 : 0);
}

int main(void)
{
    printf("__STDC_VERSION__ = %ld (C23 is 202311)\n", __STDC_VERSION__);
    printf("sizeof(enum perm) = %zu byte (underlying type: unsigned char)\n\n",
           sizeof(enum perm));

    printf("role table:\n");
    for (int i = 0; i < N_ROLES; i++)
        print_perms(roles[i].name, roles[i].perms);

    printf("\npromote guest -> grant WRITE, then EXEC:\n");
    unsigned char p = roles[0].perms;
    p = grant(p, PERM_WRITE);
    print_perms("guest+", p);
    p = grant(p, PERM_EXEC);
    print_perms("guest++", p);

    printf("\ndemote admin -> revoke ADMIN:\n");
    unsigned char q = revoke(roles[3].perms, PERM_ADMIN);
    print_perms("ex-admin", q);

    /* nullptr(C23):型別安全的空指標常量 */
    const struct role *found = nullptr;
    for (int i = 0; i < N_ROLES; i++)
        if (has_perm(roles[i].perms, PERM_ADMIN))
            found = &roles[i];
    printf("\nfirst role with ADMIN: %s\n",
           found != nullptr ? found->name : "(none)");

    /* 數字分隔符(C23):大數字的可讀性 */
    constexpr long big = 1'000'000'000;
    printf("digit separators: 1'000'000'000 = %ld\n", big);

    /* typeof(C23):取運算式的型別 */
    typeof(p) backup = p;       /* backup 與 p 同型別 */
    printf("typeof demo: backup = 0x%02X (same type as p)\n", backup);

    return 0;
}

程式碼講解

  • 檔頭註明 -std=c2x:Apple clang 21 用 c2x (c23 別名在新版 clang/gcc 才有),本檔所有特性 皆在 macOS 實測通過。
  • 三個 static_assert 直接寫在檔案作用域——編譯期驗證 不用等到 main 執行。
  • revoke 裡 (unsigned char)~p:~ 會先把運算元提升成 int,結果高位全是 1——塞回 unsigned char 前 cast, 避免轉換警告(位元運算單元的老朋友)。
  • print_perms 同時印 rwxa 字串與 0b 形式—— 同一個位元組的兩種讀法。
  • typeof(p) backup = p:與 p 同型別的變數,改 p 的型別 宣告也跟著變——巨集與泛型程式碼的黏著劑(GNU 擴充 轉正為標準)。
  • 易錯處:constexpr 變數仍是「物件」, 和 #define 不同——它有位址、遵守作用域; 但初始化器必須是常數運算式。

範例輸出

__STDC_VERSION__ = 202311 (C23 is 202311)
sizeof(enum perm) = 1 byte (underlying type: unsigned char)

role table:
  guest    r---  (0b0001)
  editor   rw--  (0b0011)
  runner   r-x-  (0b0101)
  admin    rwxa  (0b1111)

promote guest -> grant WRITE, then EXEC:
  guest+   rw--  (0b0011)
  guest++  rwx-  (0b0111)

demote admin -> revoke ADMIN:
  ex-admin rwx-  (0b0111)

first role with ADMIN: admin
digit separators: 1'000'000'000 = 1000000000
typeof demo: backup = 0x07 (same type as p)

練習 6:特性偵測 — 一份原始碼三種標準(難度:挑戰)

題目

寫一支「特性偵測」程式,在 -std=c99、-std=c11、-std=c2x 下都能零警告編譯,執行時回報:標準版本、編譯器、 <stdatomic.h>/<threads.h> 可用性、[[nodiscard]] 支援、 靜態斷言的可用寫法、VLA 支援;並依版本走不同的示範程式碼路徑 (C23 用 constexpr/nullptr、C11 用 _Generic、C99 用 long long)。

解題思路

  1. 可攜性程式的三層武器,本題全用上: - __STDC_VERSION__:數字比大小分版(C99=199901L、 C11=201112L、C23=202311L);先 defined 再比, C89 沒定義這個巨集; - __has_include:標頭存在性(C23 才標準化, clang/gcc 早就有); - __has_c_attribute:屬性支援度(C23)。
  2. 「先問存在、再問內容」的雙層防護——本題最核心的 工程紀律:
#ifdef __has_include
#  if __has_include(<threads.h>)
...

不能寫成 #if defined(__has_include) && __has_include(...) ——不認識 __has_include 的舊前置處理器在同一行 就會噴錯;拆成巢狀,舊環境根本不會看到第二行。 3. 旗標歸一:偵測結果統一收斂成 HAVE_XXX 0/1 巨集 (#ifndef 補 0)——後面的程式碼只看 HAVE_XXX, 偵測邏輯與使用邏輯分離。 4. 靜態斷言的三個時代:C23 關鍵字 static_assert、 C11 _Static_assert、C99 的負數陣列大小戲法 (typedef char a[cond ? 1 : -1]——條件為假時 陣列大小 -1 直接編譯錯誤)。一份檔案三種寫法並存, 靠版本分流。 5. 版本分流的程式碼路徑要真的執行出差異: 同一份原始碼、三次編譯、三種輸出—— 這正是跨十年工具鏈的程式庫(如 zlib、sqlite)的日常。 6. macOS 的預期結果:<stdatomic.h> 有、<threads.h> 沒有 ——README 講過的平台現實,這支程式讓它變成可執行的 驗證。

參考解答

/*
 * 練習 6:特性偵測 — 一份原始碼在 C99 / C11 / C23 下都能編譯
 *
 * 編譯(三種標準都試試,輸出會不同):
 *   cc -std=c99 -Wall -Wextra -o /tmp/out ex6_feature_detect.c && /tmp/out
 *   cc -std=c11 -Wall -Wextra -o /tmp/out ex6_feature_detect.c && /tmp/out
 *   cc -std=c2x -Wall -Wextra -o /tmp/out ex6_feature_detect.c && /tmp/out
 *
 * 重點:__STDC_VERSION__ 分版、__has_include / __has_c_attribute
 *       的「先問存在、再問內容」雙層防護、
 *       條件編譯讓新特性只在支援的環境現身。
 */
#include <stdio.h>

/* ---- 版本判定:數字比大小 ---- */
#if !defined(__STDC_VERSION__)
#  define STD_NAME "C89/C90 (no __STDC_VERSION__)"
#elif __STDC_VERSION__ >= 202311L
#  define STD_NAME "C23"
#elif __STDC_VERSION__ >= 201710L
#  define STD_NAME "C17"
#elif __STDC_VERSION__ >= 201112L
#  define STD_NAME "C11"
#elif __STDC_VERSION__ >= 199901L
#  define STD_NAME "C99"
#else
#  define STD_NAME "unknown"
#endif

/*
 * __has_include 本身不是所有前置處理器都有——
 * 必須先 #ifdef 再使用,而且要在「巢狀的 #if」裡問,
 * 否則舊前置處理器看到不認識的 __has_include 直接報錯。
 */
#ifdef __has_include
#  if __has_include(<stdatomic.h>)
#    define HAVE_STDATOMIC 1
#  endif
#  if __has_include(<threads.h>)
#    define HAVE_THREADS 1
#  endif
#endif

#ifndef HAVE_STDATOMIC
#  define HAVE_STDATOMIC 0
#endif
#ifndef HAVE_THREADS
#  define HAVE_THREADS 0
#endif

/* __has_c_attribute:C23 起才有,同樣先問存在 */
#ifdef __has_c_attribute
#  if __has_c_attribute(nodiscard)
#    define HAVE_NODISCARD 1
#  endif
#endif
#ifndef HAVE_NODISCARD
#  define HAVE_NODISCARD 0
#endif

/* 依標準版本挑「當前最好的靜態斷言寫法」 */
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
   static_assert(sizeof(int) >= 4, "int too small");   /* C23 關鍵字 */
#  define ASSERT_STYLE "static_assert (C23 keyword)"
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
   _Static_assert(sizeof(int) >= 4, "int too small");  /* C11 底線版 */
#  define ASSERT_STYLE "_Static_assert (C11)"
#else
   /* C99:沒有靜態斷言,用「負數陣列大小」的老把戲 */
   typedef char c99_assert_int_size[sizeof(int) >= 4 ? 1 : -1];
#  define ASSERT_STYLE "negative-array-size trick (C99)"
#endif

static const char *yes_no(int v)
{
    return v ? "yes" : "no";
}

int main(void)
{
    printf("=== compile-time environment report ===\n");
#ifdef __STDC_VERSION__
    printf("  __STDC_VERSION__ : %ld\n", (long)__STDC_VERSION__);
#else
    printf("  __STDC_VERSION__ : (not defined)\n");
#endif
    printf("  detected standard: %s\n", STD_NAME);
#ifdef __clang__
    printf("  compiler         : clang %d.%d\n",
           __clang_major__, __clang_minor__);
#elif defined(__GNUC__)
    printf("  compiler         : gcc %d.%d\n", __GNUC__, __GNUC_MINOR__);
#else
    printf("  compiler         : (unknown)\n");
#endif

    printf("\n=== feature availability ===\n");
    printf("  <stdatomic.h>        : %s\n", yes_no(HAVE_STDATOMIC));
    printf("  <threads.h>          : %s (macOS: expected no)\n",
           yes_no(HAVE_THREADS));
    printf("  [[nodiscard]]        : %s\n", yes_no(HAVE_NODISCARD));
    printf("  static assert style  : %s\n", ASSERT_STYLE);

#if defined(__STDC_NO_VLA__)
    printf("  VLA                  : no (__STDC_NO_VLA__ defined)\n");
#else
    printf("  VLA                  : yes\n");
#endif

    printf("\n=== version-gated code paths ===\n");
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
    /* 這一段只有 C23 編譯器看得到 */
    constexpr int answer = 0b101010;        /* constexpr + 二進位字面值 */
    printf("  C23 path : constexpr answer = %d (from 0b101010)\n", answer);
    printf("  C23 path : nullptr is %s\n",
           nullptr == (void *)0 ? "== (void*)0" : "?!");
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
    /* C11:_Generic 可用 */
    printf("  C11 path : _Generic says 42 is %s\n",
           _Generic(42, int: "int", default: "other"));
#else
    /* C99:至少有 long long 與 %zu */
    long long big = 9000000000LL;
    printf("  C99 path : long long works, big = %lld\n", big);
#endif

    printf("\nsame source file, three different builds -- this is how\n"
           "portable libraries stay buildable for a decade.\n");
    return 0;
}

程式碼講解

  • 版本判定鏈從高版本往低比(>= 202311L 在前)—— 反過來寫低版本會先攔截住高版本。
  • (long)__STDC_VERSION__ cast 後用 %ld:這個巨集的 型別是 long 字面值,cast 明示意圖。
  • C99 的負數陣列戲法包在 typedef——不佔記憶體、 不生成程式碼,純粹讓編譯器評估條件。
  • __STDC_NO_VLA__:C11 起 VLA 是選配,編譯器用這個 巨集聲明「我沒有」——查「沒有」的巨集而不是「有」的, 是 C11 選配特性的統一模式(__STDC_NO_ATOMICS__ 等同理)。
  • 三次編譯的輸出並排讀:報告區只有標準名與斷言寫法變、 特性區完全相同(編譯器沒變)、示範區走了三條不同的路—— 每一行差異都能解釋。
  • 易錯處:在 #if 運算式裡直接用未定義巨集—— 未定義巨集在 #if 中默默當 0,拼錯字不會報錯 (-Wundef 可以抓);defined() 先問存在最穩。

範例輸出

-std=c99:

=== compile-time environment report ===
  __STDC_VERSION__ : 199901
  detected standard: C99
  compiler         : clang 21.0

=== feature availability ===
  <stdatomic.h>        : yes
  <threads.h>          : no (macOS: expected no)
  [[nodiscard]]        : yes
  static assert style  : negative-array-size trick (C99)
  VLA                  : yes

=== version-gated code paths ===
  C99 path : long long works, big = 9000000000

same source file, three different builds -- this is how
portable libraries stay buildable for a decade.

-std=c11(差異行):

  __STDC_VERSION__ : 201112
  detected standard: C11
  static assert style  : _Static_assert (C11)
  C11 path : _Generic says 42 is int

-std=c2x(差異行):

  __STDC_VERSION__ : 202311
  detected standard: C23
  static assert style  : static_assert (C23 keyword)
  C23 path : constexpr answer = 42 (from 0b101010)
  C23 path : nullptr is == (void*)0

常見錯誤解法對照

錯誤寫法 症狀 正解
sprintf 無上限輸出 緩衝區溢位 snprintf(buf, sizeof buf, ...)(練習 1)
位置初始化長表 數位置數到眼花、插入即錯位 指定初始化器(練習 2)
FAM 配置忘了加資料長度 sizeof 不含 data[],立即越界 malloc(sizeof *s + len + 1)(練習 3)
以為 _Generic 會隱式轉換 float 走錯分支或編譯錯誤 每個型別各列一項(練習 4)
'A'、true 當 char/bool _Generic 選到 int 分支 字元常量與 stdbool 巨集都是 int(練習 4)
const int 當檔案作用域陣列大小 C 編譯錯誤(C++ 才行) C23 constexpr(練習 5)
#if defined(__has_include) && __has_include(...) 同行 舊前置處理器直接報錯 拆成巢狀 #ifdef + #if(練習 6)