【C++11】可变参数模版


一、可变参数模版的概念

可变参数模板是C++11新增的最强大的特性之一,它对参数高度泛化,能够让我们创建可以接受可变参数的函数模板和类模板

  • 在C++11之前,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
  • 在C++11之前其实也有可变参数的概念,比如printf函数就能够接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。

二、可变参数模板的定义方式

函数的可变参数模板定义方式如下:
template<class …Args>
返回类型 函数名(Args… args)
{
  //函数体
}

template<class ...Args>
返回类型 函数名(Args... args)
{
	// 函数体
}

我们使用如下:

template<class ...Args>
void showlist(Args... args)
{}

说明一下

  • 模板参数Args前面有省略号,代表它是一个可变模板参数,我们把带省略号的参数称为参数包,参数包里面可以包含0到N ( N ≥ 0 ) 个模板参数,而args则是一个函数形参参数包。
  • 模板参数包Args和函数形参参数包args的名字可以任意指定,并不是说必须叫做Args和args。

现在调用showlist函数时就可以传入任意多个参数了,并且这些参数可以是不同类型的。

#include<iostream>
#include<string>
using namespace std;

template<class ...Args>
void showlist(Args... args)
{}

int main()
{
	showlist();
	showlist(1);
	showlist(1, 'a');
	showlist(1, 'a', string("1234"));
	return 0;
}

我们可以在函数模板中通过sizeof计算参数包中参数的个数:

template<class ...Args>
void showlist(Args... args)
{
	std::cout << sizeof...(args) << std::endl;
}

在这里插入图片描述

特别注意,语法并不支持使用args[i]的方式来获取参数包中的参数。

template<class ...Args>
void showlist(Args... args)
{
	// 错误例子
	for (int i = 0; i < sizeof...(args); i++)
	{
		std::cout << args[i] << " "; // 这里不能调用args[i]
	}
	std::endl;
}

因此要获取参数包中的各个参数,只能通过展开参数包的方式来获取,一般我们会通过递归或逗号表达式来展开参数包。

三、参数包的展开方式

1、递归展开法

  • 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来。
  • 在函数模板中递归调用该函数模板,调用时传入剩下的参数包。
  • 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
// 展开函数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
	std::cout << value << " "; // 打印分离出的第一个参数
	showlist(args...); // 递归调用,将参数包继续向下传
}

但就有一个问题了,这个递归结束的出口是什么,也就是怎么样才能退出递归呢?

(1)无参的递归终止函数

我们可以在刚才的基础上,再编写一个无参的递归终止函数,该函数的函数名与展开函数的函数名相同。如下:

// 无参的递归终止函数
void showlist()
{
	std::cout << std::endl;
}
// 展开函数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
	std::cout << value << " "; // 打印分离出的第一个参数
	showlist(args...); // 递归调用,将参数包继续向下传
}

int main()
{
	//showlist();
	showlist(1);
	showlist(1, 'a');
	showlist(1, 'a', string("1234"));
	return 0;
}

在这里插入图片描述
这样一来,当递归调用showlist函数模板时,如果传入的参数包中参数的个数为0,那么就会匹配到这个无参的递归终止函数,这样就结束了递归。

  • 但如果外部调用showlist函数时就没有传入参数,那么就会直接匹配到无参的递归终止函数。
  • 而我们本意是想让外部调用showlist函数时匹配的都是函数模板,并不是让外部调用时直接匹配到这个递归终止函数。

则我们就开始写一个新的showlistarg的函数,供外部函数能够进行调用,我们看下面的代码来进行讲解:

// 无参的递归终止函数
void showlistarg()
{
	std::cout << std::endl;
}
// 供外部函数进行调用
template<class T, class ...Args>
void showlistarg(T value, Args... args)
{
	std::cout << value << " "; // 打印分离出的第一个参数
	showlistarg(args...); // 递归调用,将参数包继续向下传
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
	showlistarg(args...);
}

这时无论外部调用时传入多少个参数,最终匹配到的都是同一个函数了。

(2)编写带参的递归终止函数

除了编写无参的递归终止函数,也可以编写带参数的递归终止函数来终止递归,比如这里编写带一个参数的递归终止函数:

// 带参的递归终止函数
template<class T>
void showlistarg(const T& t)
{
	std::cout << t << std::endl;
}
// 供外部函数进行调用
template<class T, class ...Args>
void showlistarg(T value, Args... args)
{
	std::cout << value << " "; // 打印分离出的第一个参数
	showlistarg(args...); // 递归调用,将参数包继续向下传
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
	showlistarg(args...);
}

