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

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

練習 1:typedef Matrix3 —— 為陣列型別取別名(難度:基礎)

題目

用 typedef 定義 Matrix3(double [3][3] 的別名), 寫一個列印矩陣的函式 print_matrix3,再寫一個把矩陣每個元素乘上純量的 scale_matrix3。 印出 sizeof(Matrix3) 驗證它就是 9 個 double。

解題思路

  1. 陣列型別的 typedef 語法:把「宣告一個變數」的寫法前面加上 typedef, 變數名的位置就變成型別別名:
double  m[3][3];               /* 宣告變數 m */
typedef double Matrix3[3][3];  /* 宣告別名 Matrix3 */

注意別名 Matrix3 出現在中間而不是最後——陣列 typedef 的語法初看不直觀, 記住「跟宣告變數長一樣」就好。 2. 用別名宣告變數:Matrix3 a = { ... }; 與 double a[3][3] = { ... }; 完全等價。 3. 函式參數:void print_matrix3(const Matrix3 m)—— 陣列參數照樣退化:Matrix3 m 實際上是 double (*m)[3]。 所以 scale_matrix3 內的修改會直接反映到呼叫端(不是複本)。 typedef 不會改變退化規則,它只是讓簽名更好讀。 4. 驗收:sizeof(Matrix3) 應是 9 × 8 = 72 bytes。 注意在參數 m 上做 sizeof m 只會得到指標大小——退化陷阱依舊, sizeof 要作用在型別或原始陣列上。

參考解答

/*
 * ex1_matrix3_typedef.c
 * ---------------------
 * 練習 1:typedef Matrix3 —— 為 double[3][3] 取別名(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_matrix3_typedef.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/*
 * Matrix3 是「3×3 double 陣列」的別名。
 * typedef 陣列型別的語法和宣告變數一樣,只是前面多了 typedef:
 *   double m[3][3];          <- 宣告變數
 *   typedef double Matrix3[3][3];  <- 宣告型別別名
 */
typedef double Matrix3[3][3];

/*
 * 陣列型別的參數一樣會退化:Matrix3 m 等同 double m[][3],
 * 也就是 double (*m)[3]——所以函式內修改會影響呼叫端。
 * 這裡只讀,加 const。
 */
void print_matrix3(const char *name, const Matrix3 m)
{
    printf("%s =\n", name);
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++)
            printf("%8.2f", m[i][j]);
        printf("\n");
    }
}

/* 順手示範:把矩陣的每個元素乘上純量 */
void scale_matrix3(Matrix3 m, double k)
{
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            m[i][j] *= k;
}

int main(void)
{
    /* 用別名宣告變數:跟 double a[3][3] 完全等價 */
    Matrix3 a = {
        { 1.0, 2.0, 3.0 },
        { 4.0, 5.0, 6.0 },
        { 7.0, 8.0, 9.0 },
    };

    print_matrix3("A", a);

    scale_matrix3(a, 0.5);
    print_matrix3("A × 0.5", a);

    printf("sizeof(Matrix3) = %zu bytes(9 個 double)\n", sizeof(Matrix3));

    return 0;
}

程式碼講解

  • typedef double Matrix3[3][3];:typedef 不創造新型別,只是別名—— Matrix3 與 double [3][3] 可互換使用、互相指派。
  • scale_matrix3(a, 0.5) 之後 a 真的變了:參數退化成指標, 函式操作的是呼叫端的矩陣本體。這既是方便(不用回傳大陣列)也是陷阱 (不想被改就傳 const)。
  • sizeof(Matrix3) = 72 而不是 8:sizeof 作用在型別上時看到完整陣列。
  • 常見疑問:為什麼不 typedef double **Matrix3?——那是完全不同的結構 (指標的指標,需要指標表),與二維陣列不相容,正是上上單元強調的區別。

範例輸出

A =
    1.00    2.00    3.00
    4.00    5.00    6.00
    7.00    8.00    9.00
A × 0.5 =
    0.50    1.00    1.50
    2.00    2.50    3.00
    3.50    4.00    4.50
