引子:最近发现以前写的代码有点问题,又重新温习了一遍可变参数。从网络上找到一些东东,整理一下放到这里,供备忘,各位也可以浏览学习,何乐而不为呢?有时间再写一篇自己的体会。
可变参数学习笔记
来源:http://topic.csdn.net/t/20041124/09/3582660.html
作者:laomai(原创,转载时请注明来自CSDN 的论坛及c/c++电子杂志)
前言:
本文在很大程度上改编自网友kevintz的“C语言中可变参数的用法”一文,在行文之前先向这位前辈表示真诚的敬意和感谢。
一、什么是可变参数
我们在C语言编程中有时会遇到一些参数个数可变的函数,例如printf()函数,其函数原型为:
int printf( const char* format, ...);
它除了有一个参数format固定以外,后面跟的参数的个数和类型是可变的(用三个点“…”做参数占位符),实际调用时可以有以下的形式:
printf("%d",i);
printf("%s",s);
printf("the number is %d ,string is:%s", i, s);
以上这些东西已为大家所熟悉。但是究竟如何写可变参数的C函数以及这些可变参数的函数编译器是如何实现,这个问题却一直困扰了我好久。本文就这个问题进行一些探讨,希望能对大家有些帮助.
二、写一个简单的可变参数的C函数
先看例子程序。该函数至少有一个整数参数,其后是占位符…,表示后面参数的个数不定. 在这个例子里,所有的输入参数必须都是整数,函数的功能是打印所有参数的值.
函数代码如下:
//示例代码1:可变参数函数的使用
#include "stdio.h"
#include "stdarg.h"
void simple_va_fun(int start, ...)
{
va_list arg_ptr;
int nArgValue =start;
int nArgCout="0"; //可变参数的数目
va_start(arg_ptr,start); //以固定参数的地址为起点确定变参的内存起始地 址。
do
{
++nArgCout;
printf("the %d th arg: %d\n",nArgCout,nArgValue); //输出各参数的值
nArgValue = va_arg(arg_ptr,int); //得到下一个可变参数的值
} while(nArgValue != -1);
return;
}
int main(int argc, char* argv[])
{
simple_va_fun(100,-1);
simple_va_fun(100,200,-1);
return 0;
}
从这个函数的实现可以看到,我们使用可变参数应该有以下步骤:
⑴在程序中将用到以下这些宏:
void va_start( va_list arg_ptr, prev_param );
type va_arg( va_list arg_ptr, type );
void va_end( va_list arg_ptr );
va在这里是variable-argument(可变参数)的意思.
这些宏定义在stdarg.h中,所以用到可变参数的程序应该包含这个头文件.
⑵函数里首先定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数地址的指针.因为得到参数的地址之后,再结合参数的类型,才能得到参数的值。
⑶然后用va_start宏初始化⑵中定义的变量arg_ptr,这个宏的第二个参数是可变参数列表的前一个参数,也就是最后一个固定参数。
⑷然后依次用va_arg宏使arg_ptr返回可变参数的地址,得到这个地址之后,结合参数的类型,就可以得到参数的值。然后进行输出。
⑸设定结束条件,这里的条件就是判断参数值是否为-1。注意被调的函数在调用时是不知道可变参数的正确数目的,程序员必须自己在代码中指明结束条件。至于为什么它不会知道参数的数目,读者在看完下面这几个宏的内部实现机制后,自然就会明白。
三、可变参数在编译器中的处理
我们知道va_start,va_arg,va_end是在stdarg.h中被定义成宏的, 由于1)硬件平台的不同 2)编译器的不同,所以定义的宏也有所不同,下面看一下VC++6.0中stdarg.h里的代码(文件的路径为VC安装目录下的\vc98\include\stdarg.h)
typedef char * va_list;
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )
下面我们解释这些代码的含义:
1、首先把va_list被定义成char*,这是因为在我们目前所用的PC机上,字符指针类型可以用来存储内存单元地址。而在有的机器上va_list是被定义成void*的
2、定义_INTSIZEOF(n)主要是为了某些需要内存的对齐的系统.这个宏的目的是为了得到最后一个固定参数的实际内存大小。在我的机器上直接用sizeof运算符来代替,对程序的运行结构也没有影响。(后文将看到我自己的实现)。
3、va_start的定义为&v+_INTSIZEOF(v),而&v是最后一个固定参数的起始地址,再加上其大小后,就得到了第一个可变参数的起始内存地址。所以我们运行va_start(ap, v)以后,ap指向第一个可变参数在的内存地址,有了这个地址,以后的事情就简单了。
这里要知道两个事情:
⑴在intel+windows的机器上,函数栈的方向是向下的,栈顶指针的内存地址低于栈底指针,所以先进栈的数据是存放在内存的高地址处。
(2)在VC等绝大多数C编译器中,参数进栈的顺序是由右向左的,因此,
参数进栈以后的内存模型如下图所示:最后一个固定参数的地址正好位于第一个可变参数之下,并且是连续存储的。
|———————————|
| 最后一个可变参数 | ->高内存地址处
|———————————|
........................
|———————————|
| 第N个可变参数 | ->va_arg(arg_ptr,datatype)后arg_ptr所指的地方
|———————————|
...................
|———————————|
| 第一个可变参数 | ->va_start(arg_ptr,start)后arg_ptr所指的地方
| | 即第一个可变参数的地址
|———————————|
|———————————|
| |
| 最后一个固定参数 | -> start的起始地址
|———————————|
...............
|———————————|
| |
|———————————| -> 低内存地址处
(4) va_arg():有了va_start的良好基础,我们取得了第一个可变参数的地址,在va_arg()里的任务就是根据指定的参数类型取得本参数的值,并且把指针调到下一个参数的起始地址。
因此,现在再来看va_arg()的实现就应该心中有数了:
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
这个宏做了两个事情,
①用用户输入的类型对参数地址进行强制类型转换,得到用户所需要的值
②计算出本参数的实际大小,将指针调到本参数的结尾,也就是下一个参数的首地址,以便后续处理。
(5)va_end宏的解释:x86平台定义为ap=(char*)0;使ap不再 指向堆栈,而是跟NULL一样.有些直接定义为((void*)0),这样编译器不 会为va_end产生代码,例如gcc在linux的x86平台就是这样定义的. 在这里大家要注意一个问题:由于参数的地址用于va_start宏,所 以参数不能声明为寄存器变量或作为函数或数组类型. 关于va_start, va_arg, va_end的描述就是这些了,我们要注意的 是不同的操作系统和硬件平台的定义有些不同,但原理却是相似的.
四、可变参数在编程中要注意的问题
因为va_start, va_arg, va_end等定义成宏,所以它显得很愚蠢, 可变参数的类型和个数完全在该函数中由程序代码控制,它并不能智能 地识别不同参数的个数和类型. 有人会问:那么printf中不是实现了智能识别参数吗?那是因为函数 printf是从固定参数format字符串来分析出参数的类型,再调用va_arg 的来获取可变参数的.也就是说,你想实现智能识别可变参数的话是要通过在自己的程序里作判断来实现的. 例如,在C的经典教材《the c programming》的7.3节中就给出了一个printf的可能实现方式,由于篇幅原因这里不再叙述。
五、小结:
1、标准C库的中的三个宏的作用只是用来确定可变参数列表中每个参数的内存地址,编译器是不知道参数的实际数目的。
2、在实际应用的代码中,程序员必须自己考虑确定参数数目的办法,如
⑴在固定参数中设标志—— printf函数就是用这个办法。后面也有例子。
⑵在预先设定一个特殊的结束标记,就是说多输入一个可变参数,调用时要将最后一个可变参数的值设置成这个特殊的值,在函数体中根据这个值判断是否达到参数的结尾。本文前面的代码就是采用这个办法——当可变参数的值为-1时,即认为得到参数列表的结尾。
无论采用哪种办法,程序员都应该在文档中告诉调用者自己的约定。这是一个不太方便
3、实现可变参数的要点就是想办法取得每个参数的地址,取得地址的办法由以下几个因素决定:
①函数栈的生长方向
②参数的入栈顺序
③CPU的对齐方式
④内存地址的表达方式
结合源代码,我们可以看出va_list的实现是由④决定的,_INTSIZEOF(n)的引入则是由③决定的,他和①②又一起决定了va_start的实现,最后va_end的存在则是良好编程风格的体现—将不再使用的指针设为NULL,这样可以防止以后的误操作。
4、取得地址后,再结合参数的类型,程序员就可以正确的处理参数了。理解了以上要点,相信有经验的读者就可以写出适合于自己机器的实现来。下面就是一个例子
六、实践——自己实现简单的可变参数的函数。
下面是一个简单的printf函数的实现,参考了<The C programming language>中的156页的例子,读者可以结合书上的代码与本文参照。
#include "stdio.h"
#include "stdlib.h"
void myprintf(char* fmt, ...) //一个简单的类似于printf的实现,参数必须都是int 类型
{
char* pArg="NULL"; //等价于原来的va_list
char c;
pArg = &fmt; //注意不要写成p = fmt !!因为这里要对参数取址,而不是取值
pArg += sizeof(fmt); //等价于原来的va_start
do
{
c =*fmt;
if (c != '%')
{
putchar(c); //照原样输出字符
}
else
{
//按格式字符输出数据
switch(*++fmt)
{
case 'd':
printf("%d",*((int*)pArg));
break;
case 'x':
printf("%#x",*((int*)pArg));
break;
default:
break;
}
pArg += sizeof(int); //等价于原来的va_arg
}
++fmt;
}while (*fmt != '\0');
pArg = NULL; //等价于va_end
return;
}
int main(int argc, char* argv[])
{
int i = 1234;
int j = 5678;
myprintf("the first test:i=%d\n",i,j);
myprintf("the secend test:i=%d; %x;j=%d;\n",i,0xabcd,j);
system("pause");
return 0;
}
在intel+win2k+vc6的机器执行结果如下:
the first test:i=1234
the secend test:i=1234; 0xabcd;j=5678;
*************************************************************************************
26 楼imRainman(雨人)回复于 2004-11-28 09:12:55 得分 0 对村长大作的一点儿补充:(关于可变参数实现的一些细节)
下面主要针对stdarg.h文件中的宏进行分析,由于stdarg.h涉及可变参数在多种平台上的
c/c++实现,且基本原理相同,所以为简单起见,本报告只分析x86平台上的c++实现。以下
是该特定环境下与可变参数实现相关的定义:
typedef char * va_list; //指向可变参数的指针
#define _ADDRESSOF(v) ( &reinterpret_cast<const char &>(v) ) //用于取地址
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
//用于计算变量在堆栈中所占的空间
#define va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
//用于初始化可变参数的指针
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
//用于获得当前指向的可变参数
#define va_end(ap) ( ap = (va_list)0 ) //指针归零
接下来是具体的分析:
1. 可变参数实现的基本原理。
由于x86平台上c++采用堆栈式,从右向左压入的方法传递参数。所以第一个参数位于堆栈
如果知道了栈顶的地址,又知道了每个参数的类型和数量,那么被调函数就可以从堆栈中
取出参数了。当然,默认情况下,这些工作时由编译器完成的。但当被调函数的参数可变
时,没有足够的参数类型和数量信息提供给编译器,所以编译器在编译时自动完成的工作
就要程序员在编写程序时手动完成。这就是上面列出的宏所要完成的工作。
2. 具体实现。
(1). typedef char * va_list;
这个typedef定义比较简单,就是定义一个用于指向参数堆栈的指针。
[注:为什么不使用void *而使用了char *,是因为
( &reinterpret_cast<const void &>(v) )是不合法的。而为什么一
定要用( &reinterpret_cast<const void &>(v) )呢?下面会有说明]
(2). #define _ADDRESSOF(v) ( &reinterpret_cast<const char &>(v) )
这个宏的作用就是取变量v的地址。它先把v重新解释为const char &,然后再取它的
地址。后面会看到,它用于得到参数的栈顶地址。[注1:为什么不先取v的地址,然后
再把它重新解释为const char *呢?(即,使用这样的宏:
( reinterpret_cast<const char *>(&v) ))原因是在c++中,如果v是用户自定义的变
量类型,那么用户可能会重载&运算符,那么&v返回的就不一定是这个变量在内存中的
地址。因此,使用&v是不安全的。继而只能先将v重新解释为某个编译器内部的数据类
型的引用,这时再取v的地址就完美了!当然,你不能将某个变量重新解释为void &类
型,这是非法的,这也恰恰是(1)中使用了char *而没有使用void *的原因。]
(3). #define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
这个宏的作用是计算类型/变量n在堆栈中所占的空间。因为编译器将参数压入堆栈的
时候是按照int类型的大小(16位编译器中为2 bytes,32位编译器中为4 bytes)进行
对齐的。也就是说变量会被对齐到sizeof (int)的整数倍边界上。而中间的空缺会用
0(无符号数时)或0xff(带符号数时)填充。所以变量在堆栈中的大小可能会和实际的大
小不一样。例如32位编译器中,char变量在堆栈中的大小会是4 bytes,而不是我们想
象的1 byte。因此有必要对其大小进行重新计算。方法是将sizeof(n)的低位加上一个
sizeof(int) - 1,这样低位会在>= 1时产生一个进位。而后再用&~(sizeof(int) - 1)
将低位清零。这样的计算等同于下面的表达式:
(sizeof(n) / sizeof(int)) ?
sizeof(n) + sizeof(int) - sizeof(n) % sizeof(int) : sizeof(n)
(4). #define va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
这个宏将指针ap指向第一个可变参数。即参数v后面的第一个参数。
(5). #define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
这个宏返回ap指向的t类型可变参数的引用,同时将ap指向下一个可变参数。
(6). #define va_end(ap) ( ap = (va_list)0 )
作为一个好的习惯,当使用完一个指针以后,将指针归零。表示该指针不再有效。
文章评论(0条评论)
登录后参与讨论