C++ 可变参数模板

平时编写打印信息或计算总和的通用函数时,我们往往希望函数既能接收两个参数,又能接收五个甚至更多不同类型的参数。前面学习的普通模板在定义时就已经固定了尖括号里的参数个数,很难直接满足这种需求。

从 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 放在参数列表的最后面,这样就能要求调用时至少传入指定的固定参数。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号