sizeof(Matrix3) = 72 bytes(9 個 double)

練習 2:enum weekday 與 is_weekend(難度:基礎)

題目

定義 enum weekday(星期一到星期日),寫函式 int is_weekend(enum weekday d)。 走訪整週印出每天是工作日還是週末;印出某個列舉常數的底層整數值; 最後示範「enum 變數存放範圍外的值」的陷阱與守衛。

解題思路

  1. 定義 enum:enum weekday { MON = 1, TUE, WED, ... };—— 指定第一個為 1,其餘自動遞增(TUE=2、…、SUN=7)。 從 1 開始讓值和日常的「星期幾」直覺對應。
  2. is_weekend 一行:return d == SAT || d == SUN; ——enum 常數就是具名整數,比較運算直接用。
  3. 走訪整週:enum 底層是整數,for (int d = MON; d <= SUN; d++) 可行; 把 int 傳給 enum weekday 參數時加明確轉型 (enum weekday)d, 對讀者標明「我知道我在做整數↔enum 的轉換」。
  4. 陷阱示範:C 不阻止 enum weekday bogus = (enum weekday)42;—— enum 沒有執行期的範圍檢查。所以查表函式要有守衛 (複習 month_name 的招式:範圍外回傳 names[0])。

參考解答

/*
 * ex2_weekday_enum.c
 * ------------------
 * 練習 2:enum weekday 與 is_weekend(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_weekday_enum.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/* 讓 MON 從 1 開始,之後自動遞增:TUE=2 ... SUN=7 */
enum weekday { MON = 1, TUE, WED, THU, FRI, SAT, SUN };

/* 週末就是星期六或星期日 */
int is_weekend(enum weekday d)
{
    return d == SAT || d == SUN;
}

/* 查表印名稱(enum 底層是整數,可直接當索引) */
const char *weekday_str(enum weekday d)
{
    static const char *names[] = {
        "?", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"
    };
    return (d < MON || d > SUN) ? names[0] : names[d];
}

int main(void)
{
    /* 走訪整個 enum 範圍(enum 本質是整數,可以 ++) */
    for (int d = MON; d <= SUN; d++)
        printf("%s:%s\n", weekday_str((enum weekday)d),
               is_weekend((enum weekday)d) ? "週末!" : "工作日");

    /* enum 的值可以印出來看:SAT 其實就是整數 6 */
    printf("\nSAT 的底層整數值 = %d\n", SAT);

    /* 陷阱示範:C 不阻止 enum 變數存放範圍外的值 */
    enum weekday bogus = (enum weekday)42;
    printf("非法值 42 -> weekday_str 回傳 \"%s\"(守衛擋下)\n",
           weekday_str(bogus));

    return 0;
}

程式碼講解

  • MON = 1 之後不必逐一寫值——自動遞增是 enum 相對 #define 的第一個優點; 第二個優點是它們是真正的識別字,除錯器裡看得到名字。
  • weekday_str 把 enum 當索引查指標陣列:enum 值域設計(1..7)與表的索引對齊, 加守衛後索引安全。
  • 印 enum 用 %d:enum 值在算術運算與可變引數中會提升為 int。
  • 陷阱示範是刻意的「反面教材」:(enum weekday)42 合法編譯、正常執行—— enum 的範圍紀律靠程式設計者維護,switch 記得留 default、查表記得加範圍檢查。

範例輸出

MON:工作日
TUE:工作日
WED:工作日
THU:工作日
FRI:工作日
SAT:週末!
SUN:週末!

SAT 的底層整數值 = 6
非法值 42 -> weekday_str 回傳 "?"(守衛擋下)

練習 3:enum 狀態機 —— 販賣機模擬(難度:進階)

題目

用 enum 實作販賣機狀態機:IDLE → COIN_INSERTED → DISPENSING → IDLE。 以一串寫死的事件序列驅動(事件也用 enum:投幣、按購買鈕、出貨完成、退幣), 印出每一步的狀態變化。腳本需包含:一次完整購買流程、一次退幣、 以及至少兩個「非法操作」(如沒投幣就按鈕),非法操作應被忽略並回報。

