在完成第二次操作系统实验时,遇到printf
函数自实现的任务:
int printf(const char* format, ...);
虽然但是,我又一次忘记$\text{C}$的可变长参数的用法。然后又发现,$\text{C++}$,$\text{Python}$和$\text{Java}$其实都有可变长参数的用法,但我现在只记得$\text{Java}$的,所以作此文来复习该语法的用法以供日后查询.
我们这里想写一个函数sum
,对函数参数进行求和:
int sum1 = sum(1); // sum1 should be 0
int sum2 = sum(1, 2); // sum2 should be 2
int sum3 = sum(2, 2, 3); // sum3 should be 2 + 3 = 5
int sum4 = sum(2, 2, 3, 4); // sum4 should be 2 + 3 = 5
int sum5 = sum(3, 2, 3, 4); // sum5 should be 2 + 3 + 4 = 9
第一个参数是函数参数的数量
我们通过头文件<stdarg.h>
和带省略号的函数参数来实现上面的需求:
#include <stdarg.h>
int sum(...) {
...
}
具体步骤如下:
下面就来一步一步实现一个参数可变的sum
函数:
#include <stdarg.h>
#include <stdio.h>
double sum(int num, ...) { // step 1
va_list valist; // step 2
double ret = 0.0;
int i = 0;
va_start(valist, num); // step 3
for (int i = 0; i < num; i++) {
ret += va_arg(valist, double); // step 4
}
va_end(valist); // step 5
return ret;
}
int main() {
printf("Sum of 2, 3 is %f\n", sum(2, 2, 3));
printf("Sum of 2, 3, 4, 5 is %f\n", sum(4, 2, 3, 4, 5));
}
执行结果:
Sum of 2, 3 is 5.000000
Sum of 2, 3, 4, 5 is 14.000000
我们查看stdarg.h
的源码,发现va_list
其实就是字符型指针(From Visual Studio):
typedef char * va_list;
这里不要和字符串混淆,设置字符型指针是因为char
的大小正好是一个字节,我们接着往下看:
// stdarg.h
#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end
// vadefs.h
typedef char * va_list;
#define _crt_va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define _crt_va_end(ap) ( ap = (va_list)0 )
#define _ADDRESSOF(v) ( &(v) )
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
_ADDRESSOF(v)
就是变量v
的地址,那么_INTSIZEOF
怎么理解?设sizeof(n)
为$s$,那么该宏展开就是$f(s)=(s+3)\&(\sim3)$,$\sim3$的二进制表示为$111…11100$,任何数和它相与都会成为$4$的倍数,也就是前两位为$0$. 为何要加上$3$?那是为了实现字节对齐:无论是32位还是64位机器,sizeof(int)
,也就是4字节,永远是机器的位数,$f(s)$使得对任意类型的变量,都能实现字节对齐:
了解了这两个关键宏之后,我们来看看其他宏的作用.
va_start
将已知参数压入栈(如上面double sum(int num, ...)
中的num
),设置指针指向已知参数的后面:
每一块都 是4字节 |
固定参数 所在字节块 |
|||||||||
---|---|---|---|---|---|---|---|---|---|---|
… | num | $\gets$指针指向的位置 也就是该字节的起始点 |
va_arg(ap, t)
“返回”以ap
为起始地址的_INTSIZEOF(t)
个字节内容转换为t
型数据,同时ap
跳到后面,准备处理第二个参数:假设第一个参数为int
:
每一块都是 一字节 |
固定参数 所在字节块 |
第一个参数(int) | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
… | num | $\gets$指针指向的位置 也就是该字节的起始点 |
如果是小于4字节的参数,比如short
,char
,根据_INTSIZEOF
宏的定义,我们知道ap
还是会进行4
字节的自增:
每一块都是 4字节 |
固定参数 所在字节块 |
第一个参数(char) | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
… | num | $\gets$指针指向的位置 也就是该字节的起始点 |
对于大于4字节的数据类型,比如double
(8字节),ap
就会调整自增的步长:
每一块都是 4字节 |
固定参数 所在字节块 |
第一个参数(int) | 第二个参数(double) | 这里也属于 第二个参数 |
||||||
---|---|---|---|---|---|---|---|---|---|---|
… | num | $\gets$指针指向的位置 也就是该字节的起始点 |
借此我们也可以推理出,$\text{C}$中的
"..."
语法在底层上的处理和定长普通参数相同,都是将这些参数压入栈.
最后,va_end
将指针赋值为NULL
,作为结束.
事实上,可变长参数函数在$\text{C}$中存在安全问题,如没有长度检查和类型检查,在传入过少参数或不符的类型会出现溢位的情况,更有可能成为攻击目标.
C++11中提供了std::initialzer_list
的新特性,用于表示某种特定类型的值的数组,属于模板类型,它相对于前面少了类型的随意性,必须是同一类型.
由于C++模板咱也不熟,所以只介绍简单的使用方法,我们还是以一个求和函数为例:
#include <initializer_list>
#include <iostream>
using namespace std;
int sum(initializer_list<int> l) {
int ret = 0;
for (auto iter = l.begin(); iter != l.end(); iter++) {
ret += *iter;
}
return ret;
}
int main(int argc, char const *argv[]) {
cout << sum({1, 2, 3}) << endl;
return 0;
}
值得注意的是,initialzer_list
严格意义上并不是“可变长参数,因为参数的外面需要有{}
包裹,参数始终只有一个;但反过来说,我们确实实现了不同数量参数的传递,不是吗?
Java 5中提供了变长参数,实际上是Java的语法糖,本质上还是基于数组的实现:
void function(int... args);
// 等价于
void function(int[] args);
由于Java中数组定义的语法,我们可以向上面那样直接将[]
看作...
;再进一步看,Java中的可变长参数更像是将$\text{C++}$中的initializer_list
的数组特性和$\text{C}$中的省略号语法结合起来使用:
class Main {
public static main(String[] args) {
System.out.println(sum(1, 2, 3)); // 输出为6
}
public static int sum(int... args) {
int ret = 0;
for (int i = 0; i < args.length; i++) {
ret += args[i];
}
return ret;
}
};
在很多Python代码中,我们常常会遇到,在函数定义的参数处,都会跟上*args
和**kwargs
,前面一个就是可变参数,后面一个则是不定参数的另一种形式.
我们先做一个测试:
def func1(a, *args):
print(a)
print(args)
>>> func1(1, 2, 3, 4)
1
(2, 3, 4)
可以发现,args
是以元组的形式进行存储。你也可以直接定义可变参数,就像之前的求和程序那样:
def sum(*args):
ret = 0
for elem in args:
ret += elem
return ret
>>> sum(1, 2, 3)
6
形参前一个*
是元组参数,两个*
就是字典参数:
def func2(**kwargs):
print(kwargs)
>>> func2(x=1, y=2, z=3)
{'x': 1, 'y': 2, 'z': 3}
我们也可以反过来传入一个字典,只是要注意要加上**
:
>>> d = {'x': 1, 'y': 2, 'z': 3}
>>> func2(**d)
{'x': 1, 'y': 2, 'z': 3}
该文虽然是将一个语法进行多语言地总结,但在C语言上着墨较多。学习C语言2年以来,越觉得它的强大和高深,时至今日,我已经能够书写出去年的我所看不懂的代码,但当时我居然觉得C已经无需多学,回想起来甚是可笑.