省略号(以及为何应避免使用)
迄今为止我们接触的所有函数,其形参数量均须在编写时即已确定(即便这些形参拥有缺省值亦然)。然而,在某些场景下,若能向函数传递可变数量的实参将颇具价值。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_list
、va_arg
、va_start
与 va_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;
}
注意,我们不再显式传入长度,而以哨兵值作为最后一个实参。
此方法存在若干难点:
- C++ 要求至少传入一个固定实参。上例中,首个待平均的数即为此固定实参,故需对其特殊处理(如将
sum
初始化为first
而非 0)。 - 用户必须记得在末尾传入哨兵值;若遗忘或传错,函数将无限循环直至遇到匹配的垃圾值或崩溃。
- 我们选择
-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),大大提升了形参包的可用性,现已成为可行方案。
我们拟于未来网站更新中推出相关教程。