解題思路

  1. 狀態機的三要素分別用什麼表示: - 狀態(現在在哪)→ enum state { IDLE, COIN_INSERTED, DISPENSING }; - 事件(發生了什麼)→ enum event { EV_COIN, EV_BUTTON, EV_DONE, EV_REFUND }; - 轉移規則(狀態 × 事件 → 新狀態)→ 一個純函式 next_state(s, e)。
  2. 轉移函式的結構:外層 switch (s) 依狀態分派,內層判斷事件:
IDLE           --EV_COIN-->   COIN_INSERTED
COIN_INSERTED  --EV_BUTTON--> DISPENSING
COIN_INSERTED  --EV_REFUND--> IDLE
DISPENSING     --EV_DONE-->   IDLE
(其餘所有組合:非法,留在原狀態)
  1. 非法轉移的策略:回傳原狀態 + 印訊息。真實系統可能記 log、回傳錯誤碼—— 重點是明確處理而不是讓它掉進未定義的分支。
  2. 把狀態機跑起來:事件腳本存成 enum event script[] 陣列, 一個迴圈逐一餵給 next_state,每步印出目前狀態—— 不需要使用者互動就能完整展示狀態機行為。
  3. enum + switch 的福利:state_str/event_str 的 switch 若漏了某個 case, -Wall 會警告「enumeration value not handled」——這是用 enum 而非 #define 的實質好處。

參考解答

/*
 * ex3_vending_machine.c
 * ---------------------
 * 練習 3:enum 狀態機 —— 販賣機模擬(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_vending_machine.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/* 狀態:販賣機目前處於哪個階段 */
enum state { IDLE, COIN_INSERTED, DISPENSING };

/* 事件:外界對販賣機做了什麼 */
enum event { EV_COIN, EV_BUTTON, EV_DONE, EV_REFUND };

static const char *state_str(enum state s)
{
    switch (s) {
    case IDLE:          return "IDLE(待機)";
    case COIN_INSERTED: return "COIN_INSERTED(已投幣)";
    case DISPENSING:    return "DISPENSING(出貨中)";
    }
    return "?";    /* 防範範圍外的值 */
}

static const char *event_str(enum event e)
{
    switch (e) {
    case EV_COIN:   return "投幣";
    case EV_BUTTON: return "按下購買鈕";
    case EV_DONE:   return "出貨完成";
    case EV_REFUND: return "按退幣鈕";
    }
    return "?";
}

/*
 * 狀態轉移函式:目前狀態 × 事件 -> 下一個狀態。
 * switch (state) 裡再判斷事件,不合法的組合就維持原狀態並回報。
 */
enum state next_state(enum state s, enum event e)
{
    switch (s) {
    case IDLE:
        if (e == EV_COIN)   return COIN_INSERTED;
        break;                       /* 待機時按鈕/退幣都沒反應 */
    case COIN_INSERTED:
        if (e == EV_BUTTON) return DISPENSING;
        if (e == EV_REFUND) return IDLE;
        break;
    case DISPENSING:
        if (e == EV_DONE)   return IDLE;
        break;                       /* 出貨中不接受任何操作 */
    }
    printf("    (%s 狀態下的「%s」被忽略)\n", state_str(s), event_str(e));
    return s;                        /* 非法轉移:留在原狀態 */
}

int main(void)
{
    /* 寫死的事件腳本:包含一次完整購買、一次退幣、兩次非法操作 */
    enum event script[] = {
        EV_BUTTON,   /* 沒投幣就按鈕:非法,忽略 */
        EV_COIN,     /* 投幣 -> COIN_INSERTED */
        EV_BUTTON,   /* 購買 -> DISPENSING */
        EV_COIN,     /* 出貨中投幣:非法,忽略 */
        EV_DONE,     /* 出貨完成 -> IDLE */
        EV_COIN,     /* 再投幣 -> COIN_INSERTED */
        EV_REFUND,   /* 退幣 -> IDLE */
    };
    int n = (int)(sizeof script / sizeof script[0]);

    enum state s = IDLE;
    printf("初始狀態:%s\n\n", state_str(s));

    for (int i = 0; i < n; i++) {
        printf("事件 %d:%s\n", i + 1, event_str(script[i]));
        s = next_state(s, script[i]);
        printf("    目前狀態:%s\n", state_str(s));
    }

    return 0;
}

