前言
可变参数函数是C语言中一种相当特殊的语法,它允许我们向函数传递任意数量和类型的参数,并在运行时根据我们自己书写的逻辑来解析这些参数。当然,这样做也带来了一些代价,比如牺牲了编译器的类型检查以及带来了默认参数提升。
实际上当我们写出第一个打印"hello, world"的程序时就已经在和可变参数函数打交道了,printf
和scanf
都是可变参数函数,也恰好是可变参数函数的最佳使用场景。尽管可变参数函数被广泛使用,然而很多人直到C语言课程结束仍然对其一无所知,就连ISO标准也对可变参数函数一笔带过,而且几乎什么都没说,实在是令人失望。
申明和定义
可变参数函数的申明和定义与普通函数几乎没有区别,只是最后一个参数变成了...
,表示可以接受0个或多个不确定类型的参数。
C23之前要求可变参数函数最少有一个命名参数,C23取消了这个限制。
// 申明
int sum(int n, ...);
int sum(...); // C23之前不合法
// 定义
int sum(int n, ...) { }
int sum(...) { } // C23之前不合法
调用可变参数函数时,编译器会对命名参数进行类型检查,而未命名的参数则可以是任何数量的任意表达式,也可以为空。
访问未命名参数
头文件stdarg.h提供了一个类型和多个宏用于访问可变参数函数中的未命名参数。
类型va_list
用于创建句柄,假设变量名是ap,它可被va_start
初始化,之后每次调用va_arg
都可以获取下一个参数,最后调用va_end
清理ap。
C语言标准对va_list
没有任何具体说明,这个类型不确定是指针、整数、结构体或者其他乱七八糟的。
C语言标准对va_start
、va_arg
、va_end
的实现原理也没有任何说明,只要求它们必须定义为宏。在gcc中,这些宏都被定义为内置命令,由编译器实现其功能,而不是由预处理器。
C23之前,va_start
的用法是va_start(ap, parmN)
,其中ap
必须是va_list
类型的对象,parmN
必须是最后一个命名参数。C23中的用法是va_start(ap, ...)
,parmN
被省略,为了保持兼容使用了可变参数宏,不过只有第一个参数有效。这个宏只初始化ap
,没有返回值。
va_arg
的用法是va_arg(ap, type)
,其中ap
必须先被va_start
初始化,每次调用va_arg
都会修改ap
,type
是预期下一个参数的类型。va_arg
从第一个未命名参数开始,每调用一次都返回下一个参数,返回值类型为type
。如果参数的实际类型和type
不匹配,则行为未定义。C语言标准没有说明va_arg
的返回值是不是左值,所以只能假定它不是左值。
va_end
的用法是va_end(ap)
,它会清理ap
。如果对没有先va_start
的ap
调用va_end
,或者函数返回时va_start
了的ap
没有va_end
,行为都是未定义的。
简而言之,按照C语言标准的说法,你必须先定义一个va_list
类型的变量,然后用va_start
初始化,再用va_arg
以正确的类型获取每个参数,最后调用va_end
。除此之外的一切做法都是未定义的,并且编译器也可能检查不到错误。
C23和之前标准的区别在于函数原型和va_start
中的命名参数。在早期的系统中,函数的参数传递非常简单,仅仅是把每个参数依次压入栈中,所以只要能确定第一个参数的内存地址,就能通过指针偏移遍历所有的参数。这就是为什么函数原型和va_start
都需要一个命名参数,它是一个锚点。因为只涉及指针操作,早期访问未命名参数的实现也非常简单,可以纯粹用宏来实现,va_list
只是一个指针,va_start
获取第一个参数的地址,va_arg
通过类型大小来偏移指针并返回所指对象。随着时代发展,各种处理器和操作系统对参数传递的处理方式各不相同,可能通过寄存器传递,也可能通过栈传递,但不一定按照固定顺序。使用宏实现访问未命名参数已经不可能,只能由编译器根据特定平台的机制生成特定的代码,此时函数原型和va_start
中的命名参数已经没有意义了。
默认参数提升
可变参数函数在参数传递时,会对未命名的参数进行默认参数提升。类型等级小于int
的参数(比如char
、short
)会被提升为int
,float
会被提升为double
,其他类型不变。
因为有默认参数提升的存在,所以在使用va_arg
时,type
不能是short
、float
等提升之前的类型。也正因如此,printf
的格式控制里小于等于int
的整数都用%d
,float
和double
都用%f
;而scanf
传的是指针,所以必须明确每一个写入对象的实际类型。
示例
接下来写一个示例程序,计算多个数的和。
先写一个C23之前的版本。因为至少要有一个命名参数,所以我们用它来表示未命名参数的数量。
#include <stdio.h>
#include <stdarg.h>
int sum(int n, ...) {
va_list ap;
va_start(ap, n);
int result = 0;
for (int iter = 0; iter < n; ++iter) {
result += va_arg(ap, int);
}
va_end(ap);
return result;
}
int main(void) {
printf("%d\n", sum(3, 1, 2, 3));
}
再写一个C23的版本。没有了额外的变量,我们用数字0来表示结束。
#include <stdio.h>
#include <stdarg.h>
int sum(...) {
va_list ap;
va_start(ap);
int result = 0;
while (1) {
int num = va_arg(ap, int);
if (num == 0) {
break;
}
result += num;
}
va_end(ap);
return result;
}
int main(void) {
printf("%d\n", sum(1, 2, 3, 0));
}
上面的两个示例都默认参数类型是int
,只有参数数量在变化。这种情况下,使用数组更加安全和方便。如果参数类型也是变化的,则需要额外的信息来传递类型,比如一个记录类型的字符串,所以说printf
和scanf
是可变参数函数的最佳使用场景。