本檔收錄 5 題由易到難的練習。建議流程:先蓋住解答自己寫,卡住再看
「解題思路」,寫完對照「參考解答」。每題完整程式碼在 exercises/:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_max_n.c && /tmp/out
本單元三條鐵律,五題反覆出現:
va_start/va_end成對出現;va_arg只能取「提升後」的型別(int/double/指標, 沒有char、short、float);va_list走過一遍後狀態不確定,要重掃先va_copy。
練習 1:max_n(難度:基礎)
題目
寫 int max_n(int count, ...),回傳 count 個 int 引數中的最大值。
count <= 0 時印警告到 stderr 並回傳 INT_MIN。
測資:max_n(3, 1, 7, 3) → 7;全負數 max_n(5, -9, -2, -7, -1, -5)
→ -1;單一引數;全部相同。
解題思路
- 變參函式的資訊問題:
...裡有幾個引數、什麼型別, 函式自己無從得知——呼叫端必須用某種協定告知。 本題用「第一個固定參數當計數」(printf 用格式字串、 練習 3 用哨兵值,是另外兩種協定)。 - 標準骨架:
va_list ap;
va_start(ap, count); /* count = 最後一個具名參數 */
... va_arg(ap, int) ...
va_end(ap);
- 求最大值的初始化:不要用
best = 0(全負數就錯了), 而是「第一個引數直接當目前最大」,從第二個開始比—— 這也順便處理了 count==1 的情況。 - 邊界情況:count <= 0 沒有「最大值」可言,定義一個明確的 失敗行為(回 INT_MIN + stderr 警告)比默默回 0 誠實。
參考解答
/*
* ex1_max_n.c — 練習 1:max_n(count, ...) 回傳最大值
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_max_n.c
* 執行:/tmp/out
*
* 可變引數函式的基本骨架:va_start -> va_arg 迴圈 -> va_end。
* 引數個數由第一個固定參數 count 告知(printf 則是靠格式字串)。
*/
#include <stdio.h>
#include <stdarg.h>
#include <limits.h>
/*
* 回傳 count 個 int 引數中的最大值。
* count == 0 沒有意義:這裡定義回傳 INT_MIN 並印警告。
*/
static int max_n(int count, ...)
{
va_list ap;
if (count <= 0) {
fprintf(stderr, "max_n: count must be positive\n");
return INT_MIN;
}
va_start(ap, count); /* ap 指向 count 後的第一個引數 */
int best = va_arg(ap, int); /* 第一個引數直接當目前最大 */
for (int i = 1; i < count; i++) {
int v = va_arg(ap, int); /* 每呼叫一次前進一個引數 */
if (v > best)
best = v;
}
va_end(ap); /* 與 va_start 成對,必須呼叫 */
return best;
}
int main(void)
{
printf("max_n(3, 1, 7, 3) = %d\n", max_n(3, 1, 7, 3));
printf("max_n(5, -9, -2, -7, -1, -5)= %d\n",
max_n(5, -9, -2, -7, -1, -5));
printf("max_n(1, 42) = %d\n", max_n(1, 42));
printf("max_n(4, 5, 5, 5, 5) = %d\n", max_n(4, 5, 5, 5, 5));
return 0;
}
程式碼講解
va_start(ap, count)的第二個引數必須是最後一個具名參數—— 巨集靠它定位...的起點。- 早退路徑(count <= 0)放在
va_start之前: 還沒 start 就不需要 end,控制流最單純。 若在 start 之後提前 return,必須先va_end。 - 讀取個數完全信任 count:多讀(count 比實際引數多)是未定義 行為——變參函式的安全完全建立在「呼叫端遵守協定」上, 這是它與陣列參數本質的差異。
- 易錯處:
best初始化為 0——全負數測資max_n(5, -9, ...)就是抓這個 bug 的。
範例輸出
max_n(3, 1, 7, 3) = 7
max_n(5, -9, -2, -7, -1, -5)= -1
max_n(1, 42) = 42
max_n(4, 5, 5, 5, 5) = 5
練習 2:product(難度:基礎)
題目
寫 double product(int count, ...),計算 count 個 double 引數的
乘積;count == 0 回傳 1.0(空乘積)。並回答:呼叫時寫
product(4, 0.5, 0.5, 2.0, 8)(最後一個是 int 的 8)會發生什麼事?
解題思路
- 骨架同練習 1,但初始值改為乘法單位元素
1.0, 空乘積自然成立(數學慣例:空積 = 1)。 - 本題真正的考點:預設引數提升。傳給
...的引數會做 default argument promotions: -char、short→int; -float→double。 所以va_arg的型別只能寫提升後的型別:va_arg(ap, float)是未定義行為,一律取double。 - 反過來的陷阱:呼叫端傳
8(int)但函式取va_arg(ap, double)——int 不會自動升成 double(提升只在整數與 float 身上發生),讀出來是垃圾或當機,也是未定義行為。 變參函式沒有型別檢查,呼叫端寫8.0是自己的責任。 - 驗收:含負數(結果變號)、空乘積、0.5×0.5×2×8=4。
參考解答
/*
* ex2_product.c — 練習 2:product(count, ...) 計算 double 乘積
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_product.c
* 執行:/tmp/out
*
* 重點:預設引數提升(default argument promotions)——
* 傳給 ... 的 float 會自動升成 double,所以 va_arg 一律取 double;
* 寫 va_arg(ap, float) 是未定義行為。
*/
#include <stdio.h>
#include <stdarg.h>
/* 回傳 count 個 double 引數的乘積;count==0 回傳 1.0(空乘積) */
static double product(int count, ...)
{
va_list ap;
double prod = 1.0; /* 乘法單位元素 */
va_start(ap, count);
for (int i = 0; i < count; i++)
prod *= va_arg(ap, double); /* 只能取 double,不能取 float */
va_end(ap);
return prod;
}
int main(void)
{
printf("product(3, 2.0, 3.0, 4.0) = %g\n",
product(3, 2.0, 3.0, 4.0));
printf("product(2, 1.5, -2.0) = %g\n", product(2, 1.5, -2.0));
printf("product(0) = %g (empty product)\n",
product(0));
printf("product(4, 0.5, 0.5, 2.0, 8)= %g\n",
product(4, 0.5, 0.5, 2.0, 8.0));
/*
* 整數字面值 8 若不寫成 8.0 會怎樣?
* 傳進去的是 int,但 va_arg 取 double —— 未定義行為!
* (上面呼叫刻意寫 8.0;這是變參函式最常見的炸點。)
*/
printf("\nrule: pass 8.0 not 8 -- va_arg(ap, double) on an int\n");
printf("argument is undefined behavior.\n");
return 0;
}
程式碼講解
prod = 1.0讓count == 0不需要特判——選對初始值, 邊界情況自動正確。- 為什麼沒有
va_arg(ap, float):ABI 層面 float 引數早已在 呼叫端升成 double 放進暫存器/堆疊,函式端按 float 的大小去讀 必然錯位。編譯器對字面型別的 va_arg 甚至會直接警告。 - printf 的
%g:自動選擇%e或%f並去掉多餘的 0, 適合展示「數值本身」。 - 易錯處:測資最後一個引數寫
8而非8.0。這在 printf 家族 同樣成立——printf("%f", 1)是同型的 UB,只是大家常忘記 自己寫的變參函式也一樣脆弱。
範例輸出
product(3, 2.0, 3.0, 4.0) = 24
product(2, 1.5, -2.0) = -3
product(0) = 1 (empty product)
product(4, 0.5, 0.5, 2.0, 8)= 4
rule: pass 8.0 not 8 -- va_arg(ap, double) on an int
argument is undefined behavior.
練習 3:concat——哨兵結尾的字串串接(難度:進階)
題目
實作 char *concat(const char *first, ...):以 (char *)NULL 為
哨兵,把所有字串串成一個新配置的字串(呼叫端負責 free)。
要求:先掃一遍算總長、只 malloc 一次,再掃第二遍複製——
這需要 va_copy。
測資:concat("Hello", ", ", "world", "!", (char*)NULL)、
單一字串、含空字串的組合。
解題思路
- 協定換成哨兵:引數個數未知,改用「特殊值收尾」—— 對指標而言天然的哨兵就是 NULL。execl、gtk 的變參 API 都用這招。
- 為什麼掃兩遍:邊掃邊
realloc也行但效率差(O(n²) 複製 風險);先算總長再一次 malloc 是標準的兩段式配置。 - va_copy 登場:
va_list被第一遍的va_arg消耗完後 狀態不確定,不能倒帶。要重掃就必須在消耗前va_copy(ap2, ap)留一份乾淨的副本,兩份各自va_end。 - 複製迴圈:維護寫入游標
dst,每段memcpy後前進strlen(s);最後補'\0'。用 memcpy 而非 strcat 是因為 strcat 每次都要重找結尾,O(n²)。 - 哨兵的型別陷阱:呼叫端必須寫
(char *)NULL而不是裸的NULL或0——後者可能以 int 寬度入棧,在 64 位平台上 va_arg 讀指標時只讀到一半垃圾。 - 邊界情況:空字串是合法輸入(貢獻 0 長度);malloc 失敗
回 NULL 前記得
va_end(ap2)。
參考解答
/*
* ex3_concat.c — 練習 3:concat(first, ...):哨兵結尾的字串串接
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_concat.c
* 執行:/tmp/out
*
* 以 (char *)NULL 為哨兵,把所有字串串成一個新配置的字串。
* 策略:掃兩遍——第一遍算總長(一次 malloc),第二遍複製。
* 需要 va_copy:va_list 走過一遍後狀態不確定,不能重複使用。
*/
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
/*
* 把 first 與後續字串(以 (char*)NULL 結尾)串成新字串。
* 回傳 malloc 的緩衝區,呼叫端負責 free;失敗回 NULL。
*/
static char *concat(const char *first, ...)
{
va_list ap, ap2;
size_t total = 0;
/* ---- 第一遍:算總長 ---- */
va_start(ap, first);
va_copy(ap2, ap); /* 複製一份給第二遍用 */
for (const char *s = first; s != NULL; s = va_arg(ap, const char *))
total += strlen(s);
va_end(ap);
char *result = malloc(total + 1); /* +1 給 '\0' */
if (result == NULL) {
va_end(ap2);
return NULL;
}
/* ---- 第二遍:用 ap2 重掃一次,邊走邊複製 ---- */
char *dst = result;
for (const char *s = first; s != NULL; s = va_arg(ap2, const char *)) {
size_t len = strlen(s);
memcpy(dst, s, len);
dst += len;
}
*dst = '\0';
va_end(ap2);
return result;
}
int main(void)
{
/* 哨兵一定要寫 (char *)NULL:裸 NULL 可能是 int 0,寬度不對 */
char *s1 = concat("Hello", ", ", "world", "!", (char *)NULL);
char *s2 = concat("one", (char *)NULL);
char *s3 = concat("", "empty", "", " pieces", (char *)NULL);
printf("s1 = \"%s\" (len=%zu)\n", s1, strlen(s1));
printf("s2 = \"%s\" (len=%zu)\n", s2, strlen(s2));
printf("s3 = \"%s\" (len=%zu)\n", s3, strlen(s3));
free(s1); /* 呼叫端負責釋放 */
free(s2);
free(s3);
return 0;
}
程式碼講解
va_copy(ap2, ap)必須在第一個 va_arg 之前做: copy 複製的是「目前位置」,消耗一半才 copy 就只剩後半。- 兩個 for 迴圈的長相刻意一致(
s = first; s != NULL; s = va_arg(...))——同一個協定掃兩遍,差別只在迴圈體。 dst += len游標推進配memcpy:整個串接是 O(總長); 新手常寫的strcat(result, s)版本是 O(總長²) (Shlemiel the painter 問題)。- malloc 失敗的路徑也要
va_end(ap2)——va_end 是資源釋放, 所有離開路徑都要經過。 - 易錯處:忘記
+1留'\0'的位置;或第二遍誤用已耗盡的ap(讀到垃圾指標,多半當機)。
範例輸出
s1 = "Hello, world!" (len=13)
s2 = "one" (len=3)
s3 = "empty pieces" (len=12)
練習 4:my_snprintf 與截斷偵測(難度:進階)
題目
實作 int my_snprintf(char *buf, size_t size, const char *fmt, ...):
內部用 vsnprintf,回傳「完整輸出所需長度」(不含 '\0')。
用同一條格式化需求(18 字元的輸出)在 size = 64、19、18、10、1、0
下各跑一次,展示截斷偵測的標準寫法;最後示範「先問需求量再配置」
的兩段式用法。
解題思路
- 三層包裝模式(本單元的核心模式):自己的函式收
...,va_start後把va_list轉交給 v 系列函式 (這裡是vsnprintf),格式化邏輯完全不用自己寫。 - snprintf 的回傳值語意(很多人記錯):回傳的是
「如果緩衝區無限大,會寫出幾個字元」(不含
'\0'), 不是實際寫入的字元數。因此: -needed < size:完整寫入; -needed >= size:發生截斷,buf 裡是前 size-1 字元 +'\0'。 這個設計讓截斷偵測變成一個比較運算。 - 邊界枚舉:size=19 恰好裝下(18+1);size=18 差一格
(截斷,最後一個字元被
'\0'擠掉);size=1 只裝'\0'; size=0 完全不寫(此時 buf 甚至可以是 NULL)。 - 兩段式用法:
my_snprintf(NULL, 0, fmt, ...)只回需求量, 再malloc(needed+1)後重新格式化——動態組字串的標準流程 (asprintf的手工版)。
參考解答
/*
* ex4_my_snprintf.c — 練習 4:my_snprintf 與截斷偵測
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_my_snprintf.c
* 執行:/tmp/out
*
* 實作 my_snprintf(buf, size, fmt, ...):內部用 vsnprintf,
* 回傳「完整輸出所需長度」(不含 '\0')——與標準 snprintf 相同語意。
* 回傳值 >= size 即表示發生截斷,示範標準的截斷偵測寫法。
*/
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
/*
* 三層包裝模式的教科書範例:
* 1. 對外收 ...
* 2. va_start 後把 va_list 轉交給 v 系列函式
* 3. vsnprintf 完成真正的格式化
*/
static int my_snprintf(char *buf, size_t size, const char *fmt, ...)
{
va_list ap;
int needed;
va_start(ap, fmt);
needed = vsnprintf(buf, size, fmt, ap); /* 交棒給 v 函式 */
va_end(ap);
return needed; /* 完整輸出所需字元數(不含 '\0') */
}
static void try_size(size_t size)
{
char buf[64];
/* 只用 buf 的前 size 個位元組模擬小緩衝區 */
int needed = my_snprintf(buf, size, "user=%s id=%d", "alice", 1234);
printf(" size=%2zu needed=%d ", size, needed);
if ((size_t)needed >= size)
printf("TRUNCATED buf=\"%s\"\n", size > 0 ? buf : "(no space)");
else
printf("ok buf=\"%s\"\n", buf);
}
int main(void)
{
printf("my_snprintf(\"user=%%s id=%%d\", \"alice\", 1234)\n");
printf("full output is \"user=alice id=1234\" -> 18 chars\n\n");
try_size(64); /* 綽綽有餘 */
try_size(19); /* 剛好裝下 18 字元 + '\0' */
try_size(18); /* 差一格:截斷 */
try_size(10); /* 明顯不夠 */
try_size(1); /* 只放得下 '\0' */
try_size(0); /* 完全不寫入(buf 可為 NULL),只回需求量 */
/* 「先問需求量再配置」的兩段式用法 */
printf("\ntwo-pass usage: ask needed size first\n");
int needed = my_snprintf(NULL, 0, "pi=%.10f", 3.1415926535);
printf(" needed=%d -> allocate needed+1 bytes, then format again\n",
needed);
return 0;
}
程式碼講解
my_snprintf本體只有四行——三層包裝模式把「變參的麻煩」 壓縮到最小。你自製的每個 printf 風格函式都應長這樣。- 截斷判斷
(size_t)needed >= size:cast 是因為 needed 是 int(vsnprintf 錯誤時可回負值,生產程式要先檢查 < 0)。 try_size用同一個 64 位元組的 buf 模擬各種小緩衝區—— vsnprintf 保證絕不寫超過 size,所以這樣測試是安全的。- size=1 的結果
buf="":vsnprintf 永遠保證 NUL 結尾 (只要 size > 0),這是它比 strncpy 可靠的地方。 - 易錯處:把回傳值當「實際寫入數」用來推進游標——截斷時游標
會衝出緩衝區。推進量應是
min(needed, size-1)。
範例輸出
my_snprintf("user=%s id=%d", "alice", 1234)
full output is "user=alice id=1234" -> 18 chars
size=64 needed=18 ok buf="user=alice id=1234"
size=19 needed=18 ok buf="user=alice id=1234"
size=18 needed=18 TRUNCATED buf="user=alice id=123"
size=10 needed=18 TRUNCATED buf="user=alic"
size= 1 needed=18 TRUNCATED buf=""
size= 0 needed=18 TRUNCATED buf="(no space)"
two-pass usage: ask needed size first
needed=15 -> allocate needed+1 bytes, then format again
練習 5:log_msg——完整的 logging 外殼(難度:挑戰)
題目
寫 void log_msg(int level, const char *fmt, ...):
- 加上
[HH:MM:SS]時間戳與等級字首(DEBUG/INFO/WARN/ERROR)後 輸出到 stderr; - 內部拆成
vlog(level, fmt, va_list),格式化交給vfprintf; - 支援執行期調整的等級門檻(低於門檻的訊息丟棄);
- 用 format attribute 讓編譯器檢查呼叫端的格式字串與引數。
解題思路
- 架構:三層包裝的完整版。
log_msg(收...)→vlog(收va_list,做門檻過濾、時間戳、字首)→vfprintf(stderr, fmt, ap)(真正格式化)。 把vlog獨立出來的價值:日後要加「寫進檔案」「送網路」 的變體,都只是再寫一個薄外殼,格式化邏輯不重複。 - 時間戳:
time()取秒級時間、localtime_r轉本地時間 (_r版本執行緒安全,為第 13 單元鋪路)、strftime格式化 成HH:MM:SS。 - 等級門檻:一個全域
log_threshold,vlog開頭if (level < log_threshold) return;——工業 logger(syslog、 log4c)的最小可行版本。 - format attribute:
__attribute__((format(printf, 2, 3)))
告訴編譯器「第 2 參數是 printf 格式字串、變參從第 3 個起」。
之後呼叫端寫 log_msg(LOG_INFO, "count=%d", "oops") 會在
編譯期被 -Wall 抓出——把執行期 UB 提前成編譯警告,
這是自製變參函式必配的安全帶。
5. 細節:va_list 交給 vfprintf 之後狀態不確定,
log_msg 不得再碰 ap,只能 va_end。
參考解答
/*
* ex5_log_msg.c — 練習 5:log_msg——時間戳+等級+format attribute
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_log_msg.c
* 執行:/tmp/out
*
* 三層包裝模式的完整應用:
* log_msg(level, fmt, ...) 對外介面
* -> vlog(level, fmt, ap) 接受 va_list 的內部實作
* -> vfprintf(stderr) 真正的格式化輸出
* 並用 GCC/Clang 的 format attribute 讓編譯器檢查呼叫端的格式字串。
*/
#include <stdio.h>
#include <stdarg.h>
#include <time.h>
enum log_level { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR };
static const char *level_name[] = { "DEBUG", "INFO ", "WARN ", "ERROR" };
/* 執行期可調的全域門檻:低於它的訊息直接丟棄 */
static enum log_level log_threshold = LOG_DEBUG;
/*
* 內部實作:接受 va_list(v 系列慣例)。
* 格式化邏輯只寫在這裡一次,所有外殼共用。
*/
static void vlog(enum log_level level, const char *fmt, va_list ap)
{
if (level < log_threshold)
return; /* 等級不夠:丟棄 */
/* 時間戳:HH:MM:SS(logging 標配) */
time_t now = time(NULL);
struct tm tmv;
localtime_r(&now, &tmv); /* _r 版:執行緒安全 */
char stamp[16];
strftime(stamp, sizeof stamp, "%H:%M:%S", &tmv);
fprintf(stderr, "[%s] %s ", stamp, level_name[level]);
vfprintf(stderr, fmt, ap); /* 轉交 va_list */
fputc('\n', stderr);
}
/*
* 對外介面。format attribute 告訴編譯器:
* 「第 2 個參數是 printf 格式字串、可變引數從第 3 個開始」,
* 呼叫端型別不符時 -Wall 會直接警告(編譯期抓 bug)。
*/
__attribute__((format(printf, 2, 3)))
static void log_msg(enum log_level level, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vlog(level, fmt, ap); /* 轉交後 ap 狀態不確定 */
va_end(ap);
}
int main(void)
{
printf("logging to stderr with timestamp + level prefix:\n\n");
fflush(stdout); /* stdout/stderr 順序對齊 */
log_msg(LOG_DEBUG, "cache warmed with %d entries", 128);
log_msg(LOG_INFO, "server listening on port %d", 8080);
log_msg(LOG_WARN, "disk usage at %.1f%%", 91.5);
log_msg(LOG_ERROR, "cannot open %s: %s", "conf.yml", "no such file");
/*
* format attribute 的威力(取消註解會得到編譯警告):
* log_msg(LOG_INFO, "count=%d", "oops"); <- %d 對到字串
* log_msg(LOG_INFO, "%s %d", "only-one"); <- 引數不夠
*/
fprintf(stderr, "\n");
fflush(stderr);
printf("raise threshold to WARN: DEBUG/INFO now dropped\n");
fflush(stdout);
log_threshold = LOG_WARN;
log_msg(LOG_DEBUG, "this DEBUG message is dropped");
log_msg(LOG_INFO, "this INFO message is dropped");
log_msg(LOG_WARN, "warnings still visible");
return 0;
}
程式碼講解
vlog是系統的心臟:門檻過濾放最前(被丟棄的訊息連時間戳 都不用算)、字首用fprintf、本文用vfprintf、換行統一補—— 呼叫端的 fmt 不需要(也不應該)帶\n。level_name的字串刻意等寬("INFO "帶空白),輸出欄位 自然對齊,省掉格式化寬度的心力。- format attribute 放在函式宣告前;它是 GCC/Clang 擴充
(MSVC 用 SAL 註記),跨編譯器的程式可包進
#if defined(__GNUC__)巨集。 fflush(stdout):stdout(可能全緩衝)與 stderr(不緩衝) 混用時,不沖掉會看到訊息順序錯亂。- 易錯處一:
log_msg在呼叫vlog之後又想用ap—— 交棒後狀態不確定,要用必須事先va_copy。 - 易錯處二:忘了 format attribute 的參數是從 1 數起,
且成員函式/有隱藏參數時要位移;本題 fmt 是第 2 個、
...從第 3 個開始,寫(printf, 2, 3)。
範例輸出
logging to stderr with timestamp + level prefix:
[21:17:39] DEBUG cache warmed with 128 entries
[21:17:39] INFO server listening on port 8080
[21:17:39] WARN disk usage at 91.5%
[21:17:39] ERROR cannot open conf.yml: no such file
raise threshold to WARN: DEBUG/INFO now dropped
[21:17:39] WARN warnings still visible
(時間戳依執行時刻而異。)
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
va_arg(ap, float) / (ap, char) |
UB:引數已被提升 | 取 double/int(練習 2) |
呼叫端傳 8 給期待 double 的 ... |
讀到垃圾值 | 寫 8.0;用 format attribute 抓(練習 2、5) |
| 用耗盡的 va_list 重掃 | 垃圾指標、當機 | 事先 va_copy(練習 3) |
哨兵寫裸 NULL/0 |
64 位平台讀到半個垃圾指標 | (char *)NULL(練習 3) |
| 把 snprintf 回傳值當實際寫入數 | 截斷時游標衝出緩衝區 | needed >= size 判截斷(練習 4) |
| 轉交 va_list 後繼續用 | 狀態不確定 UB | 交棒即結束,要用先 copy(練習 5) |