您好,这里是limou3434的一篇博文,感兴趣可以看看我的其他内容。本次我给您带来了C语言的“可变参数列表”,要明白这些内容,您可能需要重新复习下C语言视角的栈帧空间知识。最后我还给出两个小的C语言知识点:“命令行参数”和“递归调用”,这旨在补全您的C语言知识面。
可以去看看我之前的博客
关于我之前讲解的栈帧空间图解,还有一些细节的方面在这里需要强调,以便后续理解可变参数列表。
int Add(int a, int b)
{
printf("Before:%d\n", b);
*(&a + 1) = 100;
printf("After:%d\n", b);
int z;
z = a + b;
return z;
}
在我们了解可变参数列表的原理之前,可以先来看看可变参数列表具体的使用是怎么样的。有了这一铺垫,哪怕您看不懂原理,使用起可变参数列表也是没有问题的。
#include
#include
int GetMax(int number, ...)//注意:可变参数要被使用,则其前面至少有一个及以上个明确参数
{
//使用四个宏来编写代码:va_list、va_start、va_arg、va_end
//由于我们自己是不太可能在栈帧空间中一一找到所有临时变量对应的地址并且进行解引用,所以我们为了方便使用,C语言提供了“三个操作符”和“一个类型符”,来完成寻找临时变量的操作
va_list arg;//1.定义一个可以访问可变参数部分的变量,其实就是一个char*类型的变量,这意味着该变量可以按照一个字节的方式访问数据
va_start(arg, number);//2.使arg指向可变参数部分
int max = va_arg(arg, int);//3.根据类型大小可以获取可变参数列表中的参数数据(这里获取的是第一个int参数数据)
for (int i = 0; i < number - 1; i++)
{
int x = va_arg(arg, int);//4.持续获取下一个参数
if (max < x)
{
max = x;
}
}
va_end(arg);//5.arg使用完毕,收尾工作,本质就是将arg指向NULL(类似free的使用,避免arg成为野指针)
return max;
}
int main()
{
int max = GetMax(5, 1, 2, 3, 4, 5);
printf("%d", max);
return 0;
}
本质就是利用:哪怕函数调用的时候使用了多余的实际参数,栈帧空间也会创建临时变量来保存这些实际参数,并且是根据参数列表从右向左创建对应的临时变量(从右往左一一进行push)。
那么具体的细节应该怎么理解呢?
首先在创建临时变量的时候,编译器依次push了5, 4, 3, 2, 1, 5这六个变量(注意顺序在函数调用中是GetMax(5, 1, 2, 3, 4, 5),即:从右到左push)
然后根据va_list arg;这就定义一个可以访问可变参数部分的变量,其实就是一个char*类型的变量,这意味着该变量可以按照一个字节的方式访问数据
va_start(arg, number);使arg指向可变参数部分,即指向临时变量number的地址
int max = va_arg(arg, int);根据类型大小可以获取可变参数列表中的参数数据(这里获取的是第一个int参数数据)
for (int i = 0; i < number - 1; i++) { int x = va_arg(arg, int)};这个语句则持续获取下一个参数(在x86比较好演示,甚至于我们可以根据栈帧规则手动创建一个不需要“…”可变参数的可变参数函数。然而在现代编译器中就比较难以实现了,比如在x64环境中连续push后的临时变量地址不容易手动找到,要查找只能依靠关键字va_arg(arg, int);而从这一点我们也可以明白为什么可变参数“…”必须放在最后,这是为了避免读取到参数列表中靠前面的临时参数)
如果感兴趣,可以试着将多个char类型的实参传给GetMax函数,求得多个字符中ACSII码值最大的字符,但是上述代码都不变,这个时候会有一个很神奇的现象,代码依旧能正常运行。有人会问“va_arg(arg, int)”语句不是会根据“int”类型来查找临时变量的地址吗?每一次arg的挪动都应该是int个字节才对。
是的没错,arg变量每一次的确是移动了4个字节(int的大小),但是由于char类型的实参在调用GetMax函数之前,会在栈帧中压入这几个cha类型变量的值,但是这里发生了整型提升(char->int),根本原因就是因为内存中存储的数据基本都是4个字节/8个字节起步的,char数据会隐式提升位int类型来压入栈。
这就导致一个比较违反直觉的事情:将代码改成va_arg(arg, char)这种行为是错误的!!!(当然也不只是char类型是特殊的,short和float类型也会发生类似的事情。)
因此根据类型提取数据的时候,我们更多是依靠int和double类型来进行提取的,不规范使用关键字va_arg的话,会造成不可预估的后果。
思考一下,如果超出了可变参数列表的范围会怎么样呢?
注意:这几个宏在不同的编译器有可能有不一样的定义
typedef char* va_list;//本质就是一个char类型指针
注释可能有点长,其中需要对除法有更进一步的数学理解能力,还请您耐心看下去……
#define va_start __crt_va_start
#define _crt_va_start(ap, v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
//1.而其中宏“_INTSIZEOF”的定义是“#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )”
//2.即,“_INTSIZEOF”的作用是“4字节对齐(向上取整,整体为4的倍数)”
//3.而“_INTSIZEOF”能“4字节对其”的原理是:
//3.1.对于“_INTSIZEOF”来说,n可能是char类型、double类型、int类型等,而“_INTSIZEOF”的作用就是把这些不同的类型转化为“4字节对其”的数字,让“(va_list)_ADDRESSOF(v)”能找到可变参数的第一个可变参数的地址,例如“char和short类型通过_INTSIZEOF计算出来的数值都是4,而double则是8”
//3.2.从数学角度上来说,“_INTSIZEOF”的意思就是计算一个x,满足(x>=N)&&(x%4==0),其中N就是sizeof(n)的大小,例如:“sizeof(char) = 1,则x=4”。在C语言中N的取值只有1、2、4、8等
//3.3.因此实际上,再讲大白话一点“_INTSIZEOF”做的事情就是找到一个x,满足x为4的倍数,即“x=4*m”,并且“x>=N”(m!=0,N!=0)
//3.4.如果N能被4整除,则m==N/4①
//3.5.如果N不能被4整除,则m==N/4+1②
//3.6.其实按照上面的逻辑,就可以写出一个普通的“_INTSIZEOF”了。但是若要简介,则可以合并起来写来求得m,常见的写法就是“m=(N+4-1)/4”,这个式子可以整合两个公式
//3.6.1.“+4”是为了凑够数,让没能整除4的N变得能整除,并且得到的m==1
//3.6.2.“-1”是受到“+4”的影响,如果有“+4”并且N能被4整除,则m会计算多一个,因此“-1”后就不多了(或者理解为“N=能被4整除的部分+不能被4整除的部分r”,而“+3”就会导致“4<=r+3<7”,则“m==N/4+1”)
//3.7.这样就顺利得到m的值,就可以推导出4*m的大小啦,进而理解了宏“_INTSIZEOF”的工作原理,有了上述的公式,完全可以写一个和库里等价的“_INTSIZEOF”
//3.8.但是这样的方法还是不够简洁,对于表达式“4*m==4*(N+4-1)/4”,假设“w=N+4-1”,则得到“(w/4)*4”,欸?这不就是“(w/(2^2)*(2^2))”么?那么在比特位上不就相当于先右移两位,再左移两位么?这不就相当于给一个二进制序列的末两位二进制位给清0了?那不就可以直接写“w & ~3”了?直接一步到位,所以简洁版诞生了“w&~3”等价于“(N+4-1) & ~3”,这么一写,可不就是库里定义的“_INTSIZEOF”了嘛……这就是二进制的力量!!!将“/*”算术运算转化为“&~”位运算,效率得到了极大的提高
//3.9.最后再提一嘴,这其实是一种取整的方法嘛…
//4.因此“_crt_va_start”的整体实现就是:“ap = (char*)&v + 4*m”,其中m由v来决定,既:4*m通过“_INTSIZEOF”确认。这样就让qp参数存储的是指向可变参数的地址
#define va_arg __crt_va_arg
#define _crt_va_arg(ap, t) (*(t *)((ap += _INTSIZEOF(t))- _INTSIZEOF(t)) )
//1.“_crt_va_arg”的作用有两个:一个是找到目标值,另一个是找到将ap自增
//2.这个代码写的非常具有特色和技术:自增"4*m"找到下一个可变参数2的起始地址,但是又减去“4*m”找回原来的可变参数1的起始地址。
//3.这个时候就厉害了,ap存储的是可变参数2的起始地址,但是使用宏“_crt_va_arg”时获取的是可变参数1的起始地址。使用宏“_crt_va_arg”就能达到既能取得可变参数1的起始地址,而下一个使用宏“_crt_va_arg”的时候自动指向下一个可变参数2的起始地址。
//4.获得可变参数1的起始地址后,就可以通过可变参数的类型来强制转化,并且进行解引用得到可变参数1内的数据
#define va_end __crt_va_end
#define _crt_va_end(ap) ( ap = (va_list)0 )
//这个就比较简单,就是将ap指针指向空而已
在不同编译器中,这四个关键字还有可能存在不一样的定义,但是其基本逻辑是大差不差的,就比如VS2022的C库里定义的四个关键字和上面的就有很大区别,不过您只需了解即可……
main函数也是一个函数,也是可以携带参数的
int main(int argc, char* argv[], char* envp[])
{
program-statements
}
而可以像下面一样使用,“argc”就是命令行中一串命令的子字符串个数,而每一个命令内包含的子字符串会被分别存储到“argv[]”这个数组中,这样就可以让程序在命令行中表现出不一样的行为
$ vim main.c
//--------
//vim中书写的代码
int main(int argc, char* argv[])
{
int i = 0;
for(i = 0; i < argc; i++)
{
printf("%s\n", argv[i]);
}
return 0;
}
//--------
$ gcc main.c
$ ./a.out abcdef ghij
./a.out
abcdef
ghij
而“envp[]”这个数组又是什么呢?存储的是环境变量,不存在就以NULL结尾
int main()
{
for(int i = 0; envp[i]; i++)
{
printf("envp[i] = %d\n", i, envp[i]);
}
return 0;
}
函数在创建的时候,可以调用别的函数,包括自己调用自己,即:C语言支持递归。(比如:main调用自己,但是如果控制不好,容易崩溃)
只要是函数调用就会创建函数栈帧,而递归只是一种特殊的函数调用,而内存的大小是有限的,因此递归一定是有限次递归的,即:大部分情况下,递归必须有递归出口来结束递归。
递归会消耗时间和空间上的消耗
递归最适合在那种“问题和子问题是同一个解决方法”的问题里,例如某些有关二叉树的代码,您可以去了解一下,这是属于数据结构的知识。
#include
int my_strlen(const char* str)
{
if(*str == '\0')
{
return 0;
}
return 1 + my_strlen(str + 1);
}
int main()
{
int len = my_strlen("abcdef");
printf("%d\n", len)
return 0;
}
递归还有可能出现重复运算的问题,比如最经典的斐波那契的求解。其树形结构的递归就会出现大量冗余重复的计算
这里只针对斐波那契数列问题,介绍两种常见的方法供您使用。
int main()
{
int n = 10;
int* f = (int*)malloc(sizeof(int) * (n + 2));
if (!f) exit(-1);
//实际上这就是一个简单的动态规划例子
//1.条件初始化
f[1] = 1;
f[2] = 1;
int i = 3;
while (i <= n)
{
//2.递推过程
f[i] = f[i - 1] + f[i - 2];
i++;
}
printf("%d\n", f[n]);
free(f);
return 0;
}
int main()
{
int n = 10;
int first = 1;
int second = 1;
int third = 1;
while (n >= 3)
{
third = second + first;
first = second;
second = third;
n--;
}
printf("%d\n", third);
return 0;
}
本次我和您一起复习了C语言的栈帧空间,并且引出可变参数列表的使用和原理。还和您补充了一些有关“命令行参数”和“递归”的相关应用。到此我的C语言系列基础文章算是告一段落……