可变长参数方法汇总

Varargs in C/C++, Python and Java

Posted by Welt Xing on April 10, 2021

引言

在完成第二次操作系统实验时,遇到printf函数自实现的任务:

int printf(const char* format, ...);

虽然但是,我又一次忘记$\text{C}$的可变长参数的用法。然后又发现,$\text{C++}$,$\text{Python}$和$\text{Java}$其实都有可变长参数的用法,但我现在只记得$\text{Java}$的,所以作此文来复习该语法的用法以供日后查询.

从C开始

我们这里想写一个函数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(...) {
    ...
}

具体步骤如下:

  1. 定义一个函数,最后一个参数为省略号,省略号前是可以设置自定义参数的.
  2. 在函数定义中创建一个 va_list 类型变量,该类型是在 stdarg.h 头文件中定义的.
  3. 使用 int 参数和 va_start 宏来初始化 va_list 变量为一个参数列表。宏 va_start 是在 stdarg.h 头文件中定义的.
  4. 使用 va_arg 宏和 va_list 变量来访问参数列表中的每个项.
  5. 使用宏 va_end 来清理赋予 va_list 变量的内存.

下面就来一步一步实现一个参数可变的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)$使得对任意类型的变量,都能实现字节对齐:

\[f(s)=\lceil\frac{s}{4}\rceil\]

了解了这两个关键宏之后,我们来看看其他宏的作用.

va_start将已知参数压入栈(如上面double sum(int num, ...)中的num),设置指针指向已知参数的后面:

每一块都
是4字节
固定参数
所在字节块
                 
num $\gets$指针指向的位置
也就是该字节的起始点
               

va_arg(ap, t)“返回”以ap为起始地址的_INTSIZEOF(t)个字节内容转换为t型数据,同时ap跳到后面,准备处理第二个参数:假设第一个参数为int

每一块都是
一字节
固定参数
所在字节块
第一个参数(int)                
num   $\gets$指针指向的位置
也就是该字节的起始点
             

如果是小于4字节的参数,比如shortchar,根据_INTSIZEOF宏的定义,我们知道ap还是会进行4字节的自增:

每一块都是
4字节
固定参数
所在字节块
第一个参数(char)                
num   $\gets$指针指向的位置
也就是该字节的起始点
             

对于大于4字节的数据类型,比如double(8字节),ap就会调整自增的步长:

每一块都是
4字节
固定参数
所在字节块
第一个参数(int) 第二个参数(double) 这里也属于
第二个参数
           
num       $\gets$指针指向的位置
也就是该字节的起始点
         

借此我们也可以推理出,$\text{C}$中的"..."语法在底层上的处理和定长普通参数相同,都是将这些参数压入栈.

最后,va_end将指针赋值为NULL,作为结束.

C++的initializer_list

事实上,可变长参数函数在$\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 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的可变参数

在很多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已经无需多学,回想起来甚是可笑.