C++可变参数

可变参数的宏

一般在调试打印Debug信息的时候, 需要可变参数的宏. 从C99开始可以使编译器标准支持可变参数宏(variadic macros), 另外GCC也支持可变参数宏, 但是两种在细节上可能存在区别.

__VA_ARGS__ 将 “…” 传递给宏 . 如

#define debug(format, ...) fprintf(stderr, format, __VA_ARGS__)

GCC使用一种不同的语法,从而可以给可变参数一个名字,如同其它参数一样.

#define debug(format, args...) fprintf (stderr, format, args)

这和第一条的宏例子是完全一样的,但是这么写可读性更强并且更容易进行描述.

上面两个定义的宏,如果出现 debug(“A Message”)的时候,由于宏展开后有个多余的逗号,所以在GCC中将导致编译错误, 而VS则不会. 所以移植性更好的写法是
使用一个特殊的”##”操作,格式如下:

#define debug(format, ...) fprintf (stderr, format, ## __VA_ARGS__)

这里,如果可变参数被忽略或为空,”##”操作将使预处理器(preprocessor)去除掉它前面的那个逗号.

什么是可变形参函数

  在 c++ 编程中,有时我们需要编写一些在源代码编写阶段无法确定参数个数,有时甚至无法确定参数类型的函数。

  例如,一个求和函数。可以通过重载实现若干个数的和。

1
2
3
4
5
6
7
int sum(int i1, int i2);
int sum(int i1, int i2, int i3);
...//还可以重载更多类似函数

double sum(double d1, double d2);
double sum(double d1, double d2, double d3);
...//还可以重载更多类似函数

  以上代码通过重载机制来解决变参问题。但很快我们就会发现这种方法存在的问题:必须确保所有可能的实参列表都有对应的重载声明和定义,如果上述方法如果参与运算的参数个数可能从 2——20 个不等,那么我们就需要重载 19 次同一个函数。

  我们需要的是这样一类函数:它们可以在运行时取任意的实参个数并根据实参的个数自动处理不同实参的情形,或者至少可以在运行时指定任意的实参个数。

实现变参函数的三种方法

  在 C++ 中实现一个变参函数的方法有三种:第一种方法,将函数形参声明为 C++11 新标准中的 initializer_list 标准库类型;第二种方法继承自 C 语言,形参声明为省略符,函数实现时用参数列表宏访问参数;最后一种方法利用 C++ 泛型特性,声明一个可变参数模板来实现。

  1. 可变参数宏
  实现步骤如下:
    1. 函数原型中使用省略号;
    2. 函数定义中创建一个 va_list 变量;
     3. 初始化 va_list 变量;
     4. 访问参数列表;
     5. 完成清理工作;
  上述步骤的实现需要使用到四个宏:_va_list、va_start(va_list, arg)、va_arg(va_list, type)、va_end(valist) 这些宏在头文件 stdarg.h 中声明定义。因此使用时需要包含该头文件。

  以下代码使用可变参数宏实现一个函数 sum,该函数接受任意个数的整形实参,返回这些实参的和。(忽略可能存在的整形溢出)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* --sum.cpp-- 可变参数宏实现求任意个整形值得和 */
#include <stdarg.h>

int sum(int count, ...);   //原型中使用省略号

int sum(int count, ...){   //count 表示可变参数个数
va_list ap;          //声明一个va_list变量
va_start(ap, count);     //初始化,第二个参数为最后一个确定的形参

int sum = 0;
for(int i = 0; i < count; i++)
sum += va_arg(ap, int); //读取可变参数,的二个参数为可变参数的类型

va_end(ap);     //清理工作
return sum;
}

  使用这种方法需要注意一下几点:
  1. 函数原型中,省略号必须在参数列表的末尾:也就是说,在函数原型中参数列表省略号的右边不能再出现确定参数;
  2. 运行时,函数必须能够根据已有信息(既有约定,或确定实参)确定可变参数的具体个数与类型:函数定义需要知道可变参数的具体类型、个数,这些信息是在运行时确定的,那么显然应该由实参来确定。在上面的例子中 count 传递了可变参数的个数,而参数类型则是既有约定(整形);
  3. 使用完成时需要用 va_end() 做清理工作,可变参数宏可能使用了动态分配的内存,忘记执行清理操作有可能导致内存泄漏等问题;
  4. 可变参数宏只能实现顺序访问可变参数,无法后退访问,但是可以在清理操作完成后重新使用 va_start 初始化 va_list 变量,重新遍历形参表;
  5. 该方法是极不安全的,宏本身无法提供任何安全性保证,他总是按照既定代码 “自作多情” 的认为实参就应该是那么多,即使实参并不是那么多。这就要求所有安全性必须由程序员来保证。例如,在以上的示例代码中,如果调用时指定 count 为 10,但实际上只给出 9 个可变形参,那么函数还是会读取 10 个参数,显然第十次读取是多余的,多余的操作一般不会有什么好结果,当然如果实参过多,多余的实参也不会被读取而是被忽略。

  使用这种方法的一个实例是 printf() 函数。printf() 函数通过分析第一个字符串参数中的占位符个数来确定形参的个数;通过占位符的不同来确定参数类型(%d 表示 int 类型、%s 表示 char *);它也有上述提到的安全问题,如果不小心少提供了个实参,那么越界访问就会发生。

  2. initializer_list 标准库类型
  实现步骤如下:
    1. 函数原型中使用实例化 initializer_list 模板代表可变参数列表;
    2. 使用迭代器访问 initializer_list 中的参数;
     3. 传入实参写在 {} 之内。
  以上步骤中使用到 initializer_list。这是 C++11 新标准中引入的一个标准库类型,与 vector 等容器一样 initializer_list 也支持 begin() 和 end() 操作,返回指向首元素的迭代器和尾后迭代器。initializer_list 在同名头文件中声明,其实现由编译器支持。
  以下代码使用 initializer_list 实现函数 sum。(忽略可能存在的整形溢出)

