本檔收錄本單元的練習題、完整解題思路與參考解答。
強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」;
真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 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。
解題思路
- 陣列型別的 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 變數存放範圍外的值」的陷阱與守衛。
解題思路
- 定義 enum:
enum weekday { MON = 1, TUE, WED, ... };—— 指定第一個為 1,其餘自動遞增(TUE=2、…、SUN=7)。 從 1 開始讓值和日常的「星期幾」直覺對應。 - is_weekend 一行:
return d == SAT || d == SUN;——enum 常數就是具名整數,比較運算直接用。 - 走訪整週:enum 底層是整數,
for (int d = MON; d <= SUN; d++)可行; 把int傳給enum weekday參數時加明確轉型(enum weekday)d, 對讀者標明「我知道我在做整數↔enum 的轉換」。 - 陷阱示範: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:投幣、按購買鈕、出貨完成、退幣),
印出每一步的狀態變化。腳本需包含:一次完整購買流程、一次退幣、
以及至少兩個「非法操作」(如沒投幣就按鈕),非法操作應被忽略並回報。
解題思路
- 狀態機的三要素分別用什麼表示:
- 狀態(現在在哪)→
enum state { IDLE, COIN_INSERTED, DISPENSING }; - 事件(發生了什麼)→enum event { EV_COIN, EV_BUTTON, EV_DONE, EV_REFUND }; - 轉移規則(狀態 × 事件 → 新狀態)→ 一個純函式next_state(s, e)。 - 轉移函式的結構:外層
switch (s)依狀態分派,內層判斷事件:
IDLE --EV_COIN--> COIN_INSERTED
COIN_INSERTED --EV_BUTTON--> DISPENSING
COIN_INSERTED --EV_REFUND--> IDLE
DISPENSING --EV_DONE--> IDLE
(其餘所有組合:非法,留在原狀態)
- 非法轉移的策略:回傳原狀態 + 印訊息。真實系統可能記 log、回傳錯誤碼—— 重點是明確處理而不是讓它掉進未定義的分支。
- 把狀態機跑起來:事件腳本存成
enum event script[]陣列, 一個迴圈逐一餵給next_state,每步印出目前狀態—— 不需要使用者互動就能完整展示狀態機行為。 - 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 位)。
解題思路
- 原理:union 的成員共用同一塊記憶體。宣告
union { double d; unsigned long long u; },寫入d再讀u, 就把 double 的 64 個位元「原封不動」地當成整數看—— 這就是型別雙關(type punning),C11 明文允許經由 union 進行。 - IEEE 754 雙精度的格式:
63 62 52 51 0
+---+-------------+--------------------------------------+
| S | E (11) | M (52) |
+---+-------------+--------------------------------------+
值 = (-1)^S × 1.M × 2^(E-1023) (正規數)
- 用位移與遮罩切出三段:
- 符號:
bits >> 63(最高位); - 指數:(bits >> 52) & 0x7FF(右移 52 位後取低 11 位;0x7FF = 11 個 1); - 尾數:bits & 0xFFFFFFFFFFFFF(13 個 F = 52 個 1)。 - 驗算一個例子(-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...,完全吻合。 - 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) 的實際值。
解題思路
- 為什麼是 5-6-5? 16 位元裝三個通道分不平均,人眼對綠色最敏感, 所以綠色多分一位——這是真實硬體(RGB565 LCD、舊式 framebuffer)的格式。
- 壓縮(8 位 → 5/6 位):保留高位、丟棄低位,右移即可:
c.r = r8 >> 3;(8−5=3)、c.g = g8 >> 2;(8−6=2)。 - 解壓(5/6 位 → 8 位):左移補回位數是第一步,但
31 << 3 = 248不是 255! 標準技巧是把高位複製到空出的低位:*r8 = (c.r << 3) | (c.r >> 2);→ 31 變成11111000 | 00000111= 255。 這樣 0 映到 0、最大值映到 255,整個範圍均勻拉伸。 - 驗證設計:白/黑/正紅的每個通道都是全 0 或全 1,應無損還原; 中灰 128 的低 3 位被丟掉,還原成 132——有損,親眼看到損失多大。
- 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 成員永遠成對設定。
解題思路
- JSON 值的本質是「多型」:一個值可能是四種型別之一。
C 沒有物件導向的多型,標準解法就是本單元的 tagged union:
-
enum json_kind記錄「現在是哪種」(tag); -union依 tag 存放對應資料(bool 用 int、number 用 double、string 用指標; null 不需要資料)。 - 空間效益:union 的大小 = 最大成員(8 bytes 的 double/指標), 整個結構 = tag + union + padding = 16 bytes。 若用 struct 平放三種資料就要 24+,型別越多差越大。
- 紀律問題與解法:tagged union 最大的風險是「tag 說是 A、union 裡卻是 B」。
把設定包進建構函式(
json_bool(1)、json_number(42)……), tag 與資料在同一行一起寫入,外界永遠拿到一致的值—— 這是 C 模擬「不變式(invariant)」的慣用手法。 - 讀取的標準姿勢:
switch (v->kind)分派,每個 case 只碰自己的 union 成員。-Wall之下漏 case 會警告,等於編譯器幫你檢查「處理了所有型別」。 - 輸出格式:照 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(位元欄位結構) 等於位元總和 |
平台相依,跨平台資料毀損 | 可攜格式改用明確位移/遮罩 |