Not Only Algorithm,不仅仅是算法,关注数学、算法、数据结构、程序员笔试面试以及一切涉及计算机编程之美的内容 。。
你的位置:NoAlGo博客 » 程序设计 » ,

C/C++变长参数的原理与实现

C/C++语言可以支持参数个数可变及参数类型不定的函数,虽然实际编程中用到的可能不多,但深入研究下其细节还是有点意思的。
本文将主要介绍C/C++变长参数的原理与实现。

使用方法

我们非常熟悉的printf函数就是一种变长参数,显然printf函数中可以根据格式字符串的内容指定若干个变量作为参数进行调用,虽然使用得非常频繁,但大家有没想过这个函数是怎么定义的呢?在Visual Studio中查看可以知道其函数原型如下:

_Check_return_opt_ _CRTIMP int __cdecl printf(_In_z_ _Printf_format_string_ const char * _Format, ...);

其中包含了很多宏、函数调用约定之类的内容,简化版本的函数原型即为:

int printf(const char * _Format, ...);  

其中,第一个参数即为必须指定的格式字符串,后面的省略号表示数量不定的参数列表。

使用上面提到的三个点表示的省略号即可达到定义一个边长参数的函数的目的,但是函数中如何取出这里面的所有参数呢?
这里要使用到C语言中解决变长参数问题的若干宏定义va_start、va_arg、va_end,他们均定义在stdarg.h头文件中,以va开头(表示variable-argument可变参数),可根据预先定义的系统平台自动获取相应平台上各个数据类型的偏移量。他们的使用方法为:

va_list ap;		//定义一个可变参数列表ap
va_start(ap, arg);	//初始化ap指向参数arg的下一个参数
va_arg(ap, type);	//获取当前参数内容并将ap指向下一个参数
va_end(ap);		//释放ap

首先定义一个va_list类型的变量ap,然后使用va_start初始化这个变量。初始化之后,ap即指向了参数arg后面的第一个参数,即不确定参数中的第一个。然后使用va_arg可以取出当前ap指向的这个参数的数值,并把ap指向了下一个参数,不断的进行这个操作可以取出边长参数中的所有参数。最后使用va_end清空这个变长参数列表。

简单例子

下面通过一个简单例子说明变长参数函数的具体使用方法。该函数返回输入参数指定的数据之和,其中参数的第一个变量表示这组数一共有多少个,然后紧接着对应数量的数值。

#include <cstdio>
#include <cstdarg>

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

int main()
{
	int t1 = f(1, 1);
	int t2 = f(2, 1, 2);
	int t3 = f(3, 1, 2, 3);
	int t4 = f(4, 1, 2, 3, 4);
	int t10 = f(10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
	printf("%d %d %d %d %d\n", t1, t2, t3, t4, t10);
}

原理

通过上面的介绍已经可以在实际中使用变长参数进行编程了,但是有一些细节还不是非常清楚,比如里面用到的一些宏和变量是什么,它们又是怎么达到取出所有参数的目的的呢?

其实变长参数函数的实现关键在于参数的使用方法,指定了的参数直接使用指定的参数名称访问(如例子中的num),不确定的参数则是通过函数调用过程中参数传递的栈来获取的。一般的函数调用约定都是按从右至左的顺序将参数压栈,于是所有参数是放在一起的,知道前一个参数的类型及地址,可以知道后一个参数的地址,而知道了后一个参数的类型的话,则可以取出这个参数的具体内容,并且可以得到再后一个参数的地址,如此反复,可以得到所有参数的内容。对于变长参数函数,我们可以根据最后一个指定参数获取之后的省略参数内容(比如例子中的函数f,我们知道了参数num的地址及类型,就可知道第一个可变参数的栈地址,如果知道第一个可变参数的类型,就可知道第一个可变参数的内容和第二个可变参数的地址,以此类推,可以实现对可变参数函数的所有参数的访问)。
比如在经典的printf函数中,指定参数为格式化字符串。通过分析这个格式化字符串,可以知道后面可变参数的个数及每个参数的类型,从而可以获取各个参数内容。

通过Visual studio,我们可以看到其中va_list类型只是一个简单的字符指针,通过这个指针可以得到其指向的地址的内容。在其他一些平台或操作系统可能定义为void型的指针。

typedef char *  va_list;

而里面各个宏的定义如下所示:

#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end

#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define _crt_va_end(ap)      ( ap = (va_list)0 )

里面的定义又涉及到一些额外的宏,_ADDRESSOF宏根据字面意思应该可以猜到其是取变量v的地址用到,通过Visual Studio中查看发现确实如此。

#ifdef  __cplusplus
#define _ADDRESSOF(v)   ( &reinterpret_cast<const char &>(v) )
#else
#define _ADDRESSOF(v)   ( &(v) )
#endif

而_INTSIZEOF宏应该是取变量或类型n所占的内存长度,但可以看到其内容比较复杂,并不是简单的sizeof。这涉及到一些内存对齐的问题,它实际上做的事情是将变量或类型n的长度化为int长度的整数倍,正如它的名字所暗示的int sizeof。例如,如果sizeof(n)=4的话,sizeof(int)=4,那么_INTSIZEOF(n)=(4+4-1)&~(4-1)=7&~3=(111)2&(100)2=(100)2=4;如果sizeof(n)=5的话,则_INTSIZEOF(n)=(5+4-1)&~(4-1)=8&~3=(1000)2&(1100)2=(1000)2=8。

#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

那么容易知道:

  • va_start(ap, v)是把v的地址(va_list)_ADDRESSOF(v)加上v所占的内存大小_INTSIZEOF(v),得到的即为下一个参数的地址。
  • va_arg(ap, t)中的t指定了当前这个参数的类型,ap += _INTSIZEOF(t)则把ap加上了t的内存大小,此时ap指向了下一个参数的地址。后面又减回_INTSIZEOF(t)回到原先的ap地址,这是为了取出原先ap指向的参数的内容,需要先把该地址转化为类型t的指针,然后再通过解引用得到该地址的值。
  • va_end(ap)则直接把ap赋值为转化为字符指针的0,即把ap置为了空指针NULL,表示不再需要使用。

一般的函数调用约定中参数都是从右到左入栈,所以参数列表中后面的变长参数会先入栈,最后前面的指定的参数才入栈,我们知道程序的内存地址空间中栈是向下生长的,所以后入栈的成员其地址会更小,所以指定参数的地址是所有参数中最小的,把它加上对应的偏移后会得到其后面的变长参数的地址。

其实指定参数的数量并没有限制成一个,实际中可以根据需要使用任意多的指定参数,此时函数定义时仍然是把省略号写在最后面。比如例子中如果要增加一个求和的初始值时,函数可以声明为:

int f(int initsum, int num, ...){...}

变长参数函数与带有默认参数的函数有点类似,都可以变现出调用使用使用不同长度的参数列表的特性。但拥有变长参数的函数在声明时参数的个数与类型是不确定的,在运行期间才可以确定参数的状态。而默认参数函数在声明时参数的类型与个数已经确定,只是后面的部分参数指定了默认值,可通过省略其中的部分参数来调用这个函数,但是带有默认参数的函数还是使用了声明中指定的全部参数来进行调用,只不过是编译器自动给后部分参数赋了默认值。

上一篇: 下一篇:

我的博客

NoAlGo头像编程这件小事牵扯到太多的知识,很容易知其然而不知其所以然,但真正了不起的程序员对自己程序的每一个字节都了如指掌,要立足基础理论,努力提升自我的专业修养。

站内搜索

最新评论