C语言中变参函数的实现细节
C语言的函数虽然不具备C++的多态性,但也可以接受参数不确定的情况,当然,C语言中的变参函数实际在功能上是受限的,废话不多讲,下面来看看变参函数的边边角角的问题。
讨论之前我们来看一下最熟悉的变参函数printf的原型声明:
--------------------------------------------------------------------------------
int printf(const char *format, ...);
--------------------------------------------------------------------------------
注意到,在函数中声明其参数是可变的方法是三个点“...”,但同时,这个函数必须要有一个固定的参数,比如printf里面的这个format,也就是说变参函数的参数数目至少是一个。这是由C语言中实现变参的原理---计算堆栈地址---决定的。顺着printf函数我们来看看它的定义是什么:
--------------------------------------------------------------------------------
int __printf(const char *format, ...) { va_list arg; int done; va_start(arg, format); done = vfprintf(stdout, format, arg); va_end(arg); return done; }
--------------------------------------------------------------------------------
(注意到库函数中内部定义的变量和函数用了双下划线开头,这也是我们写应用程序时尽量不要用双下划线开头的原因,我们也不应该使用单下划线开头的函数和变量,因为那也是系统保留的)
其中发现__printf函数里用了va_list,va_start,va_end等宏,事实上,在__printf中调用的vfpirntf函数还用到了一个叫做va_arg的宏,这几个宏就是编写变参函数的关键。现在我们自己写一个最简单的变参函数,先来个感性认识:
--------------------------------------------------------------------------------
#include #include void simple_va_fun(int i, ...) { va_list arg_ptr; //定义一个用来指向函数变参列表的指针arg_ptr int j; va_start(arg_ptr, i); //使arg_ptr指向第一个可变参数 j = va_arg(arg_ptr, int); //取得arg_ptr当前所指向的参数的值,并使arg_ptr指向下一个参数 va_end(arg_ptr); //指示提取参数结束 printf("%d %d\n", i, j); return; } int main(void) { simple_va_fun(3, 4); return 0; }
--------------------------------------------------------------------------------
如代码中的注释所示,arg_ptr实际上是一个指向函数变参列表的指针,va_list实际上是void指针类型。
va_start用来初始化这个指针,使之指向变参列表中的第一个参数,注意到它的第二个参数是变参函数的那个固定参数。
va_arg利用已经初始化了的arg_ptr指针来取得变参列表中各个参数的值,第一个参数是变参列表指针,第二个参数是当前参数的类型。
va_end宏用来提示结束参数结束,在LINUX的glibc实现中,va_end实际上就是一个空语句(void)0
各个宏定义在头文件stdarg.h中声明,因此我们需要包含这个头文件。其具体的定义如下:
--------------------------------------------------------------------------------
#define _AUPBND (sizeof(acpi_native_int) - 1) #define _ADNBND (sizeof(acpi_native_int) - 1) #define _bnd(X, bnd) (((sizeof(X)) + (bnd)) & (~(bnd))) #define va_start(ap, A) (void)((ap) = (((char *)&(A)) + (_bnd(A, _AUPBND))) #defind va_arg(ap, T) (*(T*)(((ap) += (_bnd(T, _AUPBND))) - (_bnd(T, _ADNBDN)))) #define va_end(ap) (void)0
--------------------------------------------------------------------------------
这些宏定义都比较繁琐,主要目的是为了适应不同系统的地址对齐问题。
上面说过,va_start的功能实际上是使ap指针指向第一个变参,A就是我们的第一个固定参数,不考虑地址对齐,最简单的办法当然如下:
ap = &A + sizeof(A)
上述代码其实也是实现的这个简单的功能,但经过宏_AUPBND和_bnd之后,就能保证ap指向的地址至少是关于acpi_native_int对齐的,打个比方,如果此时A的地址是0x0003,而且A的类型占用4个字节,而当前系统要求4个字节对齐,那么就让_AUPBND中的sizeof参数为4,经过多次宏替代之后ap的地址值就会是0x0008,而简单地用上面的算式ap = &A + sizeof(A)计算出的结果是0x0007。
同样地,va_arg宏替代在不考虑任何移植性问题时,要取得当前变参的值并使指针指向下一个参数最简单的办法如下:
*((ap+=sizeof(T)) - sizeof(T))
这个需要稍微解释一下,首先,C里面的参数压栈是从右到左顺序压栈的,因此可以想象,第一个固定参数在栈顶(LINUX进程映像中栈是倒着增长的,这个地址是所有参数中最小的),第二个参数(也就是第一个变参)在紧接着固定参数之上,以此类推。因此,要想ap指针不断指向下一个参数,就必须让它每次都加上当前指向的变量所占内存的大小即 ap+=sizeof(T) 的含义。
接下来,利用这个地址值又减去sizeof(T),实际上地址值又回到上一个参数处(注意,此时ap指针的值并未改变,也就是说,va_arg宏实现获取第一个变参的值的时候是先使ap指向第二个变参,然后再去获取第一个变参的值),然后取值。
va_end宏就比较简单了,虽然各种平台的实现细节不一样,但是道理都是一样的,在glibc中va_end被简单地实现为一个空语句。
由此可见,实际上C语言的所谓变参函数是很笨的,它基本上啥智能都没有,不能跟C++的多态性和符号重载相比,我们在传递参数的时候虽然可以传递不定个数的参数,但是这些参数都必须在函数实现中给予一一处理。所以我还是比较推崇C++呵呵!
至于printf这个调皮鬼,上面看到它的原型了,里面还调用了vfprintf函数,这个函数就不分析了(实在太长了),它里面就用了va_arg来获取各个变参的值。printf之所以可以识别各种变量类型,是因为你调用它的时候必须用printf修饰符,也就是%d,%f,%s等等来指定你的参数,printf是很笨的,它是不知道的。