可变参数函数、可变参数模板和折叠表达式

可变参数函数

可变参数是在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++语言核心特性解析》-- 谢方堃

C++17中引入了新的语法`constexpr if`折叠表达式,可以更方便地实现可变参数函数调用。 具体实现方式如下: 1. 定义一个模板函数函数模板参数包含一个参数包。 2. 在函数体中使用`constexpr if`折叠表达式来展开参数包,以实现对可变参数的遍历操作。 下面是一个简单的例子,实现可变参数函数的调用: ```c++ #include <iostream> #include <cstdio> using namespace std; template<typename... Args> void my_printf(const char* fmt, Args... args) { while (*fmt) { if (*fmt == '%' && *(fmt + 1) != '%') { if constexpr (std::is_same_v<int, std::decay_t<decltype(args)>>) { cout << va_arg(args, int) << endl; } else if constexpr (std::is_same_v<const char*, std::decay_t<decltype(args)>>) { cout << va_arg(args, const char*) << endl; } else { // Other types } fmt += 2; } else { cout << *fmt << endl; fmt++; } } } int main() { my_printf("Hello %s!\n", "world"); my_printf("The value of pi is %d.\n", 3); return 0; } ``` 在上面的例子中,我们定义了一个`my_printf`函数,该函数的第一个参数是一个`const char*`类型的字符串,用于格式化输出,后面的参数是一个可变参数列表,可以是任意类型的值。在函数体中,我们使用了`constexpr if`折叠表达式来根据参数类型来输出不同的结果。 需要注意的是,在使用可变参数的时候,应该尽量避免类型转换参数类型不一致的问题,以保证程序的正确性可读性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值