目录

1、可变参数模板的概念

2、参数包的展开方式

        递归函数方式展开参数包

        逗号表达式展开参数包

3、STL容器中的empalce相关接口函数


1、可变参数模板的概念

  • C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的。现阶段呢,我们掌握一些基础的可变参数模板特性就够我们用了,所以这里我们点到为止。

下面就是一个基本可变参数的函数模板

// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}
  • 上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为参数包,它里面包含了0到N(N>=0)个模版参数
  • 模板参数包Args和函数形参参数包args的名称可以自己进指定。

现在调用ShowList函数就可以传入任意个数的任意类型的参数了:

int main()
{
	ShowList(1, 'x', 2.2, "abc");
	ShowList(-1, -2, -3);
	return 0;
}

我们也可以通过sizeof获得参数包的个数,但注意格式:sizeof...(args)

template <class ...Args>
void ShowList(Args... args)
{
	cout << sizeof...(args) << endl;//获取参数包中参数的个数
}

我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。语法并不支持使用args[i]这样方式获取可变参数也不支持auto范围for的方式获取可变参数

template <class ...Args>
void ShowList(Args... args)
{
	/*不支持args[i]
	for (size_t i = 0; i < sizeof...(args); ++i)
	{
		cout << args[i] << endl;
	}*/
	/*不支持auto范围for
	for (auto& e : args)
	{
		cout << e << endl;
	}*/
}

由于语法不支持使用args[i]这样方式获取可变参数,所以我们的用一些奇招来一一获取参数包的值。


2、参数包的展开方式

递归函数方式展开参数包

递归展开参数包需要实现两个函数,且二者同名:

  • 一、递归函数
  • 二、递归终止函数

我们分开来讨论:

一、递归函数:

  1. 给可变参数的函数模板增加一个模板参数,用于后续获得每一个参数的值
  2. 在该函数模板中递归调用函数模板,把剩下的参数包传进去
  3. 一直递归下去,每次分离参数包中的一个参数,直至全部分离出来

示例:

//递归函数
template <class T, class ...Args>
void ShowList(const T& val, Args... args)
{
	cout << sizeof...(args) << endl;//获取参数包中参数的个数
	cout << val << "->" << typeid(val).name() << endl;
	ShowList(args...);
}

写好了递归函数,接下来完成终止函数,

二、递归终止函数:

这里我们也给出两个终止函数的方式:

  1. 带参的终止函数
  2. 无参的终止函数

先看带参的终止函数,结合递归函数和测试用例一起看:

//带参的终止函数
template <class T>
void ShowList(const T& val)
{
	cout << val << "->" << typeid(val).name() << " end" << endl;
}

根据参数的最匹配原则,当参数包只有一个参数的时候,编译器会优先匹配到此终止函数完成递归终止。接下来把整体的代码加上测试用例一起看:

//带参的终止函数
template <class T>
void ShowList(const T& val)
{
	cout << val << "->" << typeid(val).name() << " end" << endl;
}
//递归函数
template <class T, class ...Args>
void ShowList(const T& val, Args... args)
{
	cout << sizeof...(args) << endl;//获取参数包中参数的个数
	cout << val << "->" << typeid(val).name() << endl;
	ShowList(args...);
}
int main()
{
	ShowList(1, 'x', 1.1);
	return 0;
}

  • 首先,1传给val,把x和1.1传给参数包,推出T的类型为int,参数包的个数为2,打印后,继续递归把x传给val,把1.1传给参数包,推出T的类型为char,参数表的个数为1,打印后再继续递归,此时参数包的个数只有一个,根据模板的最匹配原则,这一个参数会匹配到递归终止函数,
  • 但该方法有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。

下面来看看无参的递归终止函数

//无参的终止函数
void ShowList()
{}

此时当参数包的个数为0个的时候,就会走此函数,完成递归终止。

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

结合测试用例一起看:

//无参的终止函数
void ShowList()
{}
//递归函数
template <class T, class ...Args>
void ShowList(const T& val, Args... args)
{
	cout << sizeof...(args) << endl;//获取参数包中参数的个数
	cout << val << "->" << typeid(val).name() << endl;
	ShowList(args...);
}
int main()
{
	ShowList(1, 'x', 1.1);
	cout << endl;
	ShowList(1, 2, 3, 4, 5);
	cout << endl;
	ShowList();
	return 0;
}

注意:递归终止的方式不能按照如下的方式写:

//递归函数
template <class T, class ...Args>
void ShowList(const T& val, Args... args)
{
	//错误的写法:
	if (sizeof...(args) == 0)
	{
		return;
	}
	cout << val << "->" << typeid(val).name() << endl;
	ShowList(args...);
}
  • 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
  • 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的ShowList函数,此时就会产生报错,因为ShowList函数要求至少传入一个参数。
  • 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。