这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归调用函数需要写成函数模板,因为我们并不知道最后一个参数是什么类型的。
但该方法有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。

(3)错误用例:判断参数包中的参数个数

// 错误示范 -- 判断参数包中的参数个数
template<class T, class ...Args>
void showlist(T value, Args... args)
{
	std::cout << value << " ";
	if (sizeof...(args) == 0)
	{
		return;
	}
	showlist(args...);
}

这种方式是不可行的,原因如下:

  • 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用
  • 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的showlist函数,此时就会产生报错,因为showlist函数要求至少传入一个参数。
  • 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。

2、逗号表达式展开参数包

(1)通过列表获取参数包中的参数

如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。

// 展开函数
template<class ...Args>
void showlist(Args ...args)
{
	int arr[] = { args... }; // 将参数包中的整数全部放进arr数组中
	for (auto& e : arr)
	{
		std::cout << e << " ";
	}
	std::cout << std::endl;
}

在这里插入图片描述
但有缺陷,缺陷就是不能传无参进去,并且还有个致命的缺陷就是只能传一种类型的参数,如果我们第一个参数是整数,那么后面的参数也都得是整数类型的参数进行传参。

(2)通过逗号表达式展开参数包

虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。

  • 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回
  • 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值
  • 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。

我们如下代码,将int数组的arr首表达式设置成一个函数并将其进行传递args的参数,并用0进行结尾给数组说是这是整数类型的。

// 处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
	std::cout << t << " ";
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... };
	std::cout << endl;
}
  • 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可。
  • 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入PrintArg函数,代码中的{(PrintArg(args), 0)...}将会展开成{(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc...}

自此我们就可以传入不同的类型的参数了,但我们此时还有一个问题,就是我们假如说是无参函数怎么办?其实很简单,我们再写一个无参的重载函数即可:

// 无参的调用函数
void showlist()
{
	std::cout << std::endl;
}
// 处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
	cout << t << " ";
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... };
	cout << endl;
}

(3)利用返回值代替逗号表达式

我们利用传入的函数进行返回一个整数即可,就不需要繁琐的逗号表达式,我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的。

// 无参的调用函数
void showlist()
{
	std::cout << std::endl;
}
// 处理参数包中的每个参数
template<class T>
int PrintArg(const T& t)
{
	cout << t << " ";
	return 0;
}
// 展开函数
template<class ...Args>
void showlist(Args... args)
{
	int arr[] = { PrintArg(args)... };
	cout << endl;
}

四、STL容器中的emplace相关接口函数

1、emplace版本的插入接口

下面是list的emplace版本的新增加的修改功能的函数接口:
在这里插入图片描述

这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:
在这里插入图片描述
emplace_back和push_back这个原生尾插大致是相同的,但有如下不同的地方:(我们以list中的emplace_back和push_back为例进行讲解)

  • 调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化
  • 调用emplace_back函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化
  • 除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包
int main()
{
	list<pair<int, string>> mylist;
	pair<int, string> kv(10, "222");

	mylist.push_back(kv); // 左值
	mylist.push_back(pair<int, string>(10, "111")); // 右值
	mylist.push_back({ 20,"123" }); // 初始化列表
	mylist.emplace_back(kv); // 左值
	mylist.emplace_back(pair<int, string>(30, "444")); // 右值
	mylist.emplace_back(20, "12"); // 传参数表
	return 0;
}

2、emplace系列接口的工作流程

emplace系列接口的工作流程如下:

  • 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化
  • 然后调用allocator_traits::construct函数对这块空间进行初始化,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。
  • allocator_traits::construct函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。
  • 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。

3、emplace系列接口的意义

由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。

  • 如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
  • 如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
  • 如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。

即我们总结:

  • 传入左值对象,需要调用构造函数+拷贝构造函数
  • 传入右值对象,需要调用构造函数+移动构造函数
  • 传入参数包,只需要调用构造函数

当然,这里的前提是容器中存储的元素所对应的类,是一个需要深拷贝的类,并且该类实现了移动构造函数。否则调用emplace系列接口时,传入左值对象和传入右值对象的效果都是一样的,都需要调用一次构造函数和一次拷贝构造函数。

实际emplace系列接口的一部分功能和原有各个容器插入接口是重叠的,因为容器原有的push_back、push_front和insert函数也提供了右值引用版本的接口,如果调用这些接口时如果传入的是右值对象,那么最终也是会调用对应的移动构造函数进行资源的移动的。

4、emplace接口意义

  • emplace系列接口最大的特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说emplace系列接口更高效的原因。
  • 但emplace系列接口并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么emplace系列接口的效率其实和原有的插入接口的效率是一样的。
  • emplace系列接口真正高效的情况是传入参数包的时候,直接通过参数包构造出对象,避免了中途的一次拷贝。

