| 目录 |

25 可变参数函数

Variadic 是个花哨的说法,指接受任意数量实参的函数。

普通函数接受特定数量的实参,例如:

int add(int x, int y)
{
    return x + y;
}

你只能用恰好两个实参调用它,分别对应形参 x 和 y。

add(2, 3);
add(5, 12);

但如果多传几个,编译器不会让你这么做:

add(2, 3, 4);  // 错误
add(5);        // 错误

可变参数函数在一定程度上绕过了这个限制。

我们已经见过一个著名的例子:printf()!你可以传各种东西给它。

printf("Hello, world!\n");
printf("The number is %d\n", 2);
printf("The number is %d and pi is %f\n", 2, 3.14159);

它似乎根本不在乎你给它多少实参!

嗯,这并不完全正确。零个实参会导致错误:

printf();  // 错误

这就引出了 C 中可变参数函数的一个限制:它们必须至少有一个实参。

除此之外,它们相当灵活,甚至允许实参具有不同的类型,就像 printf() 那样。

我们来看看它们是怎么工作的!

25.1 函数签名中的省略号

那么从语法上讲,它是怎么工作的?

你要做的是,把所有必须传递的实参放在前面(记住至少得有一个),然后在其后放 ...。就像这样:

void func(int a, ...)   // 这里字面上就是 3 个点

下面是一些演示代码:

#include <stdio.h>

void func(int a, ...)
{
    printf("a is %d\n", a);  // 打印 "a is 2"
}

int main(void)
{
    func(2, 3, 4, 5, 6);
}

很好,我们能拿到变量 a 里的第一个实参,但其余的实参呢?怎么访问它们?

好玩的部分从这里开始!

25.2 获取额外的实参

要让这些工作,你需要包含 <stdarg.h>。

首先,我们要用一个 va_list 类型(variable argument list,可变参数列表)的特殊变量,来跟踪我们当前正在访问哪个参数。

思路是:我们先调用 va_start() 开始处理参数,用 va_arg() 依次处理每个参数,然后完成后用 va_end() 收尾。

调用 va_start() 时,需要传入最后一个具名形参(也就是 ... 前面那个),这样它才知道从哪里开始找额外的参数。

而调用 va_arg() 获取下一个参数时,必须告诉它下一个参数的类型。

下面这个演示把任意数量的整数相加。第一个参数是要相加的整数个数。我们会利用它来确定需要调用 va_arg() 多少次。

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

int add(int count, ...)
{
    int total = 0;
    va_list va;

    va_start(va, count);   // 从 "count" 之后的参数开始

    for (int i = 0; i < count; i++) {
        int n = va_arg(va, int);   // 获取下一个 int

        total += n;
    }

    va_end(va);  // 全部完成

    return total;
}

int main(void)
{
    printf("%d\n", add(4, 6, 2, -4, 17));  // 6 + 2 - 4 + 17 = 21
    printf("%d\n", add(2, 22, 44));        // 22 + 44 = 66
}

(注意,调用 printf() 时,它利用格式字符串中 %d(或其他说明符)的个数来知道还有多少个参数!)

如果你觉得 va_arg() 的语法很奇怪(因为里面飘着一个松散的类型名),你并不孤单。它们是用预处理器宏实现的,以便把各种必要的魔法都塞进去。

25.3 va_list 的功能

我们在上面用的那个 va_list 变量到底是什么?它是一个不透明变量153,保存着下一次 va_arg() 会取哪个参数的信息。你看到我们就那样一遍又一遍地调用 va_arg() 了吧?va_list 变量就是一个记录当前进度的占位符。

但我们必须把这个变量初始化为某个合理的值。这就是 va_start() 的用武之地。

上面我们调用 va_start(va, count) 时,意思就是:“把 va 变量初始化为指向 count 紧随其后的那个可变参数。”

这就是为什么我们的参数列表中必须至少有一个具名变量154。

一旦有了指向初始参数的指针,你就可以通过反复调用 va_arg() 轻松获取后续的参数值。调用时,必须传入你的 va_list 变量(这样它才能持续跟踪你所在的位置),以及你即将读取的参数类型。

作为程序员,你需要自己弄清楚要传给 va_arg() 的是哪个类型。在上面的例子中,我们只用 int。但就 printf() 而言,它用格式说明符来决定接下来取哪个类型。

完成后,调用 va_end() 收尾。规范说,在你决定再次对某个 va_list 变量调用 va_start() 或 va_copy() 之前,你必须对它调用这个函数。我知道我们还没讲过 va_copy()。

所以标准流程是:

我在上面还提到了 va_copy();它会把你的 va_list 变量复制成完全相同的状态。也就是说,如果你还没对源变量开始调用 va_arg(),新变量也不会开始。如果你到目前为止已经用 va_arg() 消耗了 5 个变量,副本也会反映这一点。

如果你需要向前扫描各个实参,同时又得记住当前的位置,va_copy() 会很有用。

25.4 使用 va_list 的库函数

它们的另一个用途相当酷:编写你自己的自定义 printf() 变体。要处理那么多格式说明符岂不是很痛苦?成千上万个呢。

幸运的是,有一些 printf() 变体可以接受一个可用的 va_list 作为实参。你可以用它们来包装并做出自己的自定义 printf()!

这些函数以字母 v 开头,例如 vprintf()、vfprintf()、vsprintf() 和 vsnprintf()。基本上就是你那些 printf() 经典老歌,只不过前面多了个 v。

我们来做一个 my_printf() 函数,它的工作方式和 printf() 一样,只是前面多接受一个参数。

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

int my_printf(int serial, const char *format, ...)
{
    va_list va;
    int rv;

    // 做我自定义的工作
    printf("The serial number is: %d\n", serial);

    // 然后把剩下的交给 vprintf()
    va_start(va, format);
    rv = vprintf(format, va);
    va_end(va);

    return rv;
}

int main(void)
{
    int x = 10;
    float y = 3.2;

    my_printf(3490, "x is %d, y is %f\n", x, y);
}

看出我们做了什么吗?在第 12 到 14 行,我们启动了一个新的 va_list 变量,然后直接把它传给 vprintf()。它完全知道该怎么处理它,因为它内置了 printf() 的全部聪明才智。

不过,完成后我们仍然必须调用 va_end(),所以别忘了!

25.5 可变参数宏的陷阱

正如我提到的,va_start() 和 va_end() 可能是宏。这带来的一个后果是,它们有可能引入一个新的局部作用域。(也就是说,如果 va_start() 里有 {,而 va_end() 里有 }。)

所以我们需要当心潜在的作用域问题。看下面这个例子:

va_start(va, format);          // 可能包含 {
int rv = vprintf(format, va);
va_end(va);                    // 可能包含 }

return rv;

如果 va_start() 开启了一个新作用域,rv 就会成为该作用域的局部变量,于是 return 语句就会失败。但这种阴险的问题只会出现在那些恰好对 va 宏这么做的编译器上。


| 目录 |