C语言的可变参数的实现非常巧妙:大师只用了 3 个宏就解决了这个难题。
一、可变参数的应用
这里实现一个简单的可变参数函数 sum:它将个数不定的多个整型参数求和后返回,其第 1 个参数指明了要相加的数的个数(va.c):
#include <stdio.h>
#include <stdarg.h>
// 要相加的整数的个数为 n
int sum(int n, ...)
{
va_list ap;
va_start(ap, n);
int ans = 0;
while(n--)
ans += va_arg(ap, int);
va_end(ap);
return ans;
}
int main()
{
int ans = sum(2, 3, 4);
printf("%d\n", ans);
return 0;
}
sum 函数的第一个参数是 int n,逗号后面是连续的 3 个英文句点,表示参数 n 之后可以跟 0、1、2…… 个任意类型的参数。 sum 可以这么用:
sum(0);
sum(1, 2);
sum(3, 1, 1, 1);
二、可变参数的实现
可以看到在 sum 函数中用到了 3 个函数一样的东西:
- va_start
- va_arg
- va_end
它们是标准库(意味着各种平台都有)头文件 stdarg.h 中定义的宏,这 3 个宏经过清理后是下面这个样子:
typedef char* va_list;
#define va_start(ap,v) ( ap = (va_list)(&v) + sizeof(v) )
#define va_arg(ap,t) ( *(t *)((ap += sizeof(t)) - sizeof(t)) )
#define va_end(ap) ( ap = NULL )
- va_start 将 ap 定位到可变参数列表的起始地址
- va_arg 每次返回一个参数,并后移 ap 指针
- va_end 将 ap 置 NULL(避免非法使用)
这 3 个宏的实现就是基于 C语言默认调用惯例是从右至左将参数压栈的事实,比如说 va.c 中调用 sum 函数,参数压栈的顺序为:4->3->2,又因为 x86 CPU 的栈是向低地址增长的,所以参数的排列顺序如下:
va_start(n, ap)就是 ( ap = (char*)(&n) + 4 )。因此 ap 被赋值为 ebp+12 也就是变参列表的起始地址。之后 va_arg 取出每一个参数:
( *(int *)((ap += 4) - 4) ) 它首先将变参指针 ap 右移到下一个参数的起始地址,再将加赋操作的返回值减到之前的位置取出一个参数。这样,用一条语句既取出了当前参数,又后移了指针 ap,真是神了!sum 中循环使用 va_arg 就取出了 n 个要相加的整数。
三、变参函数的可行性
一个变参函数能接受个数、类型可变的参数,需要满足以下两个条件:
- 能定位到可变参数列表的起始地址
- 能获知可变参数的个数、每个参数的大小(类型)
条件 1 只要有个前置参数就能满足,而对于这样的变参函数:void func(…);编译能通过,但是不能用 va_start 取到变参列表的起始地址,所以基本不可行。
sum 函数中参数 n 被用来定位可变参数列表的起始地址(满足条件1);n 的值是可变参数的个数,类型默认全部是 int 型(满足条件2),因此 sum 能正常工作。
再看看 printf 函数是如何满足以上两个条件的,printf 函数的原型是:
int printf(const char *fmt, ...);
printf 的第1个参数 fmt(格式串)被用来定位其后的可变参数的起始地址(满足条件1); fmt 指向的字符串中的各个格式描述符如:%d、%lf、%s 等告诉了 printf fmt 之后参数的个数、各个参数的类型(满足条件2),因此 printf 能正常工作。
当然,sum、printf 能正常工作是设计者一厢情愿的期望,如果使用者不按规矩传入参数、格式串,函数能正常工作才怪! 比如:
sum(2, "111", "222");
printf("%s", 0);
编译器可不会进行可变参数的类型检查、格式串-参数匹配,后果将会在运行的时候出现……