平时编写打印信息或计算总和的通用函数时,我们往往希望函数既能接收两个参数,又能接收五个甚至更多不同类型的参数。前面学习的普通模板在定义时就已经固定了尖括号里的参数个数,很难直接满足这种需求。
从 C++11 标准开始,C++ 引入了 “可变参数模板(variadic template)”,支持让函数模板或类模板接收 0 个或任意多个、任意类型的参数。
参数包与 sizeof... 运算符
可变参数模板最明显的语法标志是三个连续的英文句点,也就是省略号(...)。我们可以把这里的省略号想象成一个 “打包袋”,它能把传进来的一串数据类型或一串实参打包成一个整体,称为 “参数包”。下面我们先在函数模板中看看参数包的基本写法。
语法:
template <typename... Args>
返回类型 函数名(Args... args) {
// 函数体
}说明:
上面的语法中一共出现了两处省略号,它们都紧跟在关键字或类型后面,分别用来定义两种参数包:
- typename... Args:称为 “模板参数包”,用来打包接收 0 个或多个数据类型。其中
Args是英文 “Arguments(参数)” 的缩写,我们也可以自定义其他合法的名字。 - Args... args:称为 “函数参数包”,用来打包接收调用时传入的 0 个或多个具体实参。
拿到参数包之后,怎么知道这次调用一共传进来了几个参数呢?前面我们学过用来计算字节大小的 sizeof 运算符,而可变参数模板专门提供了一个带省略号的 sizeof... 运算符,可以直接获取参数包里的元素个数。
示例 1:获取参数包的元素个数
#include <iostream>
template <typename... Args>
void countArgs(Args... args) {
std::cout << "参数个数:" << sizeof...(args) << '\n';
}
int main() {
countArgs();
countArgs(10, 20);
countArgs("Jack", 18, 95.5, 'A');
return 0;
}运行结果如下。
参数个数:0
参数个数:2
参数个数:4分析:
上面这个例子非常简单,我们连续调用了 3 次 countArgs(),每次传入的参数个数和数据类型都不一样。第一次调用没有传参,sizeof...(args) 返回 0。第三次调用传入了 4 个不同类型的实参,Args 自动打包这 4 种类型,最终返回 4。
需要注意的是,sizeof... 既可以计算函数参数包 sizeof...(args),也可以计算模板参数包 sizeof...(Args),两者算出来的个数完全相同。
另外小伙伴们千万不要把参数包当成数组来用,我们不能直接通过 std::cout 输出整个参数包,也不能通过中括号下标去取某一项。下面这两种写法都是错误的:
std::cout << args; // 不能直接输出参数包
std::cout << args[0]; // 不能通过下标访问折叠表达式展开参数包(推荐)
既然参数包不能直接打印,也不能按下标读取,那怎么把里面的参数一个个取出来参与运算呢?这个把参数包解开的过程就叫做 “展开参数包”。
从 C++17 标准开始,C++ 引入了非常简洁的 “折叠表达式”。我们只需要在小括号里指定一个二元运算符(比如 + 或 <<),编译器就会自动使用该运算符把参数包里的元素依次连接起来。比如传入 1、2、3 时,(args + ...) 在编译阶段就会展开成 1 + 2 + 3。
1. 计算任意多个数的总和
当对参数包中的所有元素求和时,调用者可能全部传入 int 整数,也可能同时传入 int 和 double。由于结果类型无法提前固定,我们可以在函数返回类型的位置写上 auto 关键字,让编译器根据 return 语句的计算结果自动推导返回类型。
语法:
(参数包 运算符 ...)
(初始值 运算符 ... 运算符 参数包)说明:
折叠表达式最外层的一对英文小括号 “()” 是语法本身的组成部分,不能省略。在实际开发中,常用的有以下 2 种写法:
- 不带初始值的写法:形如
(args + ...),参数包写在左边,省略号写在右边,编译器会使用+把包里的元素依次连接起来。这种写法要求调用时至少传入一个参数,如果传 0 个参数就会编译报错。 - 带初始值的写法:形如
(0 + ... + args),由于我们要从左边的初始值 0 开始往后累加,所以把初始值放在最左边、参数包args放在最右边,中间夹着...和两个相同的运算符。这种写法即使传入 0 个参数,也能正常返回初始值 0。
示例 2:计算任意多个数的总和
#include <iostream>
template <typename... Args>
auto sumAll(Args... args) {
return (0 + ... + args);
}
int main() {
std::cout << sumAll() << '\n';
std::cout << sumAll(10, 20) << '\n';
std::cout << sumAll(1, 2, 3, 4) << '\n';
std::cout << sumAll(10, 2.5, 3) << '\n';
return 0;
}运行结果如下。
0
30
10
15.5分析:
(0 + ... + args) 表示从初始值 0 开始,把参数包 args 中的所有元素依次累加起来。第一次调用 sumAll() 没有传参,直接返回初始值 0。
第三次调用 sumAll(1, 2, 3, 4) 时,编译器会在编译阶段自动将其展开为 return (((0 + 1) + 2) + 3) + 4;,算出总和 10。第四次调用同时传入了整数和小数,配合返回类型 auto 自动推导出小数结果 15.5。
这里大家一定要记住,折叠表达式最外层的小括号不能漏掉。如果去掉小括号写成 return 0 + ... + args;,编译时就会直接报错。
下面代码中,result 的最终值是 24,小伙伴们可以思考一下为什么。
template <typename... Args>
auto multiplyAll(Args... args) {
return (1 * ... * args);
}
int result = multiplyAll(2, 3, 4);2. 连续输出任意多个参数
除了加减乘除运算符之外,输出流运算符 << 同样可以放进折叠表达式中。就像前面把初始值 0 放在最左边一样,这里我们把 std::cout 放在最左边写成 (std::cout << ... << args),一行代码就能将任意多个不同类型的参数连续输出到屏幕上。
另外,前面我们在单个类型模板里学过用 const T& 避免大对象拷贝。在可变参数模板中也是同样的道理,只需在后面加上省略号写成 const Args&... args,编译器展开时就会自动给包里的每一个参数都加上常量引用。
示例 3:连续输出多个参数
#include <iostream>
template <typename... Args>
void printLine(const Args&... args) {
(std::cout << ... << args);
std::cout << '\n';
}
int main() {
printLine("欢迎来到绿叶网!");
printLine("姓名:", "Jack", ",成绩:", 95);
printLine(10, " + ", 20, " = ", 30);
return 0;
}运行结果如下。
欢迎来到绿叶网!
姓名:Jack,成绩:95
10 + 20 = 30分析:
在 printLine() 函数中,(std::cout << ... << args); 以左边的 std::cout 为起点,通过 << 把参数包里的每一项依次输送到屏幕上。
比如调用 printLine("姓名:", "Jack", ",成绩:", 95) 时,编译器会自动将其展开为连续的 << 输出语句,运行效果与我们手动逐个拼接完全一样。
需要注意的是,(std::cout << ... << args) 并不会自动在各个参数之间添加空格。如果我们直接调用 printLine(10, 20, 30),屏幕上就会紧挨着输出 102030,因此需要间隔时记得像上面那样传入空格或分隔文字。
提示 C++ 标准库中很多常用工具底层都是通过可变参数模板实现的。比如前面学过的 std::make_unique() 函数,之所以能接收任意个数和类型的构造参数来创建对象,靠的就是可变参数模板。
递归函数展开参数包(了解)
在 C++17 引入折叠表达式之前,展开参数包最常见的方式是结合函数重载与递归函数:每次先把排在第一个的参数拆出来处理掉,再把剩下的参数包继续传给自己。在阅读早期 C++11 或 C++14 项目的代码时,我们经常会见到这种写法。
它的实现思路分为 2 步:① 编写一个同名的无参普通函数,作为参数包被拆空时的递归出口;② 编写一个形参为 (T first, Args... rest) 的函数模板,每次先输出 first,再用 rest... 把剩余参数展开并递归传给自己。
示例 4:递归拆解参数包
#include <iostream>
// 无参递归出口函数
void printArgs() {
std::cout << '\n';
}
// 拆出首参数与剩余参数包
template <typename T, typename... Args>
void printArgs(T first, Args... rest) {
std::cout << first << ' ';
printArgs(rest...);
}
int main() {
printArgs(10, 3.14, "lvye");
return 0;
}运行结果如下。
10 3.14 lvye分析:
调用 printArgs(10, 3.14, "lvye") 时,整个递归拆解过程分为以下 4 步:
- 第 1 步:
first拿到 10 并输出,剩余参数包rest里还有 3.14 和"lvye",执行printArgs(rest...)相当于调用printArgs(3.14, "lvye")。 - 第 2 步:
first拿到 3.14 并输出,剩余参数包rest里只剩"lvye",继续调用printArgs("lvye")。 - 第 3 步:
first拿到"lvye"并输出,此时剩余参数包rest已经为空,调用无参的printArgs()。 - 第 4 步:匹配到最上方定义的无参函数
void printArgs(),输出换行符后结束整个递归过程。
需要注意的是,作为递归出口的无参函数 void printArgs() 必须声明在可变参数函数模板的前面,否则模板内部在最后一步调用 printArgs() 时会因为找不到声明而报错。
在实际开发中,只要项目使用的是 C++17 及以上标准,我们优先选择前面的折叠表达式即可,不仅代码更简短,编译速度也更快。
常见问题
1. 可变参数模板可以用于类模板吗?
可以。可变参数模板不仅能用于函数模板,同样也能用于类模板。定义类模板时,在尖括号里写成 template <typename... Types> class 类名,就能让这个类模板接收 0 个或任意多个不同的类型参数。
2. 函数模板的参数列表里可以同时包含固定参数和可变参数包吗?
可以。就像示例 4 中的 void printArgs(T first, Args... rest) 一样,我们可以在前面放一个或多个固定参数,把可变参数包 Args... rest 放在参数列表的最后面,这样就能要求调用时至少传入指定的固定参数。