程式碼講解

  • next_state 是純轉移邏輯:不做 I/O 決策、不記外部狀態, 同樣輸入永遠同樣輸出——這種設計讓狀態機容易測試、容易畫成轉移圖對照。
  • switch 內的 break 走到函式尾端的「非法處理」—— 合法轉移都在 case 裡 return,掉出來的一律是非法組合,控制流很乾淨。
  • state_str 在 switch 完整涵蓋三個 case 後仍寫了 return "?";—— 因為 enum 變數可能存放範圍外的值(上一題的教訓), 編譯器也要求所有路徑都有回傳值。
  • 事件腳本的設計覆蓋了:非法(IDLE 按鈕)、完整購買(投幣→按鈕→完成)、 非法(出貨中投幣)、退幣流程——輸出逐行對照,狀態機行為一目瞭然。

範例輸出

初始狀態:IDLE(待機)

事件 1:按下購買鈕
    (IDLE(待機) 狀態下的「按下購買鈕」被忽略)
    目前狀態:IDLE(待機)
事件 2:投幣
    目前狀態:COIN_INSERTED(已投幣)
事件 3:按下購買鈕
    目前狀態:DISPENSING(出貨中)
事件 4:投幣
    (DISPENSING(出貨中) 狀態下的「投幣」被忽略)
    目前狀態:DISPENSING(出貨中)
事件 5:出貨完成
    目前狀態:IDLE(待機)
事件 6:投幣
    目前狀態:COIN_INSERTED(已投幣)
事件 7:按退幣鈕
    目前狀態:IDLE(待機)

練習 4:用 union 拆解 double 的位元表示(難度:進階)

題目

用 union 觀察 double(64 位元 IEEE 754)的位元組成: 對幾個代表性的值(1.0、-6.25、0.5、0.0、0.1)印出原始位元(hex)、 符號位(1 位)、指數(11 位,並換算成去偏移的真實指數)、尾數(52 位)。

解題思路

  1. 原理:union 的成員共用同一塊記憶體。宣告 union { double d; unsigned long long u; },寫入 d 再讀 u, 就把 double 的 64 個位元「原封不動」地當成整數看—— 這就是型別雙關(type punning),C11 明文允許經由 union 進行。
  2. IEEE 754 雙精度的格式:
 63  62        52  51                                  0
+---+-------------+--------------------------------------+
| S |   E (11)    |              M (52)                  |
+---+-------------+--------------------------------------+
值 = (-1)^S × 1.M × 2^(E-1023)    (正規數)
  1. 用位移與遮罩切出三段: - 符號:bits >> 63(最高位); - 指數:(bits >> 52) & 0x7FF(右移 52 位後取低 11 位;0x7FF = 11 個 1); - 尾數:bits & 0xFFFFFFFFFFFFF(13 個 F = 52 個 1)。
  2. 驗算一個例子(-6.25):6.25 = 1.5625 × 2² → S=1、真實指數 2 → E = 2+1023 = 1025、 尾數 = 0.5625 的二進位 .1001 → M 的高位是 0x9。 輸出 raw=0xc019...、E=1025、M=0x9000...,完全吻合。
  3. 0.1 的教學價值:十進位的 0.1 在二進位是無限循環小數(尾數 0x99999...a), 所以 0.1 + 0.2 != 0.3——親眼看到位元是理解浮點誤差的最短路徑。

參考解答

/*
 * ex4_double_bits.c
 * -----------------
 * 練習 4:用 union 拆解 double 的 IEEE 754 位元表示(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_double_bits.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/*
 * IEEE 754 雙精度(64 位元):
 *  63  62        52  51                                  0
 * +---+-------------+--------------------------------------+
 * | S |   E (11)    |              M (52)                  |
 * +---+-------------+--------------------------------------+
 * 值 = (-1)^S × 1.M × 2^(E-1023)   (正規數)
 */
