本檔收錄 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;
}
解題思路
- 重構不是翻譯,是逐項升級。把 C89 的每個「不得已」
對應到 C99 的解法:
- 變數擠開頭 → 宣告貼近首次使用:
int mn = data[0];一行完成宣告與初始化,「未初始化空窗期」消失; - 迴圈變數外宣告 →for (int i = ...):i 的生命週期 縮到迴圈內,迴圈外誤用直接編譯錯誤; - 0/1 int →bool/true/false:型別即文件; -long sum→int64_t:溢位邊界跨平台一致。 - 三個輸出參數 → 回傳 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)(含閏年與無效日期處理),
再示範指定初始化器的稀疏查表用法。
解題思路
- K&R 原版的痛點:
{0, 31, 28, 31, ...}位置初始化, 讀的人要自己數「第 3 個是幾月」;而且為了閏年開了 兩排表(daytab[2][13])。 - 指定初始化器的升級:
[1] = 31, [2] = 28, ...索引就是月份,所見即所得;[0]沒列——沒指定的元素 自動零初始化,正好表達「不用」。 - 兩排表 → 一排表 + 修正函式:閏年只影響 2 月,
days[month] + (month == 2 && is_leap(year))一個運算式 解決,資料少一半、真相只有一份。 - day_of_year:第 1 到 month-1 月的天數和 + day。 驗收點設計成「跨 2 月」的日期(3 月 1 日): 平年 60、閏年 61,一眼看出閏年邏輯生效。
- 邊界:月份超界、日超過該月天數 → 回 -1 (2025-02-29 必須被拒——2025 不是閏年)。
- 稀疏查表是指定初始化器的殺手級應用:
[‘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)*pcast: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」的傳統寫法並排比較:
配置次數、記憶體佈局、釋放方式。
解題思路
- 問題:「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 匹配「不含隱式轉換」的特性。
解題思路
- _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 並展示角色權限表。
解題思路
- 這題是「位元運算單元 × C23 語法」的合體:
邏輯照舊(
|授權、& ~撤銷、&檢查), 語法全面換新——看新特性怎麼讓舊程式更嚴謹。 0b0001二進位字面值:旗標定義所見即位元, C11 時代的1 << 0、0x1都是折衷方案。- enum 底層型別
: unsigned char:C23 之前 enum 的 大小由編譯器決定(通常 int);指定後sizeof(enum perm) == 1成為可斷言的保證—— 封包格式、暫存器映射這些「大小敏感」場景的剛需。 - constexpr vs #define vs const:
-
#define:無型別、無作用域; -const int:C 裡不是常數運算式(不能當 檔案作用域的陣列大小——這是 C/C++ 的著名差異); - C23constexpr:真正的編譯期常數,能當陣列大小、 能進 static_assert。N_ROLES當roles[]大小就是實證。 - static_assert 驗證「設計不變量」:旗標不重疊
(
READ & WRITE == 0)、聯集恰為全集——這些以前只能 靠 code review 的約定,現在編譯器每次都幫你查。 - [[nodiscard]]:
has_perm的回傳值被丟棄毫無意義 (純查詢函式),標上讓誤用變警告。 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)。
解題思路
- 可攜性程式的三層武器,本題全用上:
-
__STDC_VERSION__:數字比大小分版(C99=199901L、 C11=201112L、C23=202311L);先defined再比, C89 沒定義這個巨集; -__has_include:標頭存在性(C23 才標準化, clang/gcc 早就有); -__has_c_attribute:屬性支援度(C23)。 - 「先問存在、再問內容」的雙層防護——本題最核心的 工程紀律:
#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) |