[深入浅出C语言]可变参数列表

1,575 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第26天,点击查看活动详情

前言

        本文就来分享一波作者对可变参数列表的学习心得与见解。

        笔者水平有限,难免存在纰漏,欢迎指正交流。

        建议学习了函数栈帧后再学习如下内容,关于函数栈帧可移步至:[深入浅出C语言]深入函数栈帧 - 掘金 (juejin.cn)

可变参数列表

        可变参数列表主要由四个宏来控制,分别是va_list,va_start,va_arg,va_end,我们从例子入手,由浅入深讲解一下。

引例:求最大值

        对于两个数据取最大值是很简单的,直接比较两个数据不就ok了嘛。

#include <stdio.h>
int FindMax(int x, int y)
{
    if (x > y)
    {
    	return x;
    }
    return y;
}
int main()
{
    int x = 0;
    int y = 0;
    printf("Please Eneter Two Data# ");
    scanf("%d %d", &x, &y);
    
    int max = FindMax(x, y);
    printf("max = %d\n", max);
	return 0;
}

        那如果要求任意多个数据中的最大值,又不能使用数组传参,该怎么办呢?

        因为目前参数个数不确定,那么函数编写的时候,参数个数也无法确定,换句话说,函数好像也就没法编写呀。

        不过,C提供了满足该场景的解决方案:可变参数列表。

说明与铺垫

        可变参数列表的参数数目是可以根据情况而改变的,适用于函数传入参数个数有多个且不确定具体数目,比如我们日常一直在用的printf()和scanf()的参数用的就是可变参数列表,比如:

printf("%d %d %d %lf", a, b, c, d, e);printf("%d", a);

        只要你的转换说明写对了,并且转换说明的个数和参数个数对应了,那么传多少个参数都可以。

        转到printf函数的定义处可以看到:

        后面的就是在使用可变参数列表。那能不能把上面printf函数的_Format参数也给省去变成只有...呢?

        不能,可变参数列表至少要有一个有效元素。

铺垫:

1.在C中,如果函数没有形参,仍可以给函数传递参数。

2.在C中,只要发生了函数调用并且传递了参数就必定形成临时拷贝。

3.所谓的临时拷贝本质就是在函数栈帧内部形成的,并且是从右向左依次形成的。

4.临时拷贝是要入栈的,根据函数栈帧文章中所学,可知入栈参数之间位置关系是固定的。

示例与分析

例1

#include <stdio.h>
//num:表示传入参数的个数
int FindMax(int num, ...)
{
    va_list arg; //定义可以访问可变参数部分的变量,其实是一个char*类型
    va_start(arg, num); //使arg指向可变参数部分
    int max = va_arg(arg, int); //根据arg提供的地址和传入的类型(这里是int),依次获取可变参数列表中的数据
    for (int i = 0; i < num - 1; i++)
    {
        //获取并比较其他的
        int curr = va_arg(arg, int);
        if (max < curr)
        {
        	max = curr;
        }
	}
	va_end(arg); //arg使用完毕,收尾工作。本质就是将arg的值置为NULL
	return max;
}

int main()
{
    int max = FindMax(5,0x11,0x21,0x31,0x41,0x51);
    printf("max = %d\n", max);
    return 0;
}

例2

        如果将参数改成char类型,求char类型变量中的最大值,会得到什么结果?

#include <stdio.h>
//num:表示传入参数的个数
int FindMax(int num, ...)
{
    va_list arg; //定义可以访问可变参数部分的变量,其实是一个char*类型
    va_start(arg, num); //使arg指向可变参数部分
    int max = va_arg(arg, int); //根据类型,获取可变参数列表中的第一个数据
    for (int i = 0; i < num - 1; i++)
    {
        //获取并比较其他的
        int curr = va_arg(arg, int);
        if (max < curr)
        {
       		max = curr;
        }
    }
    va_end(arg); //arg使用完毕,收尾工作。本质就是讲arg指向NULL
    return max;
}

