14. 可变参数(stdarg.h):va_list、va_start、va_arg、va_end。实现自己的printf风格函数。
说实话,可变参数这个功能,我刚开始学C的时候觉得挺神秘的。你想想看,printf 能接受任意数量的参数,这背后到底是怎么做到的?后来我翻到 stdarg.h 这个头文件,才恍然大悟——原来C语言早就给我们准备好了这套机制。
今天我们就来彻底搞懂它。我会带着你一步步实现一个自己的 my_printf,让你真正掌握 va_list、va_start、va_arg、va_end 这四个宏的用法。
什么是可变参数?
可变参数,说白了就是函数可以接受不定数量的参数。最典型的例子就是 printf:
printf("Hello, %s!", "world"); // 2个参数
printf("a=%d, b=%d", 1, 2); // 3个参数
printf("x=%f, y=%f, z=%f", 1.0, 2.0, 3.0); // 4个参数
同一个函数,参数个数可以不一样。这在C语言里是怎么实现的?
嗯,这里要注意:可变参数函数必须至少有一个固定参数。这个固定参数的作用,就是告诉函数「后面还有多少个参数」或者「后面参数的类型是什么」。
stdarg.h 四件套
stdarg.h 提供了四个宏,它们必须按固定顺序使用。我习惯把它们叫做「可变参数四步曲」:
| 宏 | 作用 | 使用时机 |
|---|---|---|
va_list |
声明一个变量,用来存储可变参数列表 | 函数内部第一步 |
va_start |
初始化 va_list 变量,绑定到最后一个固定参数 | 使用 va_list 之前 |
va_arg |
从列表中取出下一个参数,需要指定类型 | 循环取参数时 |
va_end |
清理 va_list 变量 | 所有参数取完后 |
核心要点:这四个宏必须成对出现。有 va_start 就必须有 va_end,否则会有内存泄漏风险。
先写一个简单的例子
我们来写一个函数,它可以接受任意数量的整数,然后求和:
#include <stdio.h>
#include <stdarg.h>
int sum(int count, ...) {
va_list args;
int total = 0;
va_start(args, count); // 绑定到 count 后面的参数
for (int i = 0; i < count; i++) {
total += va_arg(args, int); // 每次取一个 int
}
va_end(args); // 清理
return total;
}
int main() {
printf("sum(3, 10, 20, 30) = %d\n", sum(3, 10, 20, 30));
printf("sum(5, 1, 2, 3, 4, 5) = %d\n", sum(5, 1, 2, 3, 4, 5));
return 0;
}
运行结果:
sum(3, 10, 20, 30) = 60
sum(5, 1, 2, 3, 4, 5) = 15
你看,第一个参数 count 告诉函数「后面还有几个参数」。然后 va_arg 在循环里一个个取出来。这就是最基础的可变参数用法。
我的小建议:我个人习惯把固定参数命名为 count 或 fmt,这样一眼就能看出它的用途。别用 n 这种模糊的名字,代码维护起来会头疼。
实现自己的 printf 风格函数
好了,现在我们来点硬核的。实现一个简化版的 my_printf,支持 %d、%s、%c 和 %%。
我在项目中遇到过类似的需求——嵌入式系统里没有标准库,需要自己实现打印函数。那时候我才真正体会到,stdarg.h 这套机制有多重要。
#include <stdio.h>
#include <stdarg.h>
void my_printf(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
while (*fmt) {
if (*fmt == '%') {
fmt++; // 跳过 '%'
switch (*fmt) {
case 'd': {
int val = va_arg(args, int);
printf("%d", val);
break;
}
case 's': {
char *val = va_arg(args, char *);
printf("%s", val);
break;
}
case 'c': {
char val = (char)va_arg(args, int);
// 注意:char 类型会被提升为 int
printf("%c", val);
break;
}
case '%': {
putchar('%');
break;
}
default:
putchar('%');
putchar(*fmt);
break;
}
} else {
putchar(*fmt);
}
fmt++;
}
va_end(args);
}
int main() {
my_printf("Hello, %s! You are %d years old.\n", "Alice", 25);
my_printf("Percent: %%\n");
my_printf("Char: %c\n", 'A');
return 0;
}
运行结果:
Hello, Alice! You are 25 years old.
Percent: %
Char: A
曾经踩过的坑:我曾经在 va_arg 里传错了类型,比如传了 float 但实际参数是 double。记住,va_arg 的第二个参数必须是实际参数的类型,不能是「兼容类型」。float 会被提升为 double,所以必须用 va_arg(args, double)。
可变参数的底层原理
你可能会好奇,这些宏到底是怎么工作的?其实原理并不复杂。
在C语言的调用约定中,函数参数是从右往左压入栈的。最后一个固定参数在栈中的位置,就是可变参数列表的起始位置。va_start 就是根据这个固定参数的地址,计算出第一个可变参数的地址。
va_arg 则根据你指定的类型大小,从当前地址取出数据,然后把指针向后移动。说白了,就是「取一块数据,指针后移」的循环。
我画了一张图,帮你理解这个过程:
va_copy:复制参数列表
还有一个宏你可能用得不多,但关键时刻很有用——va_copy。它用来复制一个 va_list 变量。
为什么要复制?因为 va_arg 会改变 va_list 的内部指针。如果你需要遍历两次参数列表,就必须先复制一份。
void process_twice(int count, ...) {
va_list args;
va_list args_copy;
va_start(args, count);
// 第一次遍历
va_copy(args_copy, args); // 复制当前状态
for (int i = 0; i < count; i++) {
printf("First: %d\n", va_arg(args_copy, int));
}
va_end(args_copy); // 记得清理副本
// 第二次遍历
for (int i = 0; i < count; i++) {
printf("Second: %d\n", va_arg(args, int));
}
va_end(args);
}
我的经验:va_copy 在 C99 中才被标准化。如果你在写 C89 的代码,可能需要自己手动保存参数指针。不过现在基本都用 C99 以上了,放心用吧。
常见陷阱与避坑指南
可变参数虽然好用,但坑也不少。我把自己踩过的坑整理了一下:
- 类型不匹配:
va_arg的类型必须和实际传入的类型完全一致。比如传了short,但va_arg(args, short)会出问题——因为short会被提升为int。正确做法是va_arg(args, int),然后自己转成short。 - 忘记调用 va_end:有些实现中,
va_start会动态分配内存。不调用va_end会导致内存泄漏。我建议你在函数出口统一调用。 - 多次调用 va_start:同一个
va_list变量不能重复调用va_start。如果需要重新开始,先va_end再va_start。 - 传递 va_list 给其他函数:可以传递
va_list变量,但要注意被调函数内部也会改变指针状态。如果主调函数还要继续使用,记得先va_copy。
曾经踩过的坑:我曾经写了一个日志函数,把 va_list 传给了 vprintf,然后自己又继续用 va_arg 取参数。结果发现取出来的数据全是乱的。后来才意识到,vprintf 内部已经把指针移到了末尾。从那以后,我只要传递 va_list,一定会先 va_copy 一份。
总结
可变参数是C语言中一个非常实用的特性。掌握了 va_list、va_start、va_arg、va_end 这四个宏,你就能写出像 printf 一样灵活的函数。
记住几个关键点:
- 必须有一个固定参数作为「锚点」
va_arg的类型必须准确- 成对使用
va_start和va_end - 需要多次遍历时用
va_copy
下次你写日志系统、格式化输出、或者需要处理不定数量参数的场景时,别忘了用上这套工具。嗯,相信我,你会感谢自己的。
公众号:蓝海资料掘金营,微信deep3321