C/C++语言基础--C++模板与元编程系列五(可变惨模板,形参包展开,折叠表达式)

本专栏目的

  • 更新C/C++的基础语法,包括C++的一些新特性

前言

  • 模板与元编程是C++的重要特点,也是难点,本人预计将会更新10期左右进行讲解,这是第五期,讲解可变惨模板,形参包展开,折叠表达式等,本人感觉这一部分内容还是比较复杂的;
  • C语言后面也会继续更新知识点,如内联汇编;
  • 欢迎收藏 + 关注,本人将会持续更新。

文章目录

可变参数模板

可变参数模板的概念和语法

📊 如果一个函数需要的参数个数以及参数类型不确定时,我们急需一种能够让参数可变的方法!

  • 参数类型一致 ,参数个数不同 时可以使用initializer_list
  • 参数类型不一致时,需要使用C++提供的可变参数模板

🎃 来个代码:

cpp 复制代码
#include <iostream>

template<typename ...Args>
void print(Args ...args)
{
	std::cout << __FUNCTION__ << " " << sizeof...(args) << std::endl;
}

int main()
{
	print(1, 2, "Hello", "World", 18, 20);  
	print("aaa", 2);
	print(11111);

	return 0;
}

⛑ 结果:

txt 复制代码
print 6
print 2
print 1

💱 解释

  • 在上面的代码中class ...Args是类型模板形参包,他可以接受零个或者多个类型的模板实参;
  • Args ...args叫做函数形参包,它出现在函数的形参列表中,可以接受零个或者多个函数实参;
  • sizeof...(args) 其中sizeof...是C++的运算符 ,专门用来获取形参包的参数个数。

以上这些语法概念看起来可能会有点复杂。但是这样写应该就明了了:

cpp 复制代码
int main()
{
	print(1, 2, "Hello", "World", 18, 20);    // print<int, int, string, string, int, int>
	print("aaa", 2);	// print<string, int>
	print(11111);		// print<int>

	return 0;
}

上面 ,我们可以看出,其实就是省去了定义类型 步骤,这个步骤编译器是自动帮我们实现的,对于可变参数模板来说 ,它可以接收任意多个参数 ,这些参数都交由编译器自动帮我们推导,最总生成对于的实例化函数。


可变参注意点

如果可变参数和其他模板变量相结合,那一定要注意顺序问题 ,必须将含餐变量放到最后,如下代码所示。

cpp 复制代码
#include <iostream>

template<typename T, typename U, typename ...Args>
void foo(T t, U u, Args ...args)
{
	std::cout << "first: " << t << " second: " << u << " other size: " << sizeof...(args) << std::endl;

}

int main()
{
	foo<int, double>(10, 66.0, "wy", 28);

	return 0;
}

⛪️ 输出:

形参包展开

含参变量,如果可以存储任意个数,任意类型,那必有一个核心功能,就是含参展开 ,也叫形参包展开,简称包展开。只有结合了包展开,才能发挥变参模板的能力。但是注意的是,包展开并不是在所有情况下都能够进行的,允许包展开的场景包括以下几种。

  1. 表达式列表。
  2. 初始化列表。
  3. 基类描述。
  4. 成员初始化列表。
  5. 函数参数列表。
  6. 模板参数列表。
  7. 动态异常列表(C++17已经不再使用)。
  8. lambda表达式捕获列表。
  9. Sizeof...运算符。
  10. 对其运算符。
  11. 属性列表。

借助辅助函数实现包展开

cpp 复制代码
#include <iostream>

template<typename T>
T print(T t)
{
	std::cout << t << std::endl;
	return t;
}

// 解包辅助函数
template<typename ...Args>
void unpack(Args ...args)
{
}

template<typename ...Args>
void foo(Args ...args)
{
	unpack(print(args)...);
}

int main()
{
	foo(1, 2, "y", "x", "z");


	return 0;
}

在上面的代码中,print是一个普通的函数模板,它将实参通过std::cout输出到控制台上。unpack是一个可变参数的函数模板,不过这个函数什么也不做。在main函数中调用了foo函数模板,并传递了参数,在它的函数体里面对形参包进行了展开,其中print(args)...是包展开,而print(args)就是模式,也可以理解为包展开的方法。所以这段代码相当于:

cpp 复制代码
void foo(int a1, int a2, string a3, string a4, string a5)
{
	unpack(print(a1), print(a2), print(a3), print(a4), print(a5));
}