int main()
{
    char a = '1'; //ascii值: 49
    char b = '2'; //ascii值: 50
    char c = '3'; //ascii值: 51
    char d = '4'; //ascii值: 52
    char e = '5'; //ascii值: 53
    int max = FindMax(5, a, b, c, d, e);
    printf("max = %d\n", max);
    return 0;
}

        可以运行并得到正确结果。

        实际上,char类型参数传入压栈时会进行符号扩展,也就是整型提升成为int类型,所以可以va_arg(arg, int)这样用,而不能va_arg(arg, char)这样用。

        通过查看汇编,我们看到,在可变参数场景下:

  1. 实际传入的参数如果是char,short,float,编译器在编译的时候,会自动进行提升(通过查看汇编,我们都能看到)

  2. 函数内部使用的时候,根据类型提取数据,更多的是通过int或者double来进行

比如上一个例子中

注意事项

        可变参数必须从头到尾逐个访问。如果你在访问了几个可变参数之后想半途终止,这是可以的,但是,如果你想一开始就访问参数列表中间的参数,那是不行的。

        参数列表中至少有一个命名参数。如果连一个命名参数都没有,就无法使用 va_start 。

        这些宏是无法直接判断实际存在参数的数量。

        这些宏无法判断每个参数的类型。

        如果在 va_arg 中指定了错误的类型,那么其后果是不可预测的。

深析原理

几个宏的剖析

        先看看这几个宏的含义:

        va_list其实就是char*,方便后续按照1字节单位进行指针移动。

#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 _ADDRESSOF(v) ( &(v) )  //取参数的地址
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

        难点是 _INTSIZEOF(n),不太好理解,暂时理解为4字节对齐(向上取整),也就是说如果是小于4字节的,统一提升为4字节,而如果大于4小于8的统一提升为8字节,以此类推,对齐到4的倍数。

        拿前面提过的例子来看的话,可以得出ap = (char*)(&(v)) + 4也就是arg = (char*)(&num) + 4

image.png

        而关于#define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )还是用前面的例子来分析:

        这个设计特别巧妙,先让ap指向下个元素,然后使用相对位置-偏移量,访问当前元素。

        访问了当前数据的同时,还让ap指向了后续元素,一举两得。

        #define _crt_va_end(ap) ( ap = (va_list)0 ) 意为将ap指针设置为NULL,防止出现野指针

深入理解_INTSIZEOF(n)

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

        _INTSIZEOF(n)的意思:计算一个最小数字x,满足 x>=sizeof(n) && x%4==0,就是求出能整除4的最小整数。

        我们先做一个规定:下面的分析都用n来表示sizeof(n),char对应1,short对应2,int对应4。

为什么要有4字节对齐

        因为参数压栈时会发生类型提升,形成的临时拷贝要么4字节要么8字节,所以在取出使用的时候也要按4字节或8字节。

第一步理解:4的倍数

        既然是4的最小整数倍取整,那么本质是:x=4*m,m是具体几倍。比如对7来讲,m就是2,对齐的结果就是8。

        而m具体是多少,取决于n是多少

        如果n能整除4,那么m就是n/4

        如果n不能整除4,那么m就是n/4+1

        上面是两种情况,如何合并成为一种写法呢?

        常见做法是 ( n+sizeof(int)-1) )/sizeof(int) -> (n+4-1)/4

简略证明:

        如果n能整除4,那么m就是(n+4-1)/4->(n+3)/4, +3的值无意义,会因取整自动消除,等价于 n/4,比如4能整除4得1,+3后变成7,除4还是得1。

        如果n不能整除4,那么n=最大能整除4部分+r,1<=r<4 那么m就是 (n+4-1)/4->(能整除4部分+r+3)/4,其中

        4<=r+3<7 -> 能整除4部分/4 + (r+3)/4 -> n/4+1

第二步理解:最小4字节对齐数

        搞清楚了满足条件最小是几倍问题,那么,计算一个最小数字x,满足 x>=n && x%4==0,就变成了

((n+sizeof(int)-1)/sizeof(int))[最小几倍] * sizeof(int)[单位大小] -> ((n+4-1)/4)*4

        这样就能求出来4字节对齐的数据了,其实上面的写法,在功能上,已经和源代码中的宏等价了。

第三步理解:理解源代码中的宏

        ((n+4-1)/4)* 4,设w=n+4-1,那么表达式可以变化成为 (w/4)4,而4就是2^2,w/4,不就相当于右移两位吗?再次4不就相当左移两位吗?先右移两位,在左移两位,最终结果就是,最后2个比特位被清空为0。

        需要这么费劲吗?

        直接w & ~3 不香吗?

        所以,简洁版:(n+4-1) & ~(4-1)

        原码版:( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) ),无需先/再*