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

ops/2024/10/18 1:31:16/

可变参数函数

可变参数是在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); //使用完之后清理apreturn 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'); //OKMyClass(1, 0.5, 'a'); //C++17之前Error, C++17之后OKMyClass<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++语言核心特性解析》-- 谢方堃


http://www.ppmy.cn/ops/126349.html

相关文章

小说漫画系统 fileupload.php 任意文件上传漏洞复现

FOFA搜索语句 "/Public/home/mhjs/jquery.js" 漏洞复现 1.向靶场发送如下数据包 POST /Public/webuploader/0.1.5/server/fileupload.php HTTP/2 Host: xxx.xxx.xx.xx Cookie: PHPSESSID54bc7gac1mgk0l3nm8cv6sek07; uloginid677742617 Cache-Control: max-age0…

阿里 C++面试,算法题没做出来,,,

我本人是非科班学 C 后端和嵌入式的。在我面试的过程中&#xff0c;竟然得到了阿里​ C 研发工程师的面试机会。因为&#xff0c;阿里主要是用 Java 比较多&#xff0c;C 的岗位比较少​&#xff0c;所以感觉这个机会还是挺难得的。 阿里 C 研发工程师面试考了我一道类似于快速…

android——自定义控件(不停变化的textview、开关switch、动画效果的打勾)

一、从开始数字到结束数字&#xff0c;不断变化 import android.animation.TypeEvaluator; import android.animation.ValueAnimator; import android.content.Context; import android.util.AttributeSet; import android.view.animation.AccelerateDecelerateInterpolator;i…

Linux之如何找回 root 密码?

1、启动系统&#xff0c;进入开界面&#xff0c;在界面中按“e"进入编辑界面 2、进入编辑界面&#xff0c;使用键盘上的上下键把光标往下移动&#xff0c;找到以”Linux16“开通内容所在的行数&#xff0c;在行的最后面输入&#xff1a;init/bin/sh 3、输入完成后&…

【Spring AI】Java实现类似langchain的第三方函数调用_原理与详细示例

Spring AI 介绍 &#xff1a;简化Java AI开发的统一接口解决方案 在过去&#xff0c;使用Java开发AI应用时面临的主要困境是没有统一且标准的封装库&#xff0c;导致开发者需要针对不同的AI服务提供商分别学习和对接各自的API&#xff0c;这增加了开发难度与迁移成本。而Sprin…

408算法题leetcode--第36天

96. 不同的二叉搜索树 题目地址&#xff1a;96. 不同的二叉搜索树 - 力扣&#xff08;LeetCode&#xff09; 题解思路&#xff1a;dp 时间复杂度&#xff1a;O(n^2) 空间复杂度&#xff1a;O(n) 代码: class Solution { public:int numTrees(int n) {// dp[]: i个节点的二…

JavaWeb Servlet--09深入:注册系统03--删除用户业务

删除用户业务 在显示用户的界面游两个超链接&#xff1a;修改和删除&#xff0c;这里将对删除进行业务实现&#xff1a; 思想&#xff1a;在页面展示信息&#xff0c;点击删除的超链接后&#xff0c;获取id&#xff0c;在controller层进行调用service的业务逻辑处理&#xff…

【Linux系统编程】环境基础开发工具使用

目录 1、Linux软件包管理器yum 1.1 什么是软件包 1.2 安装软件 1.3 查看软件包 1.4 卸载软件 2、Linux编辑器-vim 2.1 vim的概念 2.2 vim的基本操作 2.3 vim的配置 3、Linux编译器-gcc/g 3.1 gcc编译的过程​编辑​编辑​编辑 3.2 详解链接 动态链接 静态链接 4…