union double_bits {
    double d;
    unsigned long long u;    /* 64 位元無號整數,跟 double 一樣大 */
};

void show_double(double x)
{
    union double_bits pun;
    pun.d = x;                                /* 寫入 double 成員…… */

    unsigned long long bits = pun.u;          /* ……用整數成員重新解讀 */

    unsigned sign = (unsigned)(bits >> 63);               /* 最高 1 位 */
    unsigned exp  = (unsigned)((bits >> 52) & 0x7FF);     /* 中間 11 位 */
    unsigned long long mant = bits & 0xFFFFFFFFFFFFFULL;  /* 低 52 位 */

    printf("%-8g raw=0x%016llx\n", x, bits);
    printf("  符號 S = %u(%s)\n", sign, sign ? "負" : "正");
    printf("  指數 E = %u(偏移前),真實指數 = %d\n", exp, (int)exp - 1023);
    printf("  尾數 M = 0x%013llx\n\n", mant);
}

int main(void)
{
    show_double(1.0);       /* E=1023(真實指數 0),M=0 */
    show_double(-6.25);     /* -6.25 = -1.5625 × 2^2 */
    show_double(0.5);       /* 0.5 = 1.0 × 2^-1 -> 真實指數 -1 */
    show_double(0.0);       /* 全 0 的特殊表示 */
    show_double(0.1);       /* 0.1 無法精確表示:尾數是無限循環的截斷 */

    printf("sizeof(double) = %zu,sizeof(union double_bits) = %zu\n",
           sizeof(double), sizeof(union double_bits));

    return 0;
}

程式碼講解

  • pun.d = x; 寫入、pun.u 讀出:跨成員讀取在 C11 是合法的型別雙關 (C++ 規則不同,不要照搬)。前提是兩個成員大小一致(都是 8 bytes,程式尾端驗證)。
  • 遮罩常數的算法:11 位全 1 = 0x7FF;52 位全 1 = 13 個 hex 的 F。 尾數常數加後綴 ULL 確保是 64 位元字面值。
  • 「真實指數 = E − 1023」:IEEE 754 用偏移表示法讓指數可以比大小; 0.0 的 E=0 是特殊值(連同尾數全 0 表示零),印出的「真實指數 -1023」 只是機械計算,不適用正規數公式。
  • %016llx:印 64 位元 hex 補滿 16 位,位元一目瞭然。
  • 這招在真實世界的用途:實作 frexp、序列化浮點數、除錯 NaN 的 payload、 理解 0.1 + 0.2 問題……都從「看見位元」開始。

範例輸出

1        raw=0x3ff0000000000000
  符號 S = 0(正)
  指數 E = 1023(偏移前),真實指數 = 0
  尾數 M = 0x0000000000000

-6.25    raw=0xc019000000000000
  符號 S = 1(負)
  指數 E = 1025(偏移前),真實指數 = 2
  尾數 M = 0x9000000000000

0.5      raw=0x3fe0000000000000
  符號 S = 0(正)
  指數 E = 1022(偏移前),真實指數 = -1
  尾數 M = 0x0000000000000

0        raw=0x0000000000000000
  符號 S = 0(正)
  指數 E = 0(偏移前),真實指數 = -1023
  尾數 M = 0x0000000000000

0.1      raw=0x3fb999999999999a
  符號 S = 0(正)
  指數 E = 1019(偏移前),真實指數 = -4
  尾數 M = 0x999999999999a

sizeof(double) = 8,sizeof(union double_bits) = 8

練習 5:位元欄位 rgb565 —— 色彩壓縮(難度:進階)

題目

設計位元欄位結構 struct rgb565 { unsigned b:5, g:6, r:5; }(嵌入式螢幕常見的 16 位元色彩格式), 寫函式把 (r,g,b) 各 8 位元的顏色壓縮進去,再解回 8 位元。 測試:白、黑、正紅(應無損還原)與中灰、橘(會有精度損失)。 並觀察 sizeof(struct rgb565) 的實際值。

