章节目录

省略号与可变参数(以及为什么要避免它们)

本节阅读量:

在迄今为止我们看到的所有函数中,函数将采用的参数数量都必须事先知道(即使它们具有默认值)。然而,在某些情况下,将可变数量的参数传递给函数是有用的。C++提供了一个特殊的说明符,称为省略号(也就是“…”),允许我们这样做。

由于省略号很少使用、具有潜在危险,并且我们建议避免使用它们,因此本节可以视为可选阅读。

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

1
返回类型 函数名(正常参数列表, ...)

请注意,使用省略号的函数必须至少具有一个非省略号的参数。传递给函数的任何参数都必须首先与正常参数列表匹配。

省略号(写作连续三个句点)必须始终是函数中的最后一个参数。省略号会捕获其他所有参数(如果有)。尽管并不十分准确,但从概念上讲,可以将省略号视为一个数组,它保存正常参数列表之外的所有参数。


省略号示例

学习省略号的最好方法是通过例子。让我们编写一个使用省略号的简单程序。假设我们想编写一个函数来计算一组整数的平均值。可以这样做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#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_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;
}

此代码打印:

1
2
3
3.5

如您所见,该函数的参数个数现在可以变化了!接下来,让我们看一下构成这个示例的各个部分。

首先,我们必须包含cstdarg头文件。该头文件定义了va_list、va_arg、va_start和va_end,这些是我们访问额外参数所需的宏。

然后声明使用省略号的函数。请记住,参数列表必须有一个或多个固定参数。在这种情况下,传入一个整数,它告诉我们要平均多少个数字。省略号总是排在最后。

请注意,省略号参数没有名称!相反,通过一种称为va_list的特殊类型来访问省略号中的值。在概念上,将va_list视为指向省略号数组的指针。首先,声明一个va_list,为了简单起见,将其称为“list”。

下一步需要让list指向省略号参数。通过调用va_start()可以实现这一点。va_start()接受两个参数:va_list本身,以及函数中最后一个非省略号参数的名称。调用va_start()后,va_list将指向省略号中的第一个参数。

为了获得va_list当前指向的参数的值,我们使用va_arg()。va_arg()接受两个参数:va_list本身和我们试图访问的参数的类型。请注意,va_arg()还将va_list移动到省略号中的下一个参数!

最后,为了在完成后进行清理,我们调用va_end(),并将va_list作为参数。

请注意,每当我们想重置va_list以再次指向省略号中的第一个参数时,可以再次调用va_start()。


省略为何危险:类型检查缺失

省略号为程序员提供了很大的灵活性,可以实现接受可变数量参数的函数。然而,这种灵活性也有一些缺点。

对于常规函数参数,编译器会使用类型检查来确保传入数据的类型与函数参数类型匹配(或者可以隐式转换为匹配类型)。这有助于确保当函数需要字符串时,不会将整数传递给它,反之亦然。然而,请注意,省略号参数没有类型声明。使用省略号时,编译器完全不会对省略号参数进行类型检查。这意味着可以将任何类型的参数传给省略号!缺点是,如果使用没有意义的数据调用函数,编译器将无法再警告您。使用省略号时,调用方必须完全负责确保传入的数据是函数可以处理的。显然,这给错误留下了相当大的空间(特别是当调用方不是函数作者时)。

让我们来看一个非常微妙的错误示例:

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

尽管乍一看这似乎无害,但请注意,第二个参数(第一个省略号参数)是双精度数,而不是整数。这段代码可以通过编译,并产生一个令人惊讶的结果:

1
1.78782e+008

这是一个非常大的数字。为什么会这样?

正如您在前面的课程中所学到的,计算机将所有数据存储为一个比特序列。变量的类型告诉计算机如何将该比特序列转换为有意义的值。然而,省略号丢弃了变量的类型!因此,从省略号中获取有意义的值的唯一方法是手动告诉va_arg()下一个参数的预期类型。如果实际的参数类型与预期的参数类型不匹配,通常会发生错误的事情。

