20-5 省略号(以及为何要避免使用它们)

在我们迄今所见的函数中,函数接收的参数数量必须预先确定(即使它们具有默认值)。然而,在某些情况下,能够向函数传递可变数量的参数会非常有用。C++提供了一种名为省略号(即“…”)的特殊指定符,正是用于实现这一功能。

由于省略号使用频率极低且存在潜在风险,我们建议避免使用,因此本节内容可视为可选阅读。

使用省略号的函数采用以下形式:

return_type function_name(argument_list, ...)

参数列表包含一个或多个常规函数参数。请注意,使用省略号的函数必须至少包含一个非省略号参数。传递给函数的任何参数都必须首先匹配参数列表中的参数。

省略号(以三个连续的句点表示)必须始终作为函数的最后一个参数。省略号用于捕获所有额外参数(若存在)。虽然这种比喻不够严谨,但概念上可将省略号视为一个数组,用于存储 argument_list 之外的所有额外参数。


省略号示例

学习省略号的最佳方式是通过实例。因此,让我们编写一个使用省略号的简单程序。假设我们要编写一个函数来计算一组整数的平均值。实现方式如下:

#include <iostream>
#include <cstdarg> // needed to use ellipsis

// The ellipsis must be the last parameter
// count is how many additional arguments we're passing
double findAverage(int count, ...)
{
    int sum{ 0 };

    // We access the ellipsis through a va_list, so let's declare one
    std::va_list list;

    // We initialize the va_list using va_start.  The first argument is
    // the list to initialize.  The second argument is the last non-ellipsis
    // parameter.
    va_start(list, count);

    // Loop through all the ellipsis values
    for (int arg{ 0 }; arg < count; ++arg)
    {
         // We use va_arg to get values out of our ellipsis
         // The first argument is the va_list we're using
         // The second argument is the type of the value
         sum += va_arg(list, int);
    }

    // Cleanup the va_list when we're done.
    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;
}

此代码输出:

image

如您所见,此函数接受可变数量的参数!现在,让我们来看看构成这个示例的各个部分。

首先,我们需要包含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';

虽然乍看之下这似乎无伤大雅,但请注意第二个参数(第一个省略号参数)是双精度类型而非整数类型。这段代码编译通过后,会产生一个令人意外的结果:

image

这是一个非常大的数字。这是怎么发生的?

正如你在之前的课程中所学到的,计算机将所有数据存储为一串位。变量的类型告诉计算机如何将这串位转换为有意义的值。然而,你刚刚了解到省略号会丢弃变量的类型!因此,要从省略号处获取有效值的唯一方法,就是手动告知 va_arg() 下一个参数的预期类型。这正是 va_arg() 的第二个参数的作用。若实际参数类型与预期类型不匹配,通常会引发严重问题。

在上述 findAverage 程序中,我们告知 va_arg() 所有变量预期类型均为 int。因此每次调用 va_arg() 都会返回下一组被转换为整数的位序列。

问题在于:作为第一个省略号参数传递的 double 占用 8 字节,而 va_arg(list, int) 每次调用仅返回 4 字节数据。因此:

  • 首次调用 va_arg() 仅读取双精度浮点数的头4字节(产生垃圾结果)

  • 第二次调用 va_arg() 读取该浮点数的后4字节(产生另一段垃圾结果)

最终导致整体结果成为无效数据。

由于类型检查被禁用,即使编写完全荒谬的代码(如下例)编译器也不会报错:

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

信不信由你,这段代码实际上能顺利编译,并在作者的机器上产生以下结果:

image

这个结果完美诠释了“垃圾进,垃圾出”这一计算机科学领域的流行说法,其核心在于“强调计算机与人类不同,会不加质疑地处理最荒谬的输入数据并产生荒谬的输出结果”(维基百科)。

简而言之,参数的类型检查被暂停,我们必须信任调用方传递正确类型的参数。若调用方未按要求传递,编译器不会发出警告——程序只会输出垃圾数据(或直接崩溃)。


省略号为何危险:省略号无法知晓传递了多少参数

省略号不仅会丢弃参数类型,还会丢弃省略号内的参数数量。这意味着我们必须自行设计方案来追踪传递给省略号的参数数量。通常有以下三种实现方式:

方法一:传递长度参数

方法一是在固定参数中设置一个代表可选参数数量的参数。这正是我们在上述findAverage()示例中采用的解决方案。

然而即便在此处,我们仍会遇到问题。例如考虑以下调用:

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

在作者撰写本文时,其设备上运行该命令产生了以下结果:

image

