可变参数函数,顾名思义,就是形参数量可以改变的函数,其中以printf函数最为典型,看一下它的使用格式:
printf("asd%d%f\n", z,n);
在使用printf进行输出时,除第一个字符串参数外,传进去几个参数,字符串中就必须有几个百分号,这些百分号其实就是告诉printf我传进来了几个参数以及其类型,如%d代表整型,%f代表浮点型。如若不然,printf输出就会出现错误结果。
按照这样的思想其实我们也可以写一个类似的函数:
int a=1;
int b=1;
int c=1;
int d=1;//其中abcd均为全局变量,假设系统全局变量地址连续
int sum(int n)//n为要相加的参数的个数
{
int sum=0;
int* sp=&a;
for(;n>0;n--)
{
sum+=(*sp);
sp++;
}
}
sum函数可以实现计算任意多个int值的和的任务,只要把待相加的变量都设置为全局变量即可(假设全局变量地址连续),这种方法是通过使用指针代替变量名对变量进行调用实现的,但有两个致命问题:
1.不是所有编译器都将全局变量地址设置成连续的
2.使用及其不便
索性c语言提供了一种函数,可变参数函数,其定义方法如下:
int sum(int n,...)
{
//代码段
}
可变参数函数在定义时最少传入一个变量以告知其他参数的个数以及类型。后边的省略号代表传入的参数类型和个数可变。
在函数调用时,形参会被放入栈中,比如如下函数:
int aa(int b,double c)
{
}
程序进入aa函数时,会在栈中创建两个变量:整型的b以及double型的c共占12字节,一般的编译器默认从右至左传递参数,所以在栈中先创建c再创建b,但由于栈是先入后出结构,故而b的地址反而比c小,但二者的地址是连续的。这就解决了上文所担忧的地址不连续问题,可以放心的用指针取值了。请看下面的新sum:
int sum(int n, ...)
{
int sum = 0;
int* sp;
sp= (int*)&n + n;//将sp指针指向传入的最右边的形参
for (n; n > 0; n--)
{
sum += (*sp);
sp--;
}
sp=(int*)0;
return sum;
}
//以上是新sum的定义
sum(6, (int)1, (int)1, (int)1, (int)1, (int)1, (int)1);
//sum的调用,传入了六个参数(n除外)
这样一看就比旧sum方便多了,毕竟不用另找地方创建全局变量,调用的时候直接传入即可,调用的时候更方便,能做的事情也更多,不仅限于求和,可以设计更多功能,且不用担心地址不连续。
但为什么新sum的指针要先取到最右边参数的地址再往下减呢,直接在n的地址上加不好吗?这就引出了printf使用的库函数了:
其名为:
#include "stdarg.h"
这个头文件最重要的函数和数据类型有四个:
1.va_list
va_list的底层是char* 所以va_list sp就相当于char* sp;就是定义了个用来取参数值的指针。
2.va_start(va_list a, 提供传递参数个数的那个参数);
va_start的底层相当于: char *a= (char *)&n + sizeof(int); //看看和新sum中有注释的那一行代码有什么不同以及va_start的优势。
3.va_arg(va_list sp, 类型名)
这个函数就是一个挪动指针的函数,取完一个参数指针就往低地址挪相应的字节。有两个形参,第一个是你设置的指针sp第二个是目前指针指向的参数的类型,动态过程如下:
图中n就是函数中用来提供参数个数的参数,一般放函数定义的最左边,也就是地址最低,abcd则是传入的四个参数。 进入函数后一定先执行va_start将指针指到地址最高的那个参数,这时执行va_arg,若参数a的类型是double,则va_arg(va_list sp, 类型名)函数返回a的值且将sp减少8字节,若在调用va_arg时将类型名参数写为int则会导致预期外的结果。
接这样通过va_arg一个个的取完所有参数,完成函数功能。
4.va_end(sp);
最后使用va_end(sp);将sp指针清零。鉴于va系列函数对指针采取了减法操作,所以我在新sum中也使用类似逻辑,至于这样有什么好处还需要学习。
提示:
打开printf的定义,会看到一个关键字__CRTDECL,展开发现是__cdecl
_CRT_STDIO_INLINE int __CRTDECL printf(_In_z_ _Printf_format_string_ char const* const_Format,...)
#define __CRTDECL __CLRCALL_PURE_OR_CDECL
#define __CLRCALL_PURE_OR_CDECL __cdecl
__cdecl关键字被用来强制编译器从右至左将参数入栈,避免编译器不同导致出现问题。
新sum解决了int型的加和问题,但要是参数中有double有int呢,只有一个n我们无法得知谁是double,谁是int,这就无法正确挪动指针sp,加和功能无法实现。
这时看printf的最左边参数
In_z_ _Printf_format_string_ char const* const_Format
会发现printf最左边的参数是一个字符串且没有别的参数了,printf一定是从字符串中得到其余参数的信息的,包括参数个数和类型,但字符串和新sum中的n在传递时有着本质不同,先看下面的最新sum函数:
int sum(int row, int column, ...)
{
int sum=0;
int* sp=&row;
sp += 2;
sp = (int*)(*sp);
for (int i = 0; i < row; i++)
{
for (int j = 0; j < column; j++)
{
sum += (*sp);
sp++;
}
}
return sum;
}
这个最新sum函数计算一个二维数组所有元素的和,下面时调用语句:
int array1[3][2] = { 1,2,3,4 ,5,6};
printf("%d",sum(3, 2, array1));
最新sum函数具有两个参数信息告知参数row和column分别告知array1的行和列。在调用时我们传入了一个新的参数array1,这个array1传入之后栈内存如下所示:
这时我们还可以像新sum一样直接通过va_arg(va_list sp, 类型名)函数取参数然后向下递减吗?当然不行,这个栈里边只有三个参数arr,row,column,sp指针减两下就没了,但数组里明明还有六个参数没取。这是因为函数在传递数组类参数时为了省空间,不把整个数组都复制到栈里,而只将数组第一个元素所在的地址传入栈,所以栈中的array中的数据不是六个int型数据,而是数组array1中第一个元素1的地址,所以最新sum函数中才有了sp = (int*)(*sp);这样的操作此时的sp便指向了数组array1中第一个元素1的地址,从而进行后面的操作。
下面回到printf,同理printf的最左边参数是一个数组(字符串),那么printf的栈必定如下所示:
其中其他参数的地址比字符串地址高。
进入printf后并不先急着找最右边参数的地址,而是执行如下代码:
int* sp=_Format;
sp = (char*)(*sp);
此时sp就指向了_Format字符串所在的空间,接下来只需要一个循环便可以求出其余参数信息。
for (int i = 0; *(sp + i) != '\0'; i++)
{
...
}
\0是字符串的结束标值,这个循环里的内容就是寻找%,判断百分号右边的值是d还是f还是s还是别的什么,同时记下这是第几个%,以及统计共有多少个百分号,这个循环完了就可以得到所有的参数信息,包括每一个参数的类型以及共有几个参数。最后记下i的值,因为除了要知道其余参数的信息,还需要知道要输出的字符串有几个字符。
有了其余参数的信息就可以挪动sp指向栈的最高地址,将相应的参数替换掉字符串中的%以及后边的f、d什么的得到要输出的完整字符串_Format,此时再将sp挪到字符串的第一个元素所在的地址,顺序输出字符串即可。
同理,我们也可以指定我们自己的格式化方法,只要将新sum(不是最新sum)中的参数n换成字符数组,并选定一个标识符比如¥,在¥后写参数信息,便可以实现既有int又有double的加和功能。
最后附上一张图
此图copy自,有助于对函数调用过程的理解,
https://www.cnblogs.com/UnknowCodeMaker/p/11002225.html
对此图原作者表示真挚的谢意,感谢,大家要给原作者点赞呀!