章节目录

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

本节阅读量:

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

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

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

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的第一个调用将仅读取双精度浮点运算的前4个字节(产生垃圾结果),而对va_arg的第二个调用将读取双精度运算的第2个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 ou),这是一个流行的计算机科学短语,“主要用于提请注意这样一个事实,即计算机与人类不同,无意义的输入数据会产生无意义的输出”。

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


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

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

方法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(匿名函数)简介

下一节