可变参数函数
可变参数是在C++编程中,允许函数接受不定数量的参数。这种特性可以帮助我们处理多种情况,例如日志记录、数学计算等。
在C++中,可变参数通常通过C风格的可变参数函数实现,需要包含<cstdarg>头文件。
对可变参数的使用具有以下几个函数:
- 使用
va_list
定义一个变量,用于访问可变参数。 - 使用
va_start
初始化va_list
变量,指定最后一个具名的参数。 - 使用
va_arg
获取每个可变参数,并且需要指定其类型(类型必须准确,和输入参数匹配,否则会发生错误)。 - 使用
va_end
清理。
例1:如下函数addint,作用是累加int型数据:
#include <cstdarg>
int addint(int count, ...)
//第一个参数是int类型数据,可以输入总的参数数量
//...代表可变参数
{
int total = 0;
va_list ap; //使用va_list声明一个变量访问参数列表
va_start(ap, count);//va_start向ap指示可变参数列表的最后一个具名参数(我理解用于在参数栈中定位可变参数)
for(int i = 0; i < count; ++i)
{
int tp = va_arg(ap, int);//用va_arg获取当前可变参数,并将指针指向下一个可变参数
cout << tp <<endl;
total += tp;
}
va_end(ap); //使用完之后清理ap
return total;
}
int main(){
cout << addint(10,1,2,3,4,5,6,7,8,9,0) << endl;
return 0;
}
输出:
例2:使用可变参数实现输出log
void printlogc(const char* format, ...)
//format有2个作用,1、同addint函数,帮助定位可变参数在参数栈中的位置;2、格式化日志
//...代表可变参数
{
va_list ap;
va_start(ap, format);
vprintf(format, ap); //格式化输出
va_end(ap);
}
int main(){
printlogc("Error:%s at line &d\n", "file not found", 32);
return 0;
}
输出:
从上面例子我们可以看出,C虽然提出了可变参数的概念,但是使用起来不够灵活。我们在调用可变参数的函数之前,要针对每个参数的数据类型都进行处理,必须一一对应,且没有类型安全检查,这样在运行过程中容易产生未知的错误。
C++11引入可变参数模板可以很好解决可变参数的上述2个问题。
在介绍可变参数模板之前,先介绍下折叠表达式,使用它 可以更好的使用可变参数模板。
可变参数模板
可变参数模板是在C++11引入的特性。顾名思义,就是这个类模板或函数模板的形参个数是可变的。
1、可变参数模板语法
可变参数模板的语法和相似。例如如下
//可变参数函数模板
template<typename ...Args>
void func(Args ...args){}
//可变参数类模板
template<typename ...Args>
class MyClass
{
public:
MyClass(){}
MyClass(Args ...args){
func(args...); //参数包展开
}
};
在上面的代码中可见...具有重要作用,它是可变参数的标志,所以在任何表示参数的地方都存在...。下面详细介绍所有出现...的地方分别是什么:
typename ...Args:是类型模板形参包,这样声明表示这个模板可以接受0个或多个类型的模板实参(型别),是可变参数模板;(可以类比 template<typename T>的声明用于理解记忆)
Args ...args:叫做函数形参包,出现于函数的形参列表中,表示这个函数的形参中,有一个可以接受0个或多个参数的形参;(可以类比 func(T t)的声明用于理解记忆)
args...:是参数展开包,在这里将会参数展开为0个或多个模式的列表,这个过程也叫解包。这里的模式说的是参数展开的方式,它规定了每个参数应该怎么处理。...代表展开。
比如这里的args...的展开操作后是这样的 func(args1, args2, ... ,argsn)。又比如,我们这样传入参数 func( &args...),则它将这样展开 func(&args1, &args2, ... , &argsn),其中&args是模式,规定每个参数要取址,再展开。
形参包展开
使用可变参数模板很重要的一点是,我们要理解参数包的展开。个人理解这是这个知识点的重点和难点。
个人认为,理解包是怎么展开:要找到1、模式是什么;2、展开方式 即可。
这节,我将用在不同场景下的包展开实例,来帮大家理解包展开。
在此之前,简单列举下允许包展开的场景,大家可以简单了解下,我不会所有都举例,会挑几个比较常用的场景展开。
允许包展开的场景:
1、表达式列表;
2、初始化列表;
3、基类描述;
4、成员初始化列表;
5、函数参数列表;
6、模板参数列表;
7、lambda表达式捕获列表;
8、sizeof...运算符;
9、对其运算符;
10、属性列表
例1:函数参数列表中的包展开
template <typename T, typename U>
T baz(T t, U u){
cout << t << ": " << u << endl;
return t;
}
template<typename ...Args>
void foo(Args ...args){
((cout << args << " "),...) << endl;
}
template<typename ...Args>
class Bar
{
public:
Bar(Args ...args){
foo(baz(args, &args)...);
}
};
int main(){
Bar<int, double, float> jx(28, 5.09, 10.26);
return 0;
}
输出:
上述代码中,在Bar的构造函数中调用foo函数时,有一个包展开的结构baz(args, &args)...,即下面这句代码:
foo(baz(args, &args)...);
我们将...前面的代码取出baz(args, &args),即包展开的模式。可见,这个参数包的模式是对baz函数的调用并获取返回值,可以将args还原为某一个具体参数,比如传入的第一个实参28,我们自己想象补充一下 arg1 = 28,arg2=5.09,arg3=10.26。所以这个包展开是这样的:
baz(arg1, &arg1),baz(arg2, &arg2),baz(arg2, &arg2)
我们在输出中可见对baz函数的调用打印的结果。
为什么先调用baz函数先处理的是10.26参数,然后在foo的参数列表中先处理的是28?
这要涉及到函数在被调用时,参数的处理流程。
一般而言,函数实参在函数体执行之前会从右至左依次对每个参数进行求解,并将结果推入栈中待用。而在函数体执行时,依次从栈顶获取参数数值,所以上述代码的打印输出是那样的。
上述代码中,((cout << args << " "),...) << endl;这句涉及到折叠表达式,后面会系统介绍。
我们再看个更复杂的展开。同样是上面例1的代码,我将Bar类的构造函数中,对foo函数的调用改成这样
foo(baz(args, &args) + args...);
这个包怎么展开?
其实还是一样的。我们只要找到...,无论其前面的表达式多么复杂,我们只要认准它是模式,是对参数包中每个参数的具体处理方法,即可。
这句话中,我们得到模式:baz(args, &args) + args,即调用baz函数求得结果,再将结果和传入的参数求和。可以将args还原为某一个具体参数arg1,arg2...,所以这个包展开是这样的
baz(arg1, &arg1)+arg1,baz(arg2, &arg2)+arg2,baz(arg3, &arg3)+arg3
例2:初始化列表中的包展开
如下代码,是在一个数组的初始化列表中进行了包展开
int add_data(int a, int b){return a+b; }
int sub_data(int a, int b){return a-b; }
template<typename ...Args>
void func(Args (*...args)(int,int))
{
int ret[] = {(cout << args(1,2) << endl, 0)...};
}
int main(){
func(add_data, sub_data);
}
输出
同样的,我们找到...,然后先取得模式,即 (cout << args(1,2) << endl, 0) ,根据 ,逗号操作符的特点我们可知,不管参数输入是什么,最终的(cout << args(1,2) << endl, 0)的结果都是0,但是这里面的结果其实不重要。因为包展开需要特定的场景,我们是不可以直接在函数体中进行包展开的,如果想要输出args(1,2)的结果,可以使用这个方法,或者使用下面介绍的折叠表达式。回归本小节主题,函数模板func是可变参数的模板,形参可见是一个形参列表是(int, int)的函数指针。根据main函数的调用,我们可以得到包展开是这样的:
(cout << add_data(1,2) <<endl, 0 ),(cout << sub_data(1,2) <<endl, 0 )
例3:基类描述和成员初始化列表中的包展开
我们先把示例代码贴出来
class B1
{
public:
B1(){}
B1(const B1& O){
cout << "B1 copy called!!" << endl;
}
};
class B2
{
public:
B2(){}
B2(const B2& O){
cout << "B2 copy called!!" << endl;
}
};
template<typename ...Args>
class Derived: public Args...
{
public:
Derived(const Args& ...args): Args(args)... {}
};
int main(){
B1 b1;
B2 b2;
Derived d(b1, b2);
}
输出:
这个示例,可见,其中有2出进行了包展开:
基类描述:
class Derived: public Args...
其中 Args...是对传输数据的类型包进行了一个展开,这里注意,展开的是Args,而不是上面几个例子介绍的args,即展开的是类型或者是类,而不是对象。而且,模式是什么呢?大家注意,模式应该是public Args,而不是Args。所以这里展开的是
public B1, public B2
展开的完整语句是 class Derived: public B1, public B1,即Derived类是public继承了B1和B2的一个派生类
成员函数初始化列表:
Args(args)...
懂得了上一个基类描述,我想这个也很容易理解了。首先还是找到模式 Args(args)
上面已经说过Args和args的区别,Args是传入的类型(类),而args是传入的对象。所以,这个包展开是如下这样的,即拷贝传入的对象。这样会调用到对应类的拷贝构造函数,所以我们从输出中可以看到B1, B2的拷贝构造函数依次被调用
B1(b1), B2(b2)
在这里示例中,博主顿悟一件事情。
虽然初始化列表原则上主要是为了初始化成员类型而服务的,博主之前也是一直这么使用的。但是它也允许我们广泛地理解和使用它。它是一个会在构造函数调用时,在函数体执行之前执行的一段语句。我们可以按照我们的需求灵活地使用这个语法规则。规则要遵守,但常规只是常规。
但是我们在程序设计时,不仅要考虑功能,还有考虑代码的复杂度、可读性。我认为在日常工作中,尽量避免为了设计而设计,如非必要,尽量将复杂的代码写在函数体中。
例4:sizeof...运算符中的包展开
借这个例子介绍下sizeof...运算符。
我们知道sizeof可以获取某个对象类型所占的的字节大小。而sizeof...是专门为了形参包引入的,用于获取形参包中形参个数的运算符。它的返回类型是std::size_t
下面简单改下例3中派生类Derived的构造函数,介绍下sizeof...的使用
Derived(const Args& ...args): Args(args)... {
cout << "sizeof...(Args): " << sizeof...(Args) << endl;
cout << "sizeof...(args): " << sizeof...(Args) << endl;
}
输出:
可变参数模板不论是函数模板还是类模板,声明的语法是一样的、包展开等也是一样的,下面介绍下二者不同的地方。
可变参数在函数模板和类模板中使用的区别
1、在C++11版本,函数模板可以通过推导得出形参包的具体内容;但是类模板不可以,必须先指定好参数包内容。但在C++17中,类模板已经支持可变参数的推导
例如如下:
//可变参数函数模板
template<typename ...Args>
void func(Args ...args){}
//可变参数类模板
template<typename ...Args>
class MyClass
{
public:
MyClass(){}
MyClass(Args ...args){
func(args...); //参数包展开
}
};
int main(){
func(1, 0.5, 'a'); //OK
MyClass(1, 0.5, 'a'); //C++17之前Error, C++17之后OK
MyClass<int, double, char>(1, 0.5, 'a'); //OK,类模板直接指定形参包内容
}
2、可变模板参数(模板参数包)可以与普通模板参数结合使用。但是在类模板中,可变参数必须是最后一个模板形参(这点同上述可变参数函数);而在函数模板中,模板形参包不必出现在最后,只要能够顺序推导即可。
类模板这样是不允许的:
template<typename ...Args, typename T>
class MyClass{};
类模板这样是允许的:
template<typename T, typename ...Args>
class MyClass{};
函数模板这样都是允许的:
template<typename ...Args, typename T>
void func(Args ...args, T t){}
template<typename T, typename ...Args>
void func(Args ...args, T t){}
可变参数模板参数的使用
可变参数模板在泛型编程中具有很重要的作用,使得模板的应用更加广泛。上面介绍了可变参数模板怎么声明定义,怎么进行参数传递。但是,在函数体中,我们应该怎样使用参数包呢?
在上面我们介绍了包展开,可以实现对参数包的应用。但是,包展开需要特定的场景和应用环境。如果不满足特定场景就无法应用包展开的语法对参数包进行使用,这样显然不符合我们的需求。
就比如在例2的最后,我曾说过,模式是这样的(cout << args(1,2) << endl, 0)。这个表达式最后的结果一定是0,我们其实并不需要这个结果,数组ret[]也是不需要的,我只是想借助对数组的初始化这个场景对包进行展开,达到输出args(1,2)的目的而已。那么怎么达成这个目的,即怎么去掉那些特殊场景的辅助,直接完成对参数包的求解,是本小节讨论的问题。
递归计算
在C++17之前,C++11标准中,要对可变参数模板形参包展开逐个计算需要用到递归的方法。
我们还是用例2的代码,以逐个输出参数的目标为例。如果我们不依赖特殊场景,需要这样完成程序设计:
//用于处理参数包中只剩下一个参数的情况
template<typename T>
void mfunc(T t)
{
cout << t << endl;
}
//递归
template<typename T, typename ...Args>
void mfunc(T t, Args ...args)
{
cout << t; //打印第一个参数
mfunc(args...); //并递归参数包中剩下的参数
}
int main(){
mfunc(1,2,3);
}
输出
递归分解参数包的基本思想就是,利用第一个具体的参数t,来递归分出参数包中剩余的第一个参数,并计算出来。
需要注意的是,虽然mfunc(T t, Args ...args)中可变参数Args是可以有0个或多个,但是由于其中有个具体的参数T,所以递归到最后mfunc(T t, Args ...args)是不可以传入0个参数的。所以要另外定义一个只有一个参数的函数模板,用于处理只剩下一个参数的情况。
折叠表达式
在前面的例子中,我们提到了递归的方式对参数包进行解包计算。但是,我们可以看出,递归的方式还是过于繁琐。为了用更方便正规的方式进行解包,C++委员会在C++17标准中引入了折叠表达式。
折叠表达式是C++17正式引入的特性,允许对参数包进行规约操作。折叠表达式主要用于在模板中处理边长参数包时简化代码。
还是上面递归的例子,我简单用折叠表达式改写一下
template<typename ...Args>
void mfunc(Args ...args)
{
(cout << ... << args) << endl;
}
int main(){
mfunc(1,2,3);
}
输出:
折叠表达式规则
折叠表达式分为一元折叠表达式和二元折叠表达式,其中有分为向左折叠和向右折叠。下面详细介绍这四种折叠规则
1、一元折叠表达式
- 一元右折叠:
(args op ...) 展开为 (arg0 op (arg1 op ... (argn-1 op argn)))
其中,op代表操作符
举例:
下面的代码,(args + ...)应用了折叠表达式,展开的计算公式是
(1+(2+3))
template<typename ...Args>
auto add_datan(Args ...args)
{
return (args + ...);
}
int main(){
cout << add_datan(1,2,3) << endl;
}
- 一元左折叠:
(... op args) 展开为 ((((arg0 op arg1) op arg2) op ... ) op argn)
举例:
上面一元右折叠的代码,改一下折叠方向
return (... + args);
(... + args)展开是这样的 ((1+2)+3)
何以见得呢?
假如我们换一下输出内容。输出这样的内容
add_datan(string("are "),"you ","ok?");
我们知道,两个字符串常量是无法使用+操作符组合的,即"you ","ok?"这两个无法相加。但是string类型对象可以和字符串常量使用+,结果是一个string对象。所以
string("are ") +("you " + "ok?")编译器会报错
(string("are ") +"you " )+ "ok?") 可以正常执行
我们可以使用这个特性判断。经过编码验证
(args + ...)会报错,而(... + args)正常输出。可得结论。
2、二元折叠表达式
- 二元右折叠:
(args op ... op init) 展开为 (arg0 op (arg1 op ... (argn op init)))
其中,init代表初始值。具有初始值的二元折叠,要先将初始值和参数包结合计算,所以是向初始值在的方向折叠。
注意,两个op必须相同。
举例:
下面的代码,(args + ... +100 )应用了折叠表达式,展开的计算公式是
(1+(2+3))
template<typename ...Args>
auto add_datan(Args ...args)
{
return (args + ... + 100);
}
int main(){
cout << add_datan(1,2,3) << endl;
}
输出:
- 二元向左折叠:
(init op ... op args) 展开为((((init op arg0) op arg1) op ... ) op argn)
举例:
同样的。如果把初始值放在前面,就变成了向左折叠 (100+ ... + args)。这是很简单的一个改动,不再多介绍。我想介绍下,其实使用cout输出一个参数包,也是一个二元向左折叠的应用。
如下代码
template<typename ...Args>
void mfunc(Args ...args)
{
(cout << ... << args) << endl;
}
int main(){
mfunc(1,2,3);
}
其中操作符op是 << ,初始值是 cout。所以是向左折叠的。
这里我们再想想,怎么使用折叠表达式,在输出每个参数的间隙添加一个空格呢?
我们可以使用逗号运算符实现这个功能。
((cout << args << " "),...) << endl;
输出:
折叠表达式先介绍到这里。写博文不易,希望如果大家感觉有所帮助帮忙随手点个赞表达下支持,转载请注明出处,有问题也欢迎留言讨论。
【参考文献】
《现代C++语言核心特性解析》-- 谢方堃