5、验证一下

我们继续用到我们写的string类了,我们代码如下:

#include<iostream>
#include<cassert>
#include<string>
#include<list>
using namespace std;

// string类
namespace JRH
{
	class string
	{
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	public:
		// 构造函数
		string(const char* str = "")
		{
			std::cout << "string(const char* str) -- 构造函数" << std::endl;
			_size = strlen(str); // 初始时字符串大小设置成字符字符串长度
			_capacity = _size; // 初始时字符串容量设置成字符串大小
			_str = new char[_capacity + 1]; // +1是为了后面还有个\0,为存储字符串开辟空间
			strcpy(_str, str);
		}
		// 析构函数
		~string()
		{
			delete[] _str; // 释放_str指向的空间
			_str = nullptr; // 置空
			_size = 0; // 字符串大小置0
			_capacity = 0; // 字符串容量置0
		}
		// 移动构造
		string(string&& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			std::cout << "string(string&& s)" << std::endl;
			swap(s);
		}
		// 移动赋值
		string& operator=(string&& s)
		{
			std::cout << "string& operator=(string&& s) -- 移动赋值" << std::endl;
			swap(s);
			return *this;
		}
		// 正向迭代器
		typedef char* iterator;
		// begin
		iterator begin()
		{
			return _str; // 返回字符串中首字母元素的地址
		}
		// end
		iterator end()
		{
			return _str + _size; // 返回字符串中最后一个字母的下一个字符的地址
		}
		// 交换两个对象的数据
		void swap(string& s)
		{
			// 调用库里面的swap
			::swap(_str, s._str); // 交换两个对象的字符串
			::swap(_size, s._size); // 交换两个对象的字符串大小
			::swap(_capacity, s._capacity); // 交换两个对象的字符串容量
		}
		// 拷贝构造(现代写法)--  深拷贝
		string(const string& str)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			std::cout << "string(const string& str) -- 深拷贝" << std::endl;
			// 先开辟一个tmp空间
			string tmp(str);
			swap(tmp); // 将tmp扔到swap中进行交换
		}
		// 赋值运算符操作
		string& operator=(const string& str)
		{
			std::cout << "string& operator=(const string& str) -- 深拷贝" << std::endl;
			// 开辟一个tmp临时空间
			string tmp(str);
			// 丢到swap将其进行交换
			swap(tmp);
			// 返回左值
			return *this;
		}
		// 运算符[]重载函数
		char& operator[](size_t i)
		{
			assert(i < _size);
			return _str[i]; // 返回下标相对应的字母
		}
		// 改容量,但大小不变
		void reserve(size_t n)
		{
			if (n > _capacity) // 只有当n大于容量的时候才进行修改
			{
				char* tmp = new char[n + 1]; // 开辟个临时空间,后面带上\0
				strncpy(tmp, _str, _size + 1); // 将_str的所有字符拷贝进tmp中
				delete[] _str; // 释放对象原本空间
				_str = tmp; // 将tmp的空间给_str(tmp只是临时空间,它需要给原住民_str开辟)
				_capacity = n; // 容量变成n
			}
		}
		// push_back进行尾插字符
		void push_back(char ch)
		{
			if (_size == _capacity) // 判断是否需要增容
			{
				reserve(_capacity == 0 ? 4 : _capacity * 2); // 初始为4,后面两倍两倍增加
			}
			_str[_size] = ch; // 尾插
			_str[_size++] = '\0'; // 后面一个字符为\0并将其_size++
		}
		// +=运算符重载
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this; // 返回左值
		}
		// 返回c类型的字符串
		const char* c_str() const
		{
			return _str;
		}
	};
}

我们用不同的传参形式进行传参看一下:

在这里插入图片描述

说明

  • 模拟实现string的拷贝构造函数时复用了构造函数,因此在调用string拷贝构造的后面会紧跟着调用一次构造函数。
  • 为了更好的体现出参数包的概念,因此这里list容器中存储的元素类型是pair,我们是通过观察string对象的处理过程来判断pair的处理过程的。

既然这样,我们再看一下push_back把:

int main()
{
	list<pair<int, JRH::string>> mylist;
	pair<int, JRH::string> kv(1, "1234");
	mylist.push_back(kv); // 传左值
	std::cout << std::endl;
	mylist.push_back(pair<int, JRH::string>(2, "12")); // 传右值
	std::cout << std::endl;
	mylist.push_back({ 50, "1222" }); // 传初始化列表
	return 0;
}

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

2022horse

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值