在使用 C 语言时,常常会用到如 printf 这样的可变接受参数的函数。可变参数究竟是如何实现的呢?

printf("%d %f %s", 1, 3.14, "hello");

stdarg.h 头文件中提供了一组接口用来支持可变参数的函数。下面是一个例子,基于这个例子来讲解参数数量可变的函数的定义方法。这个函数的用来计算多个 double 型参数的和,其中第一个参数是数量,之后是对应的浮点数。

double sum_of_doubles(int count, ...){
    double sum = 0;

    va_list ap;
    va_start(ap, count);
    for(int i = 0;i<count;i++){
        double num = va_arg(ap, double);
        sum += num;
    }
    va_end(ap);

    return sum;
}

sum_of_doubles(3, 1.1, 3.4, 4.5);

定义参数数量可变的函数时,使用 ... 来表示 1 个或多个参数。... 只能放在参数列表的最后面。

考虑函数调用过程,在调用之前,先把参数从左至右压入栈中,然后调用函数。被调用函数从栈中取出各个参数。因此,我们可以想到 ... 代表的参数在栈中的位置和它之前的参数的位置临近。用前面的例子说明,就是先把 count 压入栈中,然后把余下的浮点数以此入栈。如果是这样,那么得到参数 count 的地址后,做相应的偏移就能得到其后的浮点数了。

因此,可以先把 va_list 看做指针类型,指向可变参数,va_start 用前一个参数的位置来初始化指针。va_arg 用来把指针指向的数据强制转换为某种类型,并移动指针。va_end 用来销毁指针。基于以上思路我们可以把这几个接口实现如下:

typedef char *va_list;
#define va_start(list, parm) (list = (va_list)&parm)
#define va_arg(list, type)   *(type*)(list -= sizeof(type))
#define va_end(list) (list=NULL)

测试后,就会遇到错误,因为实际并不是这样。但是我们在常规的函数上面测试,确实符合预期,下面的测试函数把后面两个参数视为 ...,基于前面的思路来读取这两个参数。

void test(int first, int a, double b){
    va_list ap;
    va_start(ap, first);
    printf("%d", va_arg(ap, int));
    printf("%f", va_arg(ap, double));
    va_end(ap);
}

但是为什么在可变参数数量的函数上测试就会错呢?因为,编译器可能会把部分参数放到寄存器里面,或者放到其他地方。实际上,各种编译器对这几个接口的实现细节可能都不一样,对应用户它们都是黑盒子。但是用上面这种思路有助于理解这几个接口。

在使用 va_arg 来读取参数的时候,一定要给出正确的参数类型,因为在基于地址进行类型转换的时候,依赖于实际的参数类型。如果在对参数读取的时候指定的参数类型不正确,就会错误读取内存,往往会导致错误。因为 va_start 在实际的实现中可能需要做清理工作,因此在参数读完之后,一定要调用 va_end

在函数内部,如何知道调用者传递了多少个参数以及参数类型呢,答案是不知道。对于 printf,它使用格式化字符串来确定参数数量和参数类型。当用户在自己定义此类函数的时候,也必须通过某种方法把各个参数的类型告知函数,只有这样才能正确地读取参数。