【C语言深入】带你了解C语言中的可变参数列表
- 一、可变参数函数的使用方式
- 1、使用方式
- 2、自定义可变把参数函数
- 2.1、三个宏一个类型
- 2.2、实现方式
- 二、可变参数列表的原理
- 1、va_start
- 1.1、_ADDRESSOF
- 1.2、关于临时拷贝的一个小知识点
- 1.3、_INTSIZEOF
- 2、va_arg
- 3、va_end
一、可变参数函数的使用方式
1、使用方式
我们在编写程序的时,有时候可能需要用到一些参数不确定的函数,以应对各种场景。最好的一个例子就是我们在学习C语言时所使用的第一个函数printf:
#include <stdio.h>
int main() {
printf("hello world!");
return 0;
}
在使用时我们可以只传递一个参数,也可以传递多个参数:
#include <stdio.h>
int main() {
printf("hello world!");
printf("%d %d %d %d ……", a, b, c, d, ……);
return 0;
}
所以我们在使用参数可变的函数的时候,有多少个参数就传递多少个参数即可。
2、自定义可变把参数函数
name我们是否能自定义一个参数可变的函数呢?
当然可以,其实可变参数函数的实现主要依赖的就是以下这三宏和一个类型:
2.1、三个宏一个类型
我们先来粗略的认识一下:
一个类型指的是va_list,而它的本质其实就是一个char指针,我们可以转到它的定义来看一看:
其实它的作用就是用于创建一个字符指针的。
第一个宏是va_start:
这个宏需要传入两个参数,第一个参数就是我们在上面用va_list创建的char指针,第二个参数是一个标志着该函数有多少个参数的整型。
该宏的功能我们可以选粗略的理解为能使我们定义好的char指针指向我们的第一个参数(从左往右)
第二个宏是va_arg:
该宏在使用时也是需要传入两个参数,第一个就是我们前面多定义的char类型的指针,第二个就是我们每个参数的类型。
该宏的功能我们可以先粗略地理解为能让能让char指针移动一个类型长度的地址,并取得当前(未移动之前)指针所指向的一个参数。
第三个宏是va_end:
该宏只需要传递一个参数,那就是char的指针。
该宏的功能就很简单了,就是把我们char*指针置为空指针。
当然啦,这里也只是粗略的认识一下,至于细节后面也还会讲到。
2.2、实现方式
在C语言的函数定义中,使用三个点来表示可变参数列表,这就像是一个省略号,表示参数的个数是未知的。比如printf就是这样定义的:
所以,如果我们想自定义一个参数可变的函数,就直接用三个点来代替它的参数部分,例如我们现在要定义一个求多个整数中最大值的函数,就可以先这样声明:
int Max(int num, ...);
其中num这个参数是一定要有的,它的作用就是标记该函数有多少个参数,以供后面使用。
也就是说,可变参数函数至少得要有一个明确的参数。
该函数的具体实现代码如下所示:
int Max(int num, ...) {
va_list p; // 创建一个char*类型的指针
va_start(p, num); // 让指针p指向第一个参数
int max = va_arg(p, int); // 让max等于第一个参数
int i = 0;
int x = 0; // 保存每一次取到的参数
for (i = 0; i < num - 1; i++) {
x = va_arg(p, int);
if (max < x) {
max = x;
}
}
va_end(p); // 将p置为空指针
return max;
}
但是大家可能还有会有点儿懵,想知道具体的实现步骤。
想要理解步骤,就要用到一点儿函数栈帧的知识,我现在利用函数栈帧的知识来给大家画一下大致地图解过程,比如我们传递的参数如下:
int main() {
int max = Max(4, 3, 5, 2, 4);
return 0;
}
我们知道调用一个函数时候,实参的临时拷贝其实是在被调用函数的栈帧形成之前就已经形成了的,而且顺序是从右向左,所以我们的栈帧结构图大概如下:
而我们的第一个宏va_start所做的就是将我们的p指针指向第一个参数:
而后,我们令max = max = va_arg(p, int)其实做的就是先将指针当前指向的参数的值赋给max,并且让指针指向下一个参数:
然后我们在循环中一直重复va_arg就可以一直取出参数并进行比较,执行num - 1此后就可以遍历完所有的参数,也就得到了最大值。
二、可变参数列表的原理
上面只是简单地将用法和实现方法给大家介绍了一下,但要想灵活的应用可变参数列表,我们还是得知道它的实现原理,而可变参数列表的原理究其根本也就是那三个宏的实现原理,所以我们就要对那三个宏再进行深度剖析。
1、va_start
我们可以先到该宏的定义处去看看该宏是怎么定义的:
想必大家看到这就又该晕了,没想到这个va_start的实现里有潜逃了两个非常奇怪的宏,这也太复杂了吧。
但复杂归复杂,我们还得来好好的看看va_start里包含的这两个宏:
1.1、_ADDRESSOF
我们还是先转到该宏的定义处看一看:
当我们看过定义后就会发现这个宏其实非常简单,就像它的名字一样,它所做的就是对传入的参数进行取地址操作。
那么取的是谁的地址呢?
着我们需要到函数的定义里看看:
很明显这里取的就是num的地址:
然后我们就将num的地址转化成va_list类型(指针):
然后再加上一个整数(_INTSIZEOF(v)),就可以使得p指针指向第一个参数了。
在讲_INTSIZEOF这个宏之前需要先插播一个小的知识点:
1.2、关于临时拷贝的一个小知识点
我们先来想一想一个问题,若是我想传入的参数不是int类型而是char类型,那这个函数还能否达到痛痒的效果呢?
我们可以先来试验一下,我们把参数就改成char类型试试:
结果发现我们还是能达到效果的。但是有的朋友可能就会疑惑了,因为在Max函数里提取参数时候用的类型分明是int啊,为什么类型不相同但却没出现问题:
要解释这个现象,就得先补充一个传参时的小特性:
我们的实参在形成临时拷贝的时候,一般都是以4字节或8字节进行拷贝的。也就是说如果传入的实参大小不足4字节时(例如char类型),那形成临时拷贝的时候就会先将它提升为4字节。
要证明这个特性,我们就要进到汇编中去看看:
我们知道,随着参数的不断压入,栈顶寄存器esp的只应该是不断减小的,那么如果这里的拷是按照参数的本来大小拷贝,那当我们执行到下一条指令后,寄存器esp的值应该只是减小1,但如果是按照4字节拷贝,那就将减少4,我们来看看结果:
从结果就可以看出,确实是减小了4字节。
理解了这个特性,我们从才能更好地解释下面要讲到的_INTSIZEOF这个宏所有做的工作。
1.3、_INTSIZEOF
我想经过上面的描述大家应该就能知道这个宏所做的其实就是把参数的大小以4字节向上取整。
那到底是怎么做到的呢?我们还是先到该宏的定义处去看看:
想要理解这个宏,就必须分析清楚括号里那一大串有sizeof组成的表达式。我们知道sizeof(int)就是4,所以该表达式就可以转化为:
(sizeof(n) + 3) & ~(3)
这样好像也看不出什么端倪,因为这里的运算用到了位运算符,所以我们还是要从二进制位入手。
我们可以从结果倒推出结论:
我们知道整型4的二进制序列为(我这里只写出后8位):
00000100
也就是说如果我们想得到的是按4字节向上取整的数(也就是4的整数倍),那么结果的二进制序列的最后两位一定是0。
而对整型3按位取反的~(3)的二进制序列为:
11111100
它的后两位都是0,前面的都是1,所以~(3)与任何数按位与都能使结果的后两位都为0,而前面的位都不变,也就是保证结果为4的整数倍。
而它前面所做的sizeof(n) + 3就是为了向上取整,因为如果一个数不是4的整数倍,那它的二进制序列的后两位必定有有一位是1,那次是加上3,那就先使得该数字先向上超出或等于4的整数倍,然后如果加3后的结果后面的两位还有1,例如6的二进制序列00000110加3后变成00001001。不用担心这个1会在按位与~(3)的时候被消掉的。
而最后的这个转化成void的操作其实可以忽略掉,因为void是通用的嘛~:
所以,这个神奇的宏就是这样使类型以4字节向上取整的。
2、va_arg
这个宏就比上一个要简单一点了,这一点我们从它的定义就能看出来:
前面的*(t*)我们可以先不管,我们首先看后面括号里的表达式的内容。
我们看到括号里首先做的就是让ap(就是指针p)先移动到下一个参数,也就是先让p指针指向下一个参数:
但我们发现它后来好像又减了回去:
但这里的ap + _INTSIZEOF(t)只是产生了一个值,并没有改变ap的内容,所以ap的指向并没有变。
所以这个括号里的表达式的值就是ap原本的值,但这个表达式产生的一个副作用是让ap指向下一个参数。
所以外边的*(t*)解引用所得到的也就是,ap原本指向的值。
3、va_end
这个宏其实没什么好说的,就是将指针p置成空指针: