本文是自己在使用<stdarg.h>头文件时遇到的一些问题和总结

stdarg.h 介绍

<stdarg.h> 是 C 语言处理可变参数的标准头文件。C17 及更早版本要求可变参数函数在至少一个具名参数后使用省略号 ...;C23 也允许参数列表只有 ...。头文件定义了一个类型和若干宏(不是普通函数):

  • va_list:保存遍历状态的类型;它的具体表示由实现决定,不能把它当成普通列表操作。
  • va_start(ap, last_arg):C17 及更早版本用最后一个具名参数初始化 ap。C23 允许写成 va_start(ap);为兼容旧代码,多传的参数仍可保留,但不再参与初始化。
  • va_arg(ap, type):按调用约定读取下一个参数。除标准列出的少数例外外,type 必须与下一个实参经过默认实参提升后的类型兼容:float 会提升为 double,较窄的整数类型会进行整数提升(通常成为 int,必要时成为 unsigned int)。
  • va_copy(dest, src):复制一份可独立推进的遍历状态。不能假定 va_list 可以用普通赋值正确复制。
  • va_end(ap):结束遍历;每次成功执行 va_start 或 va_copy 后都应配对调用。

在 C17 及更早版本中,传给 va_start 的最后一个具名参数还不能声明为 register、函数或数组类型,其类型也必须与默认实参提升后的类型兼容;否则行为未定义。本文示例中的 int childnum 满足这些约束。

stdarg.h 的使用

实例

在多叉树的实现中,下面的函数用于一次加入多个子节点:

void AddChildren(Node* parent, int childnum, ...)
{
    if (parent == NULL) return;
    va_list child_list;
    va_start(child_list, childnum);
    for (int i = 0; i < childnum; i++) {
        parent->children[parent->child_ptr++] = va_arg(child_list, Node*);
    }
    va_end(child_list);
}

这段示例假定 childnum 非负、parent->children 的剩余容量足够,并且随后每个实参的实际类型都是 Node *。实际接口还应校验容量;如果调用方本来就能组织一个数组,传入数组与长度通常比可变参数更容易检查。

问题案例

还有一种错误的实现。它在我当时打印树结构时表现为 Segmentation fault (core dumped),但这只是未定义行为的一种可能结果:

void AddChildren(Node* parent, ...)
{
    if (parent == NULL) return;
    Node* child = NULL;
    va_list child_list;
    va_start(child_list, parent);
    while ((child = va_arg(child_list, Node*)) != NULL) {
        parent->children[parent->child_ptr++] = child;
    }
    va_end(child_list);
    child = NULL;
}

错误分析

我从 Stack Overflow 上找到过相关讨论:stdarg.h reads too many arguments。更准确地说,标准接口没有提供从 va_list 查询剩余参数数量的方法;第一次在不存在下一个实参时调用 va_arg,行为就已经未定义。程序可能碰巧读到某个值、崩溃,也可能出现其他结果,不能把它描述成一种可靠的“持续读取栈内存”过程。

It is your responsibility to communicate to your function how many arguments there are and what their types are.

上面的介绍可以得出第一种解决方案,就是本文中提到的实例,通过向函数传入一个额外的参数来实现。

另一种方案是约定哨兵值。省略号对应的实参只进行默认实参提升,不会像具名形参那样进行赋值式指针转换,因此最后一个参数应显式写成 (Node *)NULL:

AddChildren(parent, child1, child2, (Node *)NULL);

计数参数通常更容易校验;如果采用哨兵方案,必须在接口文档中明确哨兵的类型和值。无论哪种方案,读取超过实参数量或用错误类型读取都会产生未定义行为。

标准依据