省略号(以及为何应避免使用)

省略号(以及为何应避免使用)

迄今为止我们接触的所有函数,其形参数量均须在编写时即已确定(即便这些形参拥有缺省值亦然)。然而,在某些场景下,若能向函数传递可变数量的实参将颇具价值。C++ 为此提供了一种特殊说明符,称为省略号(ellipsis,即“…”),使我们得以实现这一需求。

由于省略号极少使用、潜在风险较高,且我们建议尽量避免使用,因此本节可视作选读内容。

使用省略号的函数形式

return_type function_name(argument_list, …)

其中 argument_list 为一个或多个常规函数形参。请注意,使用省略号的函数必须至少包含一个非省略号形参;任何实参须首先与 argument_list 中的形参匹配。省略号(以连续三点表示)必须为函数形参表中最后一个形参;其捕获所有额外实参(若存在)。虽不严谨,但将省略号概念化地视作一个存放 argument_list 之外所有额外形参的数组,颇有助益。

示例:省略号的运用

学习省略号的最佳途径是实例。假设我们希望编写一个计算若干整数平均值的函数,代码可如下所示:

#include <iostream>
#include <cstdarg> // 使用省略号所需头文件

// 省略号必须置于形参表末尾
// count 表示额外实参的个数
double findAverage(int count, ...)
{
    int sum{ 0 };

    // 通过 va_list 访问省略号实参
    std::va_list list;

    // 使用 va_start 初始化 va_list。第一个参数为待初始化的 va_list,
    // 第二个参数是最后一个非省略号形参。
    va_start(list, count);

    // 遍历所有省略号值
    for (int arg{ 0 }; arg < count; ++arg)
    {
        // 使用 va_arg 从省略号中取值
        // 第一个参数是所用 va_list,
        // 第二个参数为期望的数据类型
        sum += va_arg(list, int);
    }

    // 用毕须清理 va_list
    va_end(list);

    return static_cast<double>(sum) / count;
}

int main()
{
    std::cout << findAverage(5, 1, 2, 3, 4, 5) << '\n';
    std::cout << findAverage(6, 1, 2, 3, 4, 5, 6) << '\n';

    return 0;
}

程序输出:

3
3.5

如你所见,该函数可接受可变数量的实参。接下来我们剖析此示例的各个组成部分。

首先,必须引入头文件 <cstdarg>。该头文件定义了宏 va_listva_argva_startva_end,我们需借助它们方能访问省略号中的实参。

随后,我们声明使用省略号的函数。请记住,实参表须包含一个或多个固定形参。本例中,我们传入一个整数以指明待平均的数字个数;省略号始终位于末尾。

注意:省略号形参无名!我们通过名为 va_list 的特殊类型来访问其值。可将 va_list 概念化为指向“省略号数组”的指针。首先声明一个 va_list,本例中简称为 list

接下来需令 list 指向省略号实参。这通过调用 va_start() 完成;其接受两个实参:待初始化的 va_list,以及函数中最后一个非省略号形参的名称。va_start() 调用后,va_list 即指向省略号中的第一个实参。

欲获取 va_list 当前所指实参的值,我们使用 va_arg();其同样接受两个实参:所用的 va_list,以及待访问实参的类型。注意:va_arg() 还会将 va_list 移至下一实参!

最后,使用完毕后需调用 va_end(),并以 va_list 为实参进行清理。

请注意,任何时候均可再次调用 va_start(),以将 va_list 重置并指向省略号中的首个实参。

为何省略号危险:类型检查被禁用

省略号为程序员提供了极大灵活性,可编写接受可变实参的函数;然而,这种灵活性伴随若干弊端。

对于常规形参,编译器通过类型检查确保实参类型与形参类型匹配(或可隐式转换)。这能防止将整数传予期待字符串的函数,或反之。然而,请注意,省略号形参并无类型声明。使用省略号时,编译器对省略号实参完全禁用类型检查。这意味着可向省略号传递任意类型实参!但代价是,若调用者传入与函数预期不符的实参,编译器不会发出警告。使用省略号时,完全依赖调用者确保所传实参类型正确,这显然极易出错(尤其当调用者并非函数作者时)。

且看一个颇为隐蔽的错误示例:

std::cout << findAverage(6, 1.0, 2, 3, 4, 5, 6) << '\n';

乍看无害,但请注意第二个实参(即第一个省略号实参)为 double 而非 int。此代码仍可编译,却产生令人意外的结果:

1.78782e+008

一个极大的数值。为何如此?

正如先前所述,计算机以比特序列存储所有数据,变量类型指示计算机如何将该序列解释为有意义之值。然而,我们已知省略号会丢弃变量类型!因此,欲从省略号中还原有意义之值,必须手动告知 va_arg() 下一实参的期望类型——这正是 va_arg() 的第二个形参之作用。若实际类型与期望类型不符,通常将产生灾难性后果。

上述 findAverage 程序中,我们告知 va_arg() 期望类型为 int。于是每次调用 va_arg() 皆将下一比特序列按 int 解读。

本例问题在于,作为首个省略号实参传入的 double 占 8 字节,而 va_arg(list, int) 每次仅返回 4 字节。因此,首次调用 va_arg 仅读取该 double 的前 4 字节(产生垃圾值),第二次调用读取后 4 字节(再得垃圾值),最终结果自然谬以千里。

由于类型检查被禁用,即便我们做出荒谬之举,编译器亦不会报错:

int value{ 7 };
std::cout << findAverage(6, 1.0, 2, "Hello, world!", 'G', &value, &findAverage) << '\n';

信不信由你,这段代码同样可顺利编译,并在作者机器上输出:

1.79766e+008

此结果正是“Garbage in, garbage out”之写照——该短语在计算机科学中广为流传,用以提醒“计算机不像人类,会不加质疑地处理最荒谬的输入数据,并产出荒谬的输出”(维基百科)。

综上,省略号实参的类型检查被禁用,我们必须信任调用者传入正确类型的实参。若其未做到,编译器不会报错——程序只会产出垃圾(或崩溃)。

为何省略号危险:无法知晓实参个数

省略号不仅丢弃实参类型,亦丢弃实参个数。这意味着我们必须自行设法追踪传入省略号的实参数量。通常有三种做法。

方法一:传入长度实参

方法一是令某个固定形参表示可选实参的个数。上文的 findAverage() 即采用此法。

然而,即使如此亦可能出错。例如:

std::cout << findAverage(6, 1, 2, 3, 4, 5) << '\n';

作者在写作时运行此句,得到结果:

699773

何故?我们告知 findAverage() 将提供 6 个额外值,却只给了 5 个。于是 va_arg() 返回的前 5 个值为我们所传,第 6 个值则是栈中某处垃圾,结果自然荒谬。

更隐蔽之例:

std::cout << findAverage(6, 1, 2, 3, 4, 5, 6, 7) << '\n';

输出 3.5,看似正确,却因我们声明仅提供 6 个值(实际提供 7 个),导致最后一个数字被忽略。此类错误难以察觉。

方法二:使用哨兵值

方法二为使用哨兵值(sentinel)。哨兵乃一特殊值,用于在遍历中标识结束。例如,C 风格字符串以空字符为哨兵。于省略号中,哨兵通常作为最后一个实参传入。以下示范将 findAverage() 改写为使用 -1 作为哨兵值:

#include <iostream>
#include <cstdarg>

double findAverage(int first, ...)
{
    int sum{ first };

    std::va_list list;
    va_start(list, first);

    int count{ 1 };
    while (true)
    {
        int arg{ va_arg(list, int) };
        if (arg == -1)
            break;

        sum += arg;
        ++count;
    }

    va_end(list);

    return static_cast<double>(sum) / count;
}

int main()
{
    std::cout << findAverage(1, 2, 3, 4, 5, -1) << '\n';
    std::cout << findAverage(1, 2, 3, 4, 5, 6, -1) << '\n';

    return 0;
}

注意,我们不再显式传入长度,而以哨兵值作为最后一个实参。

此方法存在若干难点:

  1. C++ 要求至少传入一个固定实参。上例中,首个待平均的数即为此固定实参,故需对其特殊处理(如将 sum 初始化为 first 而非 0)。
  2. 用户必须记得在末尾传入哨兵值;若遗忘或传错,函数将无限循环直至遇到匹配的垃圾值或崩溃。
  3. 我们选择 -1 作为哨兵,仅当待平均的数均非负时方才可行;若需包含负数,则必须另觅哨兵。哨兵值仅在存在“合法集外值”时方可奏效。

方法三:使用解码字符串

方法三为传入“解码字符串”,用以指示程序如何解读实参。

#include <iostream>
#include <string_view>
#include <cstdarg>

double findAverage(std::string_view decoder, ...)
{
    double sum{ 0 };
    std::va_list list;
    va_start(list, decoder);

    for (auto codetype : decoder)
    {
        switch (codetype)
        {
        case 'i':
            sum += va_arg(list, int);
            break;
        case 'd':
            sum += va_arg(list, double);
            break;
        }
    }

    va_end(list);
    return sum / std::size(decoder);
}

int main()
{
    std::cout << findAverage("iiiii", 1, 2, 3, 4, 5) << '\n';
    std::cout << findAverage("iiiiii", 1, 2, 3, 4, 5, 6) << '\n';
    std::cout << findAverage("iiddi", 1, 2, 3.5, 4.5, 5) << '\n';

    return 0;
}

本例中,我们传入一个字符串,既编码可选实参的个数,亦编码其类型。其优点在于可处理多种类型实参。然而,此法亦存弊端:解码字符串颇为晦涩,若可选实参的个数或类型与字符串不符,则可能酿成大错。

熟悉 C 的读者当知,printf 即采用此策略!

安全使用省略号的建议

首要原则:如有可能,请勿使用省略号!通常存在其他可行方案,即便需额外付出少许工作量。例如,于 findAverage() 中,我们可改为传入动态大小的 int 数组。如此既保留可变实参之能力,又可享受强类型检查,防止调用者做出荒谬之举。

其次,若必须使用省略号,请确保所有传入的实参类型一致(如全为 int 或全为 double,勿混用)。混用类型会显著增加因类型不符而导致 va_arg() 产出垃圾值的风险。

第三,使用计数形参或解码字符串形参,通常较使用哨兵值更为安全。此举强制用户提供一个合理的计数值或解码串,即便产生垃圾值,也可确保省略号循环在有限次迭代后终止。

进阶阅读

为改进类似省略号之功能,C++11 引入了形参包(parameter packs)与可变参模板(variadic templates),其功能与省略号相似,却具备强类型检查。然而,初期的可用性问题阻碍了该特性的普及。

C++17 引入折叠表达式(fold expressions),大大提升了形参包的可用性,现已成为可行方案。

我们拟于未来网站更新中推出相关教程。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"