可变形参是指调用函数时参数的数量和类型可能发生变化。有三种方式定义函数的可变形参,分别是initializer_list
、可变参数模板和省略符。
可变形参的原理:C/C++函数的参数是存放在栈区的,并且参数的入栈是从参数的右侧开始的,即最后一个参数先入栈,而第一个参数最后才入栈。所以,根据栈先入后出的性质,函数总是能够先找到第一个参数,然后依次找到后面的参数。
1 initializer_list
如果函数的实参数量未知但是全部实参的类型相同,可以使用initializer_list
类型的形参。
initializer_list
是标准库类型,定义在<initializer_list>
头文件中,用于表示某种特定类型的值的数组。
支持的操作:
操作 | 含义 |
---|---|
initializer_list lst | 默认初始化,T类型元素的空列表 |
initializer_list lst{a,b,c,…} | lst的元素和初始值一样多,lst的元素是对应初始值的副本,列表中的元素是const |
lst2(lst) 或 lst2 = lst | 拷贝或赋值一个initializer_list对象,不会拷贝列表中的元素,拷贝后,原始列表和副本共享元素 |
lst.size() | 返回列表中的元素数量 |
lst.begin() | 返回lst中首元素的地址 |
lst.end() | 返回lst中尾元素下一位置的指针 |
initializer_list
也是一种模板类型,定义initializer_list
对象时,必须指明所包含的元素的类型,如:
initializer_list<std::string> ls;
initializer_list<int> li;
initializer_list
中包含的是常量,不能对其包含的元素进行修改。如:
可以通过initializer_list
的begin
和end
成员对其进行遍历。如:
void error_msg(std::initializer_list<std::string> msg)
{
for (auto iter = msg.begin(); iter != msg.end(); ++iter)
{
std::cout << *iter << std::endl;
}
}
调用方式是:
error_msg({ "this","is a","sentence" });
不要省略调用时的{}
,否则传入的就是三个字符串,而不是一个std::initializer_list<std::string>
对象。
使用initializer_list
形参时,还可以同时使用其他类型的形参,前后顺序没有规定,如:
void error_msg(std::initializer_list<std::string> msg,int n)
{
for (auto iter = msg.begin(); iter != msg.end(); ++iter)
{
std::cout << *iter << std::endl;
}
std::cout << n << std::endl;
}
//调用方式:
error_msg({ "this","is a","sentence" },3);
2 可变参数模板
2.1 定义
一个可变参数模板就是一个接受可变数目参数的模板函数或者模板类。其接受的参数类型和数目都可变。
可变数目的参数被称为参数包,存在两种参数包:模板参数包,表示零个或多个模板参数;函数参数包,表示零个或多个函数参数。
用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中,class...
或typename...
指出接下来的参数表示零个或多个类型的列表。一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。如:
//Args是一个模板参数包,表示零个或多个额外的类型参数
//rest是一个函数参数包,表示零个或多个函数参数
template <typename T,typename... Args>
void foo(const T &t,const Args& .. rest);
编译器从函数的实参推断模板参数类型,对于一个可变参数模板,编译器还会推断包中参数的数目。
如对于下面的调用:
foo(42,"hi",2.0,true);//编译器推断包中有三个参数,分别是const char*、double、bool类型
foo("yes",42,"hi");//编译器推断包中有两个参数,分别是int、const char*类型
2.2 sizeof…运算符
sizeof...
运算符用于计算包中的参数数量。返回的是一个常量表达式,且不会对其实参求值。
template <typename... Args>
void g(Args... args)
{
std::cout << sizeof...(Args) << std::endl;//计算类型参数的数目
std::cout << sizeof...(args) << std::endl;//计算函数参数的数目
}
2.3 可变参数函数的调用
可变参数函数是递归调用的,第一步处理包中的第一个实参,然后用剩余实参调用自身。为了终止递归,还需实现一个处理最后一个实参的函数。如按照下列方式定义print
函数:
template <typename T>
void print(std::ostream& os,const T& val)
{
os << val << std::endl;//打印函数包中最后一个实参
}
template <typename T,typename... Args>
void print(std::ostream& os,const T& val,const Args&... args)
{
os << val << " ,";//打印当前第一个实参
print(os,args...);//递归调用,打印其他实参
}
如按照print(std::cout,42,"hi",true,3.0);
进行函数调用,那么编译器会实例化出一个void print(std::ostream&,const int&,const char[3]&,const bool&,const double&)
函数。首先调用下面的print
函数,第一个参数推断为int
,参数包中的参数类型为const char*,const bool,const double
,在函数中首先打印出第一个实参,然后调用自身,调用时参数包中的内容为"hi",true,3.0
;然后递归调用自身,直到函数包中只有一个参数时,即需要执行print(os,3.0)
时,此时既可以调用自身,也可以调用上面的print
函数,调用自身时,认为参数包args
是空包。但编译器更倾向于使用上面版本的print
函数,因为非可变参数版本比可变参数模板更加特例化。
2.4 可变参数函数模板放在哪里?
本节要讨论的核心意思是:可变参数函数模板实现时,应该把声明和实现都放在头文件中?还是应该把声明放在头文件中,实现放在源文件中 ?
答案是:定义和实现都放在头文件中肯定可行;如果声明放在头文件中,实现放在源文件中,据需要在源文件中针对需要进行特例化的函数声明。
如:
在Utils.h
中定义:
//Utils.h
template <typename T>
void print(std::ostream& os,const T& val)
{
os << val << std::endl;
}
template <typename T,typename... Args>
void print(std::ostream& os, const T& val,const Args&... rest)
{
os << val << ", ";
print(os,rest...);
}
在main.cpp
中按照如下方式调用,代码是可以正常使用的。
#iinclude <iostream>
#include "Utils.h"
int main()
{
print(std::cout,1,2.0,"hi",true);
return 0;
}
如果将可变参数函数模板的声明和实现分开放到头文件和源文件中,还按照原有方式进行调用,则会编译出错:
//Utils.h
#include <iostream>
template <typename T>
void print(std::ostream& os,const T& val);
template <typename T,typename... Args>
void print(std::ostream& os, const T& val,const Args&... rest);
//Utils.cpp
#include "Utils.h"
template <typename T>
void print(std::ostream& os,const T& val)
{
os << val << std::endl;
}
template <typename T,typename... Args>
void print(std::ostream& os,const T& val,const Args&... rest)
{
os << val << ", ";
print(os,rest...);
}
具体的报错信息为:
对‘void print<int, double, bool, double>(std::ostream&, int const&, double const, bool const, double const)’未定义的引用
对函数未定义的引用,表示编译器找不到函数的实现,为什么会出现这个错误?
原因是,C++规定,当一个模板不被用到的时候是不会进行实例化的,在Utils.cpp
文件中,并没有用到任何具体的print
函数,因此并没有进行实例化实现,也就造成了链接错误。解决办法,如https://blog.csdn.net/bendanban/article/details/51321899所指出的,在Utils.cpp
中针对我们需要的版本进行模板函数的实例化,即在Utils.cpp
上面添加一行代码:
#include "Utils.h"
//非初始化调用的模板函数无需实例化
/*template void print(std::ostream&,const double&);
template void print(std::ostream&,const bool&,const double&);
template void print(std::ostream&,const double&,const bool&,const double&);*/
//新添加的模板函数实例化代码
template void print(std::ostream&,const int&,const double&,const bool&,const double&);
template <typename T>
void print(std::ostream& os,const T& val)
{
os << val << std::endl;
}
template <typename T,typename... Args>
void print(std::ostream& os,const T& val,const Args&... rest)
{
os << val << ", ";
print(os,rest...);
}
总结下:
可变参数函数模板声明和实现可以同时放在头文件中;
也可以将函数声明和实现分别放入头文件和源文件,但这种方式需要在源文件中对模板函数的初始调用形式进行实例化。
2.5 包扩展
包扩展就是指将操作或函数应用于包中的每个元素。
通过在模式右边放一个省略号可以触发扩展操作。如在const Args&...
将const Arg&
应用到Args
中的每个元素。
假设debug_rep
为一个函数,具有单个形参。const Args&... rest
定义了一个模板参数包,对rest
中的每个参数都应用debug_rep
函数的正确调用方式为:debug_rep(rest)...
,注意...
应该写在括号外,表示debug_rep(rest1),debug_rep(rest2),...,debug_rep(restn)
,rest1、rest2、...、restn
表示包中的各个参数。如果将...
写在括号内,则包扩展之后的调用形式为debug_rep(rest1,rest2,...,restn)
是无法正常调用的。
有一个注意的点:对函数应用包扩展时,省略号...
不能直接后面跟分号。如下面代码所示:
#include <iostream>
template <typename T>
void print(std::ostream& os,const T& val)
{
os << val << std::endl;
}
template <typename T,typename... Args>
void print(std::ostream& os,const T& val,const Args&... rest)
{
os << val << ", ";
print(os,rest...);
}
template <typename... Args>
void top_print(const Args&... args)
{
print(std::cout,args)...;
}
int main()
{
int a = 1;
top_print(a,2.0,true,1e-4);
return 0;
}
代码编译会出错,具体为对print(std::cout,args)...;
行,报下列错误信息:
error: expected ‘;’ before ‘...’ token
error: parameter packs not expanded with ‘...’
修改的方法有两种,如下所示:
//方法1
template <typename... Args>
void top_print(const Args&... args)
{
std::initializer_list<int> {(print(std::cout,args),0)...};
}
//方法二
template <typename T,typename... Args>
T equal(const T& val,const Args&... rest)
{
return val;
}
template <typename... Args>
void top_print(const Args&... args)
{
print(std::cout,equal(args)...);
}
方法一,将函数包扩展放入{}
中,初始化一个initializer_list
对象,但注意创建的是一个未命名的临时initializer_list
对象,也就是说initializer_list
对象不关键,关键的是借助其初始化{}
可以进行函数包扩展。在该方法中,还有一个要注意的点,这里使用的print
函数是:
template <typename T>
void print(std::ostream& os,const T& val)
因为这个函数无返回值,所以要使用std::initializer_list<int> {(print(std::cout,args),0)...};
,即每次调用print
函数后都向initializer_list
中新加一个0;当然如果具体使用的函数是有返回值的且类型为int
,那么就可以更加简化调用方式,写成std::initializer_list<int> {print(std::cout,args)...};
形式。
总之原则就是创建一个临时的initializer_list
对象,循环调用要执行的函数,函数有返回值,initializer_list
对象中放返回值;函数无返回值,创建一个临时的返回值。注意返回值/自定义初始化的值要和initializer_list
对象中的元素类型匹配。
方法二,是加了一个无意义的函数equal
,返回各个参数值。目的也是为了把top_print
中对于print(std::cout,args)...
的调用放入括号中。
两种方式都能正常的执行,但从代码简洁角度出发,推荐方法一。
至于为什么省略号后不能跟分号,必须将函数包扩展放入括号或分号中,个人还没有找到原因,欢迎大牛指导。
具体原因为:Essentially, expanding a parameter pack E... produces a list E1, E2, [...], EN, one E for each element in the pack. This syntactic construct is only valid in places where lists are grammatically correct, such as in function calls, initializer lists etc.
来自于https://stackoverflow.com/questions/33868486/parameter-packs-not-expanded-with。
3 省略符形参
省略符...
形参是为了便于C++程序访问某些特殊的C代码而设置的,使用了名为varargs
的C标准库功能。
省略符形参只出现在形参列表中的最后一个位置,只有两种形式:
void foo(param_list,...);
void foo(...);
第一种形式中,param_list
形参对应的实参会进行类型检查,省略符所对应的形参无须类型检查。param_list
后跟的逗号是可选的,可要可不要。
C语言的标准头文件stdarg.h
提供了一套对可变参函数的实现机制,所以实现可变参函数需要包含该头文件,对应到C++下就是cstdarg
头文件。
具体实现上,是依赖一个va_list
的数据类型和va_start
、va_arg
、va_end
三个宏进行实现的。
va_list
实际类型为char*
,用于存储所有的参数;va_start
用于初始化定义的va_list
对象,需要两个参数,第一个为刚才的va_list
对象,第二个参数为可变形参的总个数;va_arg
用于返回可变参数,需要两个参数,第一个为刚才的va_list
对象,第二个为要转换为的参数类型;va_end
用于释放va_list
对象。
具体实现可变整型数的加法:
#include <cstdarg>
int sum(int n,...)
{
va_list arg_ptr;
int nRes = 0;
va_start(arg_ptr,n);
for(int i=0;i<n;++i)
{
nRes += va_arg(arg_ptr,int);
}
va_end(arg_ptr);
return nRes;
}
int main()
{
int a = sum(3,15,16,45,100);
std::cout << a;
return 0;
}
结果为76而不是176,是因为sum
第一个实参为3,只进行前三个数字的求和。
如果按照下面的代码进行调用:
int main()
{
int a = sum(3,15,16.0,45,100);
std::cout << a;
return 0;
}
结果为160,16.0
无法转换为整数,所以不参与求和运算。
如果按照下面的代码进行调用:
int main()
{
int a = sum(4,15,16.0,45,100);
std::cout << a;
return 0;
}
结果为-136492192
,这是因为现有实参中只有三个可用,现在要读取四个整型数,会读取一个未定义的值,所以返回的结果也是未定义的。