解題思路

  1. 為什麼是 5-6-5? 16 位元裝三個通道分不平均,人眼對綠色最敏感, 所以綠色多分一位——這是真實硬體(RGB565 LCD、舊式 framebuffer)的格式。
  2. 壓縮(8 位 → 5/6 位):保留高位、丟棄低位,右移即可: c.r = r8 >> 3;(8−5=3)、c.g = g8 >> 2;(8−6=2)。
  3. 解壓(5/6 位 → 8 位):左移補回位數是第一步,但 31 << 3 = 248 不是 255! 標準技巧是把高位複製到空出的低位: *r8 = (c.r << 3) | (c.r >> 2); → 31 變成 11111000 | 00000111 = 255。 這樣 0 映到 0、最大值映到 255,整個範圍均勻拉伸。
  4. 驗證設計:白/黑/正紅的每個通道都是全 0 或全 1,應無損還原; 中灰 128 的低 3 位被丟掉,還原成 132——有損,親眼看到損失多大。
  5. sizeof 的現實:有效位元只有 16,但本平台以 unsigned int(4 bytes)為 底層儲存單位,sizeof 是 4 不是 2——位元欄位的佈局與大小 依實作而定,這正是 K&R 警告「不可移植」的具體示範。

參考解答

/*
 * ex5_rgb565.c
 * ------------
 * 練習 5:位元欄位 rgb565 —— 24 位元色彩壓縮成 16 位元(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_rgb565.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/*
 * RGB565:嵌入式螢幕常見的 16 位元色彩格式。
 * r 佔 5 位(0..31)、g 佔 6 位(0..63)、b 佔 5 位(0..31)。
 * 人眼對綠色最敏感,所以綠色多分一位。
 */
struct rgb565 {
    unsigned b : 5;
    unsigned g : 6;
    unsigned r : 5;
};

/*
 * 壓縮:8 位元通道(0..255)-> 5/6 位元通道。
 * 取高位即可:右移丟掉低位(255 -> 31、255 -> 63)。
 */
struct rgb565 pack_rgb(unsigned r8, unsigned g8, unsigned b8)
{
    struct rgb565 c;
    c.r = r8 >> 3;    /* 8 位 -> 5 位:保留高 5 位 */
    c.g = g8 >> 2;    /* 8 位 -> 6 位:保留高 6 位 */
    c.b = b8 >> 3;
    return c;
}

/*
 * 解壓:5/6 位元 -> 8 位元。
 * 左移補回位數,再把高位複製到低位(讓 31 -> 255 而不是 248),
 * 這是標準的位深擴展技巧。
 */
void unpack_rgb(struct rgb565 c, unsigned *r8, unsigned *g8, unsigned *b8)
{
    *r8 = (unsigned)(c.r << 3) | (c.r >> 2);
    *g8 = (unsigned)(c.g << 2) | (c.g >> 4);
    *b8 = (unsigned)(c.b << 3) | (c.b >> 2);
}

static void test(const char *name, unsigned r, unsigned g, unsigned b)
{
    struct rgb565 c = pack_rgb(r, g, b);
    unsigned r2, g2, b2;
    unpack_rgb(c, &r2, &g2, &b2);

    printf("%-8s (%3u,%3u,%3u) -> r=%2u g=%2u b=%2u -> 還原 (%3u,%3u,%3u)\n",
           name, r, g, b, c.r, c.g, c.b, r2, g2, b2);
}

int main(void)
{
    /* 有效資料只有 5+6+5 = 16 位元,但 sizeof 依實作而定:
     * 本平台用 unsigned int 當底層單位,所以是 4 bytes。
     * 想要真正的 2 bytes 可改用 unsigned short 底層或位移/遮罩手法。 */
    printf("sizeof(struct rgb565) = %zu bytes(有效位元只有 16)\n\n",
           sizeof(struct rgb565));

    test("白", 255, 255, 255);   /* 全亮:應完整還原 255 */
    test("黑", 0, 0, 0);
    test("正紅", 255, 0, 0);
    test("中灰", 128, 128, 128); /* 有損:低位被丟棄 */
    test("橘", 255, 165, 0);

    return 0;
}