发生了什么?我们告诉findAverage()函数将提供6个额外参数,但实际只传入了5个。因此,va_arg()返回的前五个值是我们传入的参数,而第六个返回值则是栈中某个位置的垃圾值。最终导致计算结果成为垃圾数据。

更隐蔽的情况:

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

这会得到答案3.5,乍看似乎正确,但遗漏了平均值中的最后一个数值——因为我们只告知程序将提供6个额外数值(实际却提供了7个)。此类错误往往难以察觉。


方法二:使用哨兵值

方法二采用哨兵值机制。哨兵值是一种特殊标记,当循环遇到该值时即终止。例如字符串使用空终止符作为哨兵值标记字符串结尾;在省略号处理中,哨兵值通常作为最后一个参数传递。以下是将 findAverage() 重写为使用哨兵值 -1 的示例:

#include <iostream>
#include <cstdarg> // needed to use ellipsis

// The ellipsis must be the last parameter
double findAverage(int first, ...)
{
	// We have to deal with the first number specially
	int sum{ first };

	// We access the ellipsis through a va_list, so let's declare one
	std::va_list list;

	// We initialize the va_list using va_start.  The first argument is
	// the list to initialize.  The second argument is the last non-ellipsis
	// parameter.
	va_start(list, first);

	int count{ 1 };
	// Loop indefinitely
	while (true)
	{
		// We use va_arg to get values out of our ellipsis
		// The first argument is the va_list we're using
		// The second argument is the type of the value
		int arg{ va_arg(list, int) };

		// If this parameter is our sentinel value, stop looping
		if (arg == -1)
			break;

		sum += arg;
		++count;
	}

	// Cleanup the va_list when we're done.
	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;
}

image

请注意,我们不再需要将显式长度作为第一个参数传递。取而代之的是,我们将哨兵值作为最后一个参数传递。

然而,这里存在两个挑战。首先,C++要求至少传递一个固定参数。在前例中,该参数即为计数变量。本例中,第一个值实为待求平均值的一部分。因此我们不再将其视为省略号参数,而是显式声明为常规参数。函数内部需对其进行特殊处理(本例中将求和变量初始值设为first而非0)。

其次,这要求用户必须将哨兵值作为最后一个参数传递。若用户忘记传递哨兵值(或传递错误值),函数将无限循环直至遇到与哨兵值匹配的垃圾数据(或导致崩溃)。

最后需注意,我们选择了-1作为哨兵值。若仅需计算正数平均值则无妨,但若需包含负数呢?哨兵值仅在存在超出问题有效值域的数值时才有效。


方法三:使用解码字符串

方法三涉及传递一个“解码字符串”,该字符串告知程序如何解释参数。

#include <iostream>
#include <string_view>
#include <cstdarg> // needed to use ellipsis

// The ellipsis must be the last parameter
double findAverage(std::string_view decoder, ...)
{
	double sum{ 0 };

	// We access the ellipsis through a va_list, so let's declare one
	std::va_list list;

	// We initialize the va_list using va_start.  The first argument is
	// the list to initialize.  The second argument is the last non-ellipsis
	// parameter.
	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;
		}
	}

	// Cleanup the va_list when we're done.
	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;
}

image

在此示例中,我们传递的字符串同时编码了可选变量的数量及其类型。其妙处在于能处理不同类型的参数。但该方法也存在缺点:解码字符串可能较为晦涩,且若可选参数的数量或类型与解码字符串不完全匹配,则可能引发错误。

对于来自C语言背景的开发者,这正是printf函数的实现原理!


更安全使用省略号的建议

首先,尽可能避免使用省略号!通常情况下,即使需要稍多些工作量,也存在其他合理的解决方案。例如在findAverage()程序中,我们可以改为传入动态大小的整型数组。此举既能提供强类型检查(确保调用方不会执行荒谬操作),又能保留传递可变数量整数进行求均值的能力。

其次,若必须使用省略号参数,建议所有传递值保持统一类型(例如全部为整型或双精度型,避免混用)。混用不同类型会大幅增加调用方误传错误类型数据的风险,导致 va_arg() 返回垃圾结果。

第三,使用计数参数或解码字符串参数通常比使用哨兵值更安全。这迫使用户为计数/解码参数选择合适值,确保即使产生垃圾值,省略号循环也能在合理迭代次数后终止。

进阶读者须知

为改进省略号式功能,C++11引入了参数包与可变参数模板,其功能类似省略号但具备强类型检查。然而显著的易用性挑战阻碍了该特性普及。

C++17新增的折叠表达式大幅提升了参数包的实用性,使其成为可行的解决方案。

我们计划在未来网站更新中推出相关主题教程。

posted @ 2026-01-20 16:23  游翔  阅读(3)  评论(0)    收藏  举报