对于这个代码来说,就非常清晰了,其实unpack这个空函数,就是用来容纳包展开的内容的。

可变参数模板的递归

递归输出所有参数

在上面的形参包展开中也能输出所有参数,但是比较麻烦,接下来看一下递归方式输出,比如下面的案例:

cpp 复制代码
#include <iostream>

template<typename T, typename ...Args>
void foo(T t, Args ...args)
{
	std::cout << t << " ";
	foo(args...);
}

int main()
{
	foo(1, 2, 3, "y", "x", "z");

	return 0;
}

但是一运行,结果会报错

这是因当可变惨args为空的时候,这个时候调用foo的时候,会出现参数不匹配的情况


🌌 解决方法

  • 方法一:传入一个结束数据,如下
cpp 复制代码
template<typename T,typename ...Args>
void foo(T t,Args ...args)
{
    if(t == 0)
        return;
	cout << t << endl;
    foo(args...,0);
}

结果:

在这里仅仅是加了一个判断,当t == 0,也是就是 foo(args...,0);这个调用的最后一个参数时,退出递归!

当然这个有个坏处,就是当调用者的参数中出现了0时,递归会提前结束,所以这样就需要额外定义一个结束标志。


  • 方法二:使用函数重载

在上面可变参数模板的递归 的时候,报错的原因是当可变惨没有数据的时候,函数foo找不到,那我们可以利用函数重载,当只剩下最后一个数据的时候,让编译器调用函数模板即可。

cpp 复制代码
#include <iostream>

template<typename T>
void foo(T t)
{
	std::cout << t << std::endl;
}

template<typename T, typename ...Args>
void foo(T t, Args ...args)
{
	std::cout << t << " ";
	foo(args...);
}

int main()
{
	foo(1, 2, 3, "y", "x", "z");

	return 0;
}

输出:

``

折叠表达式

折叠表达式求和

如果想实现可变参数中参数求和 ,递归计算的方式过于烦琐,数组和括号表达式的方法技巧性太强也不是很容易想到。为了用更加正规的方法完成包展开,C++委员会在C++17标准中引入了折叠表达式的新特性,下面是用新特性修改的例子:

cpp 复制代码
template<typename ...Args>
auto sum(Args ...args)
{
	return (args + ...);
}

int main()
{
 	std::cout << sum(1, 5.0, 11.7) << std::endl;
}

C++17这一次对这个功能的更新,极大的简化了操作,对于上面,(args + ...)会被折叠为arg0 + (arg1 + arg2),即1 + (5.0 + 11.7)。

在C++17的标准中有4种折叠规则,分别是一元向左折叠、一元向右折叠、二元向左折叠和二元向右折叠。上面的例子就是一个典型的一元向右折叠(公式看着简单,但是运用起来挺难,我如果用会结合AI辅助):

cpp 复制代码
(args op ...)折叠为(arg0 op (arg1 op ... (argN-1 op argN)))

对于一元向左折叠而言,折叠方向正好相反:

cpp 复制代码
(... op args )折叠为((((arg0 op arg1) op arg2) op ...) op argN)

二元折叠总体上和一元相同,唯一的区别是多了一个初始值,比如二元向右折叠:

cpp 复制代码
(args op ... op init )折叠为(arg0 op (arg1 op ...(argN-1 op (argN op
init)))

二元向左折叠也是只有方向上正好相反:

cpp 复制代码
(init op ... op args )折叠为(((((init op arg0) op arg1) op arg2) op
...) op argN)

虽然没有提前声明以上各部分元素的含义,但是看懂不难。

  • args表示的是形参包的名称
  • init表示的是初始化值
  • 而op则代表任意一个二元运算符。值得注意的是,在二元折叠中,两个运算符必须相同。 (且折叠初始值和末尾值不属于折叠展开式子)

其他情况

  • 情况一,用折叠参数展开,OP左右之间必须是能正常运算的,如下代码:
cpp 复制代码
#include <iostream>

template<typename ...Args>
auto sum(Args ...args)
{
	return (args + ...);
}

int main()
{
	std::cout << sum(1, 2, 3, "y", "x", "z") << std::endl;

	return 0;
}

这个时候,运行会报错,因为这个时候展开情况1 + (2 + 3 + "w" + "y" + "z") 的情况,所以报错。

  • 情况二,string类型和原生字符串类型

在折叠规则中最重要的一点就是操作数之间的结合顺序,如下:

cpp 复制代码
template<typename ...Args>
auto sum(Args ...args)
{
	return (args + ...);
}

int main()
{
	cout << sum(std::string("hello "), "C++", "Maye") << endl;
}

上面的代码会编译失败,因为折叠表达式(args +...)向右折叠,所以翻译出来的实际代码是(std::string("hello ") + ("c++ " + "Maye"))但是两个原生的字符串类型是无法相加的,所以编译一定会报错。要使这段代码通过编译,只需要修改一下折叠表达式即可:

cpp 复制代码
template<typename ...Args>
auto sum(Args ...args)
{
	return (... + args);
}

这样翻译出来的代码将是((std::string("hello ") +"c++ ") + "world")而std::string类型的字符串可以使用+将两个字符串连接起来,于是可以顺利地通过编译

折叠表达式输出

最后让我们来看一个有初始化值的例子:

cpp 复制代码
#include <iostream>

template<typename ...Args>
void foo(Args ...args)
{
	(std::cout << ... << args) << std::endl;
}


int main()
{
	foo(1, 2, 3, "y", "x", "z");

	return 0;
}

在上面的代码中,foo是一个输出函数,它会将传入的实参输出到控制台上。该函数运用了二元向左折叠(std::cout <<...<<args),其中std::cout是初始化值,编译器会将代码翻译为()(((((std::cout << 1) << 2) << 3) << "y") << "x") << "z") << std::endl;

小结

💌 核心:明白是几元运算符,是左叠还是右叠。

一元折叠表达式中空参数包的特殊处理

一元折叠表达式对空参数包展开有一些特殊规则,这是因为编译器很难确定折叠表达式最终的求值类型,比如:

cpp 复制代码
template<typename ...Args>
auto sum(Args ...args)
{
	return (args + ...);
}

在上面的代码中,如果函数模板sum的实参为空,那么表达式args +...是无法确定求值类型的。当然,二元折叠表达式不会有这种情况,因为它可以指定一个初始化值:

cpp 复制代码
template<typename ...Args>
auto sum(Args ...args)
{
	return (args + ... + 0);
}

这样即使参数包为空,表达式的求值结果类型依然可以确定,编译器可以顺利地执行编译。

为了解决一元折叠表达式中参数包为空的问题,下面的规则是必须遵守的,注意是为空的情况:

  • 只有 &&、|| 和 , 运算符能够在空参数包的一元折叠表达式中使用;

    • 这些运算符在参数包为空时有明确的求值结果,编译器可以正确处理。
  • && 的求值结果一定为 true;

    • 例如,(true && ...) 在参数包为空时求值为 true。
  • || 的求值结果一定为 false;

    • 例如,(false || ...) 在参数包为空时求值为 false。
  • , 的求值结果为 void();

    • 例如,(void(), ...) 在参数包为空时求值为 void()。
  • 其他运算符都是非法的;

    • 例如,+、-、*、/ 等运算符在参数包为空时会导致编译错误,因为编译器无法推导出返回类型。
cpp 复制代码
template<typename ...Args>
auto andop(Args ...args)
{
	return (args && ...);
}
int main()
{
	std::cout<< std::boolalpha << andop()<<std::endl;
}

在上面的代码中,虽然函数模板andop的参数包为空,但是依然能成功地编译运行并且输出计算结果true。

相关推荐
小周不摆烂6 分钟前
Java基础-内部类与异常处理
java·开发语言
wmxz52020 分钟前
SpringMVC处理请求流程
java·spring boot·后端·spring·java-ee
大鲤余29 分钟前
Rust开发一个命令行工具(一,简单版持续更新)
开发语言·后端·rust
梦想画家29 分钟前
快速学习Serde包实现rust对象序列化
开发语言·rust·序列化
fann@qiu35 分钟前
python 爱心邮件代码
开发语言·python
一念之坤36 分钟前
零基础小白 Python这样学就对啦!——07篇
开发语言·python
学点东西吧.38 分钟前
JVM(一、基础知识)
java·jvm
杜杜的man1 小时前
【go从零单排】Random Numbers、Number Parsing
开发语言·python·golang
林会1 小时前
跨域及解决跨域
java
IJ[JJ1 小时前
学Linux的第八天
java·linux·服务器