12.2 可变参数
可变参数函数在调用时可以接收个数不定的实参,典型例子是 printf。读取这些实参要用到 <stdarg.h> 中的 va_list、va_start、va_arg、va_end(以及 va_copy)。
1. 约束与设计要点
与 ... 对应的实参不携带类型信息,函数本身无法得知调用方实际传入了多少个实参,也无法得知每个实参的类型。因此调用方和函数之间必须事先约定,例如用第一个形参给出实参的个数,或者像 printf 那样用格式字符串描述每个实参的类型。
2. 一个安全的最小模式
#include <stdarg.h>
#include <stddef.h>
long long sum_ints(size_t count, ...) {
va_list ap;
long long total = 0;
va_start(ap, count);
for (size_t i = 0; i < count; ++i) {
total += va_arg(ap, int);
}
va_end(ap);
return total;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里约定:第一个实参 count 给出其后 int 实参的个数。例如 sum_ints(3, 1, 2, 3) 的结果是 6。
3. 默认实参提升
在可变参数部分,float 会提升为 double,较窄整数类型会提升为 int 或 unsigned int。因此 va_arg 取值类型必须与提升后类型一致,否则行为未定义。
4. va_copy 的使用场景
当同一组可变实参需要被遍历不止一次时,应使用 va_copy 复制实参游标。直接重复读取同一个 va_list 会导致未定义行为。
#include <stdarg.h>
int consume_twice(int n, ...) {
va_list ap;
va_list ap2;
va_start(ap, n);
va_copy(ap2, ap);
/* 第一次读取 ap ... */
/* 第二次读取 ap2 ... */
va_end(ap2);
va_end(ap);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
5. 前向转发模式
可变参数函数常写成两层:外层函数接收 ...,用 va_start 得到 va_list,再交给接受 va_list 形参的内层函数处理。标准库成对提供了这两种形式,例如 printf 与 vprintf、fprintf 与 vfprintf。这样,格式解析等逻辑只需要在内层实现一次。
6. 习题
6.1 巩固
第 2 节的 sum_ints 定义如下:
long long sum_ints(size_t count, ...);依次执行下面四个调用:
printf("%lld\n", sum_ints(3, 1, 2, 3));
printf("%lld\n", sum_ints(2, (char)3, (short)5));
printf("%lld\n", sum_ints(0));
printf("%lld\n", sum_ints(2, 1.5, 2));2
3
4
(1) 写出前三个调用的输出。
(2) 第二个调用里实参写的是 char 和 short,va_arg 却按 int 读取。解释它为什么仍能得到正确结果,这属于第 3 节说的哪一种情况;若把 char 换成 long 实参、仍按 int 读,还能得到正确结果吗?
(3) 第四个调用的第一个可变实参是 double,函数却按 int 读取,编译器对这次调用没有任何诊断。运行它,记录结果。再在 sum_ints 里把每次 va_arg 取到的值打印出来,对照实际实参,说明读到的整数并不是 3.5 转换来的。结果因此既不可预测,也不能指望在不同实现上有相同表现。最后说明:标准为什么把类型不匹配定为未定义行为,而不是规定一个转换结果。
(4) 若把 count 写成 4 而只传三个整数,问题出在哪里,函数内部能检查出来吗?由此归纳第 1 节所说的「调用方和函数之间必须事先约定」:约定一旦写错,函数自己有没有办法发现。
下面的函数想求一组实参的平均值:
#include <stdarg.h>
#include <stddef.h>
double mean(size_t count, ...) {
va_list ap;
double total = 0.0;
va_start(ap, count);
for (size_t i = 0; i < count; ++i) {
total += va_arg(ap, float);
}
va_end(ap);
return total / (double)count;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
要求:
用
gcc -std=c17 -Wall -Wextra编译,贴出警告全文,并解释「if this code is reached, the program will abort」指哪一步。运行
mean(3, 1.5f, 2.5f, 3.5f),记录实际结果。若程序直接终止,说明这与第 3 节「va_arg取值类型必须与提升后类型一致」的关系。改正函数,并约定
count为 0 时返回0.0。用下面三组调用验收:cprintf("%.2f\n", mean(3, 1.5f, 2.5f, 3.5)); printf("%.2f\n", mean(4, 1.0, 2.0, 2.0, 1.0)); printf("%.2f\n", mean(0));1
2
3期望输出依次为
2.50、1.50、0.00。调用方写
1.5f与写1.5,在改正后的函数里结果是否相同?从默认实参提升的角度回答。
6.2 编程
写一个统计函数,要求同一组实参遍历两遍:第一遍求总和与最大值,第二遍按原顺序打印每个值。
#include <stddef.h>
void report_ints(size_t count, ...);2
3
要求:
用
va_start启动一次,用va_copy复制出游标,两遍各用一个va_list,结束时两个都要va_end。输出格式:
sum=<总和> max=<最大值> values:,随后每个值前加一个空格。count为 0 时只输出sum=0 values:,不打印max。用下面三次调用验收:
creport_ints(4, 3, -1, 7, 2); report_ints(0); report_ints(1, 5);1
2
3期望输出:
textsum=11 max=7 values: 3 -1 7 2 sum=0 values: sum=5 max=5 values: 51
2
3把
va_copy(ap2, ap)换成ap2 = ap,重新编译运行。在你的实现上可能照样编译通过、结果也正确,也可能直接报错。查va_list在这台机器上的定义(例如看<stdarg.h>),解释两种可能的后果:va_list是指针类型时,赋值只是复制游标;是数组类型时,赋值本身就不合法。第 4 节因此要求用va_copy而不是赋值。再把第二遍改成
va_start(ap, count)重新开始,说明它与va_copy两种做法各自的代价。
实现一套带级别的日志接口,采用第 5 节的两层结构:
#include <stdarg.h>
#include <stdio.h>
void vlog_line(FILE *out, const char *level, const char *fmt, va_list ap);
void log_line(FILE *out, const char *level, const char *fmt, ...);2
3
4
5
要求:
vlog_line先输出[级别],再用vfprintf输出格式化内容,最后补一个换行;log_line只负责va_start、转调和va_end。用下面三次调用验收:
clog_line(stdout, "INFO", "server started, port=%d", 8080); log_line(stdout, "WARN", "queue %s is %d%% full", "rx", 80); log_line(stdout, "ERROR", "open %s failed: %d", "config.ini", 2);1
2
3期望输出:
text[INFO] server started, port=8080 [WARN] queue rx is 80% full [ERROR] open config.ini failed: 21
2
3写下
log_line(stdout, "WARN", "%d", 3.5);,用-Wall -Wextra编译。为什么编译器一声不响?运行输出是什么?说明普通可变参数函数与printf在格式检查上的差别。把格式串改为
"%s %d"并只传一个"x"。运行结果可能是打印一个无意义的值,也可能直接崩溃。据此说明:格式串与实参不匹配时,函数内部有没有办法自行发现?printf家族为什么也无法检查。选做:给两个函数加上 GCC 的格式属性:
cvoid log_line(FILE *out, const char *level, const char *fmt, ...) __attribute__((format(printf, 3, 4))); void vlog_line(FILE *out, const char *level, const char *fmt, va_list ap) __attribute__((format(printf, 3, 0)));1
2
3
4
5重新编译第 3、4 两问的调用,贴出诊断。说明两个属性里的数字各指什么,以及为什么
va_list版本的最后一个数字写 0。
6.3 综合与挑战
实现一个把若干字符串用分隔符连接起来的函数:
#include <stddef.h>
char *join_strings(const char *sep, const char *first, ...);2
3
约定:可变部分以空指针结尾,first 为 NULL 时表示没有任何字符串。
要求:
第一遍只计算总长度:
first与各段字符串的长度之和,加上段数减一乘以sep的长度,再加终止空字符。第二遍才写入。只申请一次存储,大小恰好等于所需字节数;申请失败返回
NULL。两遍遍历用
va_copy或两次va_start,不得读越过结尾的空指针。first为NULL时返回一个内容为空字符串的动态数组,调用方用free释放。用下面两段验收:
cchar *s = join_strings(", ", "alpha", "beta", "gamma", NULL); printf("%s\n", s); free(s); char *e = join_strings(", ", NULL); printf("[%s]\n", e); free(e);1
2
3
4
5
6
7期望输出:
textalpha, beta, gamma []1
2若把第一遍改成用
strlen逐个拼接(边拼边realloc),最坏情况要做多少次重新分配?从这一角度说明「先算长度、再申请」的意义。调用方忘记写结尾的空指针时,函数会一直读下去,直到碰到一个恰好为 0 的字。在第一遍计数里加一个段数上限(例如 1000 段),超过就返回
NULL,说明这个上限把什么错误变成了可观察的返回值,以及它不能替代调用方对结尾空指针的约定。