在上面的findAverage程序中,我们告诉va_arg(),所有变量都应该具有int类型。因此,每次调用va_arg()都会把下一个位序列当作整数返回。

在这种情况下,问题在于作为第一个省略号参数传入的double是8个字节,而va_arg(list, int)每次调用只返回4个字节的数据。因此,对va_arg的第一次调用只会读取这个double的前4个字节(产生垃圾结果),而对va_arg的第二次调用会读取这个double的后4个字节(产生另一个垃圾结果)。因此,最终结果就是垃圾。

由于类型检查被绕过,因此即使我们做了完全荒谬的事情,编译器也不会发出警告,例如:

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

信不信由你,这实际上编译得很好,并在作者的机器上产生以下结果:

1
1.79766e+008

这个结果对应了短语“垃圾输入,垃圾输出”(Garbage in, garbage out),这是一个流行的计算机科学短语,主要用于提醒我们:计算机与人类不同,无意义的输入数据会产生无意义的输出。

因此,由于缺少类型检查,我们必须信任调用方会传入正确类型的参数。如果传入了错误数据,编译器不会发出警告——程序只会产生垃圾结果(或者可能崩溃)。


省略为何危险:省略号不知道传递了多少参数

省略号不仅丢弃了参数类型,还丢弃了省略号中参数的数量。这意味着我们必须设计自己的方案来跟踪传入省略号的参数数量。通常,可以通过以下三种方法之一完成。

方法1:传递长度参数

让其中一个固定参数表示传入的可选参数数量。这是我们在上面的findAverage()示例中使用的解决方案。

然而,即使在这里,我们也遇到了麻烦。例如,考虑以下调用:

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

在作者写作时的机器上,这产生了以下结果:

1
699773

发生了什么?我们告诉findAverage()将提供6个额外的值,但实际上只给了它5个。因此,va_arg()返回的前五个值是传入的值,而第六个值是栈中某个位置的垃圾值。因此,我们得到了一个垃圾答案。

一个更阴险的案例:

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

这会产生答案3.5,乍一看可能是正确的,但它省略了平均值中的最后一个数字,因为我们只告诉它将提供6个额外的值(但实际提供了7个)。这种错误很难发现。

方法2:使用哨兵值

哨兵值是一个特殊的值,用于在循环中遇到时终止循环。例如,对于字符串,空终止符用作哨兵值来表示字符串的结束。对于省略号,哨兵通常作为最后一个参数传入。下面是将findAverage()改写为使用哨兵值-1的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <cstdarg> // 使用省略号,需要该头文件

// 省略号必须是最后一个参数
double findAverage(int first, ...)
{
	// 第一个参数必须特殊处理
	int sum{ first };

	// 需要通过 va_list访问省略号, 这里声明一个
	std::va_list list;

    // 通过 va_start 初始化va_list.
    // 第一个参数是va_list. 第二个参数是最后一个非省略号的参数
	va_start(list, first);

	int count{ 1 };
	// 无限循环
	while (true)
	{
		// 使用 va_arg 访问额外的参数
		// 第一个参数是使用的 va_list
		// 第二个参数是对应的类型
		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作为哨兵。如果我们只想求正数的平均值,那没问题,但如果我们想包含负数呢?只有当某个值不在要处理的值集合中时,它才能用作哨兵值。

方法3:使用解码字符串

可以使用一个额外的字符串,该字符串告诉程序如何解释参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <string_view>
#include <cstdarg> // 使用省略号,需要该头文件

// 省略号必须是最后一个参数
double findAverage(std::string_view decoder, ...)
{
	double sum{ 0 };

	// 需要通过 va_list访问省略号, 这里声明一个
	std::va_list list;

    // 通过 va_start 初始化va_list.
    // 第一个参数是va_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或全是double,而不是两者混合)。混合使用不同类型会大大增加调用者无意中传递错误类型数据的可能性,也会增加va_arg()产生垃圾结果的可能性。

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


20.3 命令行参数

上一节

20.5 lambda(匿名函数)简介

下一节