C语言篇:可变参数函数

4 阅读6分钟

前言

可变参数函数是C语言中一种相当特殊的语法,它允许我们向函数传递任意数量和类型的参数,并在运行时根据我们自己书写的逻辑来解析这些参数。当然,这样做也带来了一些代价,比如牺牲了编译器的类型检查以及带来了默认参数提升。

实际上当我们写出第一个打印"hello, world"的程序时就已经在和可变参数函数打交道了,printfscanf都是可变参数函数,也恰好是可变参数函数的最佳使用场景。尽管可变参数函数被广泛使用,然而很多人直到C语言课程结束仍然对其一无所知,就连ISO标准也对可变参数函数一笔带过,而且几乎什么都没说,实在是令人失望。

申明和定义

可变参数函数的申明和定义与普通函数几乎没有区别,只是最后一个参数变成了...,表示可以接受0个或多个不确定类型的参数。

C23之前要求可变参数函数最少有一个命名参数,C23取消了这个限制。

// 申明
int sum(int n, ...);
int sum(...);           // C23之前不合法

// 定义
int sum(int n, ...) { }
int sum(...) { }        // C23之前不合法

调用可变参数函数时,编译器会对命名参数进行类型检查,而未命名的参数则可以是任何数量的任意表达式,也可以为空。

访问未命名参数

头文件stdarg.h提供了一个类型和多个宏用于访问可变参数函数中的未命名参数。

类型va_list用于创建句柄,假设变量名是ap,它可被va_start初始化,之后每次调用va_arg都可以获取下一个参数,最后调用va_end清理ap。

C语言标准对va_list没有任何具体说明,这个类型不确定是指针、整数、结构体或者其他乱七八糟的。

C语言标准对va_startva_argva_end的实现原理也没有任何说明,只要求它们必须定义为宏。在gcc中,这些宏都被定义为内置命令,由编译器实现其功能,而不是由预处理器。

C23之前,va_start的用法是va_start(ap, parmN),其中ap必须是va_list类型的对象,parmN必须是最后一个命名参数。C23中的用法是va_start(ap, ...)parmN被省略,为了保持兼容使用了可变参数宏,不过只有第一个参数有效。这个宏只初始化ap,没有返回值。

va_arg的用法是va_arg(ap, type),其中ap必须先被va_start初始化,每次调用va_arg都会修改aptype是预期下一个参数的类型。va_arg从第一个未命名参数开始,每调用一次都返回下一个参数,返回值类型为type。如果参数的实际类型和type不匹配,则行为未定义。C语言标准没有说明va_arg的返回值是不是左值,所以只能假定它不是左值。

va_end的用法是va_end(ap),它会清理ap。如果对没有先va_startap调用va_end,或者函数返回时va_start了的ap没有va_end,行为都是未定义的。

简而言之,按照C语言标准的说法,你必须先定义一个va_list类型的变量,然后用va_start初始化,再用va_arg以正确的类型获取每个参数,最后调用va_end。除此之外的一切做法都是未定义的,并且编译器也可能检查不到错误。

C23和之前标准的区别在于函数原型和va_start中的命名参数。在早期的系统中,函数的参数传递非常简单,仅仅是把每个参数依次压入栈中,所以只要能确定第一个参数的内存地址,就能通过指针偏移遍历所有的参数。这就是为什么函数原型和va_start都需要一个命名参数,它是一个锚点。因为只涉及指针操作,早期访问未命名参数的实现也非常简单,可以纯粹用宏来实现,va_list只是一个指针,va_start获取第一个参数的地址,va_arg通过类型大小来偏移指针并返回所指对象。随着时代发展,各种处理器和操作系统对参数传递的处理方式各不相同,可能通过寄存器传递,也可能通过栈传递,但不一定按照固定顺序。使用宏实现访问未命名参数已经不可能,只能由编译器根据特定平台的机制生成特定的代码,此时函数原型和va_start中的命名参数已经没有意义了。

默认参数提升

可变参数函数在参数传递时,会对未命名的参数进行默认参数提升。类型等级小于int的参数(比如charshort)会被提升为intfloat会被提升为double,其他类型不变。

因为有默认参数提升的存在,所以在使用va_arg时,type不能是shortfloat等提升之前的类型。也正因如此,printf的格式控制里小于等于int的整数都用%dfloatdouble都用%f;而scanf传的是指针,所以必须明确每一个写入对象的实际类型。

示例

接下来写一个示例程序,计算多个数的和。

先写一个C23之前的版本。因为至少要有一个命名参数,所以我们用它来表示未命名参数的数量。

#include <stdio.h>
#include <stdarg.h>

int sum(int n, ...) {
    va_list ap;
    va_start(ap, n);
    int result = 0;
    for (int iter = 0; iter < n; ++iter) {
        result += va_arg(ap, int);
    }
    va_end(ap);
    return result;
}

int main(void) {
    printf("%d\n", sum(3, 1, 2, 3));
}

再写一个C23的版本。没有了额外的变量,我们用数字0来表示结束。

#include <stdio.h>
#include <stdarg.h>

int sum(...) {
    va_list ap;
    va_start(ap);
    int result = 0;
    while (1) {
        int num = va_arg(ap, int);
        if (num == 0) {
            break;
        }
        result += num;
    }
    va_end(ap);
    return result;
}

int main(void) {
    printf("%d\n", sum(1, 2, 3, 0));
}

上面的两个示例都默认参数类型是int,只有参数数量在变化。这种情况下,使用数组更加安全和方便。如果参数类型也是变化的,则需要额外的信息来传递类型,比如一个记录类型的字符串,所以说printfscanf是可变参数函数的最佳使用场景。