逗号表达式展开参数包

这里我们先给出使用逗号表达式展开参数包的一个例子:

template <class T>
void PrintArg(const T& t)
{
	cout << t << " ";
}
template <class ...Args>
void ShowList(Args... args)
{
	//列表初始化+逗号表达式
	int arr[] = { (PrintArg(args), 0)... };
	cout << endl;
}
int main()
{
	ShowList(1, 'x', 1.1, string("hello world"));
	ShowList(1, 2, 3, 4, 5);
	return 0;
}

下面我将给出其演化的过程:

前面我们学习到了可以使用{ }列表初始化来初始化数组等内置类型和自定义类型,那么我可不可以直接把参数包放到列表初始化呢?

template <class ...Args>
void ShowList(Args... args)
{
	//列表初始化
	int arr[] = { args... };
	cout << endl;
}

这里很明显是不可以的,C++只允许数组里面是同一种类型,但是模板的可变参数就意味着我参数包的类型并不统一,会出现一会是int,一会是char……。为了解决此问题,我们可以单独封装一层函数(PrintArg),此函数专门用于获得参数包的每个数据并输出,但是这又会出现一个问题,我得不到一个返回值放回数组里头,为了解决返回值的问题,又使用了逗号表达式来解决:

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

这里我们把逗号表达式的最后一个值设为0,此时我参数包里有几个参数,那么就有几个0,也就代表有几个值。调整后的代码如下:

template <class T>
void PrintArg(const T& t)
{
	cout << t << " ";
}
template <class ...Args>
void ShowList(Args... args)
{
	//列表初始化+逗号表达式
	int arr[] = { (PrintArg(args), 0)... };
	cout << endl;
}

注意:

  • 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入PrintArg函数,代码中的{(PrintArg(args), 0)...}将会展开成{(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc...}。

此时我们就会发现,就和一开始我们给出的代码一致了,这就是是用来逗号表达式的方式展开参数包。下面给出测试用例:

int main()
{
	ShowList(1, 'x', 1.1, string("hello world"));
	ShowList(1, 2, 3, 4, 5);
	return 0;
}

当然,这里其实不用逗号表达式也可以,直接给PrintArg函数带上返回值即可完成逗号表达式的功能:

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;
}

此时可以传入多种类型的参数了,但是不能不传参数,因为数组的大小不能为0,为了支持不传参数,我们需要单独写个无参的ShowList函数,就像无参版的终止函数那样:

//支持无参调用
void ShowList()
{
	cout << 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;
}

总结:

  • 这种展开参数包的方式,不需要通过递归终止函数,是直接在expand函数体中展开的, Printarg不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式。
  • expand函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行printarg(args),再得到逗号表达式的结果0。同时还用到了C++11的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)...}将会展开成((printarg(arg1),0),(printarg(arg2),0),(printarg(arg3),0), etc... ),最终会创建一个元素值都为0的数组int arr[sizeof...(Args)]。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。

3、STL容器中的empalce相关接口函数

template <class... Args>
void emplace_back (Args&&... args);

首先我们看到的emplace系列的接口,支持模板的可变参数,并且万能引用。那么相对insert和
emplace系列接口的优势到底在哪里呢?

int main()
{
	std::list< std::pair<int, char> > mylist;
	// emplace_back支持可变参数,拿到构建pair对象的参数后自己去创建对象
	// 那么在这里我们可以看到除了用法上,和push_back没什么太大的区别
	mylist.emplace_back(10, 'a');
	mylist.emplace_back(20, 'b');
	mylist.emplace_back(make_pair(30, 'c'));
	mylist.push_back(make_pair(40, 'd'));
	mylist.push_back({ 50, 'e' });
	for (auto e : mylist)
		cout << e.first << ":" << e.second << endl;
	return 0;
}
int main()
{
	// 下面我们试一下带有拷贝构造和移动构造的bit::string,再试试呢
	// 我们会发现其实差别也不到,emplace_back是直接构造了,push_back
	// 是先构造,再移动构造,其实也还好。
	std::list< std::pair<int, cpp::string> > mylist;
	mylist.emplace_back(10, "sort");
	mylist.emplace_back(make_pair(20, "sort"));
	mylist.push_back(make_pair(30, "sort"));
	mylist.push_back({ 40, "sort" });
	return 0;
}

总结:

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

其实我这里对emplace的讲解还不够深刻,具体可以看看龙哥(2021dragon)的博文,个人觉着他总结的确实全面,且通俗易懂,下面附上链接:2021dragon--》可变参数模板精讲

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