程式碼講解

  • 位元欄位的指派會自動遮罩:c.r = r8 >> 3; 中即使右邊超過 5 位, 也只有低 5 位被存入——但別依賴這點,右移已保證值在範圍內。
  • 高位複製低位的展開驗算:c.g = 41(橘的綠通道 165>>2)還原時 (41 << 2) | (41 >> 4) = 164 | 2 = 166——輸出的 166 與原 165 差 1, 這就是 6 位元精度的極限。
  • 位元欄位不能取位址(&c.r 是編譯錯誤),所以 unpack_rgb 用三個普通 unsigned * 輸出參數。
  • sizeof = 4 的輸出提醒我們:想要「保證 2 bytes」的可攜格式, 應改用 unsigned short 搭配自己寫的位移/遮罩((r << 11) | (g << 5) | b), 不要依賴位元欄位的佈局。

範例輸出

sizeof(struct rgb565) = 4 bytes(有效位元只有 16)

白      (255,255,255) -> r=31 g=63 b=31 -> 還原 (255,255,255)
黑      (  0,  0,  0) -> r= 0 g= 0 b= 0 -> 還原 (  0,  0,  0)
正紅   (255,  0,  0) -> r=31 g= 0 b= 0 -> 還原 (255,  0,  0)
中灰   (128,128,128) -> r=16 g=32 b=16 -> 還原 (132,130,132)
橘      (255,165,  0) -> r=31 g=41 b= 0 -> 還原 (255,166,  0)

練習 6:tagged union —— 迷你 JSON 值(難度:挑戰)

題目

為 tagged union struct json_value(支援 number/string/bool/null 四種型別) 寫一個 print_json_value 函式。 用一組寫死的值模擬 JSON 陣列 [null, true, 42, 3.14, "hello", false] 並印出。 額外要求:為每種型別寫「建構函式」,保證 tag 與 union 成員永遠成對設定。

解題思路

  1. JSON 值的本質是「多型」:一個值可能是四種型別之一。 C 沒有物件導向的多型,標準解法就是本單元的 tagged union: - enum json_kind 記錄「現在是哪種」(tag); - union 依 tag 存放對應資料(bool 用 int、number 用 double、string 用指標; null 不需要資料)。
  2. 空間效益:union 的大小 = 最大成員(8 bytes 的 double/指標), 整個結構 = tag + union + padding = 16 bytes。 若用 struct 平放三種資料就要 24+,型別越多差越大。
  3. 紀律問題與解法:tagged union 最大的風險是「tag 說是 A、union 裡卻是 B」。 把設定包進建構函式(json_bool(1)、json_number(42)……), tag 與資料在同一行一起寫入,外界永遠拿到一致的值—— 這是 C 模擬「不變式(invariant)」的慣用手法。
  4. 讀取的標準姿勢:switch (v->kind) 分派,每個 case 只碰自己的 union 成員。 -Wall 之下漏 case 會警告,等於編譯器幫你檢查「處理了所有型別」。
  5. 輸出格式:照 JSON 語法——null/true/false 小寫、字串加引號、 數字用 %g(整數值印 42、小數印 3.14,自動選格式)。

參考解答

/*
 * ex6_json_tagged_union.c
 * -----------------------
 * 練習 6:tagged union —— 迷你 JSON 值的表示與列印(難度:挑戰)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_json_tagged_union.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/* tag:union 目前存放的型別 */
enum json_kind { JSON_NULL, JSON_BOOL, JSON_NUMBER, JSON_STRING };

/*
 * tagged union:一個 tag + 一個 union。
 * 規則:寫入哪個成員就把 kind 設成對應的 tag;
 *       讀取前一律先看 kind。
 */
