在完成第二次操作系统实验时,遇到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已经无需多学,回想起来甚是可笑.