1
2
3
4
5
6
7
8
9
10
11
/* --sum.cpp-- 利用initializer_list模板实现求人一个整形值得和 */
#include <initializer_list>

int sum(initializer_list<int> il); //函数原型用int实例化initializer_list作为形参

int sum(inttializer_list<int> il){
int sum = 0;
for(auto p = il.begin(); p != il.end(); p++) //使用迭代器访问参数
sum += *p;
return sum;
}

  使用这种方法需要注意一下几点:
  1. initializer_list 在 C++11 中才被引入,这意味着在编译时可能需要加上这个选项 -std=c++11 才能成功编译。上述代码中的 auto 关键字也是 C++11 的一部分;
   2. 参数必须放在一组‘{}’(大括号)内,编译器通过大括号来将这组参数转化为 initializer_list. 大括号的的一组实参与 initializer_list 形参对应;
   3. 函数原型 initializer_list 与普通形参无异。这表明形参列表中可以包含其他类型参数且位置不限,以下函数原型是正确的:

1
void func(char c, initializer_list<int> il, double d);

  4. 同一个 initializer_list 中的参数具有相同的类型。本质上来说 initializer_list 是一个编译器支持的容器类模板,同其他容器一样,容器中的元素具有相同的类型

  使用这种方法的一个实例是 C++11 中 vector 的列表初始化构造函数。

  3. 可变参数模板
  在介绍这种方法之前需要先介绍两个并不常用的概念:模板参数包和函数参数包。
  模板参数包是零个或多个类型参数的集合。模板参数列表中,class… 或 typename… 表明其后的类型参数表示一个模板参数包;
  函数参数包是零个或多个非类型参数的集合。函数形参列表中类型名加省略号表明其后的参数表示一个函数参数包;另外,类型为模板参数包的函数形参是一个函数参数包。
  以下引用参考书目 2 中的示例代码来直观展现这两个概念:

1
2
3
4
5
//args是一个模板参数包;rest是一个函数参数包
//args表示零个或多个模板类型参数
//rest表示零个或多个函数参数
template<typename T, typename... args>
void foo(const T &t, const args&... rest);

  与 sizeof() 运算符类似,sizeof…() 运算符用于参数包。sizeof…() 将返回参数包中参数个数

  利用可变参数模板实现可变参数函数的步骤如下:
  1. 编写含有模板参数包和函数参数包的模板函数;
   2. 函数定义递归调用自己,每一步递归参数包中参数减一;
   3. 编写处理边界情况(参数包含有零个参数)的模板。
  以下引用参考书目2中示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
//用来终止递归并答应最后一个元素的函数
//此函数必须在可变参数版本的print定义之前声明
template <typename T>
std::ostream &print(std::ostream &os, const T &t){
return os << t; //包中最后一个元素
}
//包中除最后一个元素之外的其他元素都会调用这个版本的pirnt
template <typename T, typename... Args>
std::ostream &print(std::ostream &os, const T &t, cosnt Args &... rest){
os << t << ","; //打印第一个实参,包中元素减一
return print(os, rest...); //递归调用,打印剩余实参
}

  使用这种方法需要注意的是:
  1. 必须处理边界情况。且如代码注释所示:应当首先定义处理边界情况的模板。
  2. 参数包在参数列表最右侧,参数包只能从左至右展开?
  3. 参数包能够实现更加复杂的模板,更多内容参考 C++ Primer(第五版) 第 16 章相关内容。

  这种实现方式的根本原理实际上与最初提到的重载是一致的。通过定义模板,让编译器根据实参类型自动生成对应的重载函数。

三种实现方法的比较

  以上提到的三种方法都可以实现变参函数。但三种方法都有其各自的有点和局限性,在选择时可以从以下几个方面考虑:
  1. 若非必要,不要使用可变参数函数。应该首先考虑函数重载等其他方法。
  2. 除非需要兼容 C 语言编译器,否则不要使用可变参数宏。应为这种方法最不安全;尤其是当参数为对象时这种方法易产生各种问题。毕竟这些宏是为 C 语言设计的,C 语言中没有对象。
  3. 如果参数类型相同且 C++11 可用,则通过声明形参为 initializer_list 往往是最简单、最有效的办法
  4. 变参模板看似最为强大。参数的类型可以不同、比可变参数宏更加安全并且可以自动推断参数类型和参数个数。但考虑到模板会为每一个不同的实例生成代码,如果函数的实例过多可能会使代码体积增大。另外,依靠递归使得功能具有局限性,并且效率也会受到影响。