struct json_value {
    enum json_kind kind;
    union {
        int boolean;           /* JSON_BOOL:0 或 1 */
        double number;         /* JSON_NUMBER */
        const char *string;    /* JSON_STRING(指向字面值即可) */
    } u;                       /* JSON_NULL 不需要資料 */
};

/* 建構函式們:保證 tag 與 union 成員永遠成對設定 */
struct json_value json_null(void)
{
    struct json_value v = { .kind = JSON_NULL, .u = { 0 } };
    return v;
}

struct json_value json_bool(int b)
{
    struct json_value v = { .kind = JSON_BOOL, .u.boolean = (b != 0) };
    return v;
}

struct json_value json_number(double n)
{
    struct json_value v = { .kind = JSON_NUMBER, .u.number = n };
    return v;
}

struct json_value json_string(const char *s)
{
    struct json_value v = { .kind = JSON_STRING, .u.string = s };
    return v;
}

/*
 * 列印:switch (tag) 分派——tagged union 的標準讀取模式。
 * 開著 -Wall 時,若漏掉某個 case 編譯器會警告(enum 的隱藏福利)。
 */
void print_json_value(const struct json_value *v)
{
    switch (v->kind) {
    case JSON_NULL:
        printf("null");
        break;
    case JSON_BOOL:
        printf(v->u.boolean ? "true" : "false");
        break;
    case JSON_NUMBER:
        printf("%g", v->u.number);
        break;
    case JSON_STRING:
        printf("\"%s\"", v->u.string);
        break;
    }
}

int main(void)
{
    /* 用陣列模擬一個 JSON 陣列:[null, true, 42, 3.14, "hello", false] */
    struct json_value arr[] = {
        json_null(),
        json_bool(1),
        json_number(42),
        json_number(3.14),
        json_string("hello"),
        json_bool(0),
    };
    int n = (int)(sizeof arr / sizeof arr[0]);

    printf("[");
    for (int i = 0; i < n; i++) {
        print_json_value(&arr[i]);
        if (i < n - 1)
            printf(", ");
    }
    printf("]\n");

    /* union 的空間效率:大小 ≈ tag + 最大成員(不是所有成員總和) */
    printf("sizeof(struct json_value) = %zu bytes\n",
           sizeof(struct json_value));

    return 0;
}

程式碼講解

  • 建構函式用 C99 指定初始化器:{ .kind = JSON_BOOL, .u.boolean = ... } ——tag 與資料一次寫齊,語法上不可能只設一半。
  • json_bool(int b) 裡的 (b != 0):把任何非零值正規化成 1, 讓 u.boolean 永遠只有 0/1 兩種值。
  • print_json_value 每個 case 只讀對應的成員——讀錯成員(例如 tag 是 NUMBER 卻讀 u.string)會把 double 位元當指標用,是典型的 tagged union bug; 紀律靠 switch 結構維護。
  • sizeof = 16:4(enum)+ 4(padding,讓 union 的 double 對齊到 8)+ 8(union)。 上一單元的 padding 知識在這裡再度現身。
  • 真實的 JSON 庫還需要 array/object(遞迴結構,成員放 struct json_value *) 與動態字串——那要等下一單元的動態記憶體。

範例輸出

[null, true, 42, 3.14, "hello", false]
sizeof(struct json_value) = 16 bytes

常見錯誤解法對照

錯誤寫法 症狀 正確做法
typedef double **Matrix3; 當二維陣列用 型別不相容、沒有指標表可用 typedef double Matrix3[3][3];
對 enum 變數假設「只會是列出的值」 (enum weekday)42 悄悄通過 switch 留 default/查表加範圍守衛
union 寫 f 讀 i 卻期望「轉換後的值」 讀到的是位元重解讀,不是數值轉換 要轉換用 (int)v.f;要位元才用 union
tagged union 手動設 tag 與資料 兩者不同步,讀取時錯亂 用建構函式一次設定成對的 tag+資料
對位元欄位取位址 &c.r 編譯錯誤:位元欄位沒有位址 先讀到普通變數再傳指標
假設 sizeof(位元欄位結構) 等於位元總和 平台相依,跨平台資料毀損 可攜格式改用明確位移/遮罩