12. 可变参数模板:参数包的定义与展开,sizeof...运算符,递归展开
可变参数模板,说白了就是让函数或类能接受任意数量的参数。C++11 引入这个特性时,我第一反应是:终于不用再写一堆重载函数了!
你想想看,以前要写一个打印函数,得从 print()、print(int)、print(int, int) 一直写到 print(int, int, int, int, int)。现在有了可变参数模板,一个模板就能搞定所有情况。
参数包的定义
参数包有两种:模板参数包和函数参数包。写法上就是在类型名或参数名前面加个省略号(...)。
// 模板参数包
template<typename... Args>
void func(Args... args); // Args 是模板参数包,args 是函数参数包
// 也可以是非类型模板参数包
template<int... Ns>
void printValues() {
// ...
}
我个人习惯把参数包的名字起得短一点,比如 Args、Ts、Ns 之类的。太长反而影响可读性。
sizeof... 运算符
想知道参数包里有多少个参数?用 sizeof... 运算符。注意,它不是 sizeof,后面跟的是三个点。
template<typename... Args>
void countArgs(Args... args) {
std::cout << "参数个数: " << sizeof...(Args) << std::endl;
// 或者 sizeof...(args) 也行
}
countArgs(1, 2.5, "hello"); // 输出: 参数个数: 3
我在项目中遇到过一个问题:有人把 sizeof... 和 sizeof 搞混了,结果编译报错半天没找到原因。记住,sizeof... 返回的是编译期常量,类型是 std::size_t。
递归展开:最经典的方式
参数包不能直接遍历,得通过递归来展开。基本套路是:写一个处理单个参数的版本,再写一个递归版本,每次从参数包里取出第一个参数,剩下的继续递归。
// 递归终止函数
void print() {
std::cout << std::endl;
}
// 递归展开函数
template<typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first << " ";
print(rest...); // 递归调用,参数包减少一个
}
// 使用
print(1, 2.5, "hello", 'A');
// 输出: 1 2.5 hello A
嗯,这里要注意:递归终止函数必须是无参版本,否则会无限递归下去。我曾经见过有人把终止函数写成 template<typename T> void print(T last),结果最后一个参数打印完后,还会尝试继续递归——因为编译器找不到完全匹配的无参版本。
逗号表达式展开
除了递归,还可以用逗号表达式配合初始化列表来展开参数包。这种方式更简洁,但理解起来稍微绕一点。
template<typename... Args>
void printAll(Args... args) {
// 利用初始化列表展开
int dummy[] = { (std::cout << args << " ", 0)... };
// 展开后相当于:
// int dummy[] = { (cout << 1 << " ", 0), (cout << 2.5 << " ", 0), ... };
std::cout << std::endl;
}
printAll(1, 2.5, "hello");
这里的关键是 (expr, 0)... 这个模式。逗号表达式会先执行 expr,然后返回 0。初始化列表保证了参数按从左到右的顺序求值。dummy 数组其实没实际用途,只是用来触发展开。
C++17 的折叠表达式
C++17 引入了折叠表达式,让参数包展开变得更简单。说白了就是直接对参数包应用二元运算符。
// 一元右折叠
template<typename... Args>
auto sum(Args... args) {
return (args + ...); // 展开为 arg1 + (arg2 + (arg3 + ...))
}
// 一元左折叠
template<typename... Args>
auto sumLeft(Args... args) {
return (... + args); // 展开为 ((arg1 + arg2) + arg3) + ...
}
// 二元折叠(带初始值)
template<typename... Args>
auto sumWithInit(Args... args) {
return (0 + ... + args); // 初始值 0,防止空包时报错
}
std::cout << sum(1, 2, 3, 4); // 输出 10
折叠表达式有四种形式:一元左折叠、一元右折叠、二元左折叠、二元右折叠。实际开发中,我建议优先用二元折叠,因为空参数包时不会编译报错。
- 参数包用 typename... Args 定义,用 args... 展开
- sizeof... 获取参数个数,编译期常量
- 递归展开需要终止函数,否则无限递归
- 逗号表达式展开适合简单场景,注意求值顺序
- C++17 折叠表达式最简洁,推荐优先使用
实际项目中的避坑指南
我曾经在写日志库时踩过一个坑:递归展开时,如果参数类型是引用或 const 引用,需要小心处理转发问题。直接传值会导致拷贝,用 std::forward 可以完美转发。
template<typename T, typename... Args>
void log(T&& first, Args&&... rest) {
std::cout << std::forward<T>(first) << " ";
log(std::forward<Args>(rest)...);
}
另一个常见问题是:参数包展开时,如果参数类型不匹配,编译器会报一堆让人看不懂的错误。我的建议是:先用 sizeof... 打印参数个数,再用 typeid 或 decltype 检查类型,逐步缩小问题范围。
知识结构图
这张图把可变参数模板的核心知识点串起来了。从定义到展开方式,再到辅助工具 sizeof...,每一步都有对应的实现方法。我个人建议初学者先从递归展开入手,理解透了再去看折叠表达式——毕竟折叠表达式虽然简洁,但调试起来不如递归直观。
- 参数包是可变参数模板的核心,用 ... 表示
- sizeof... 获取参数个数,编译期确定
- 递归展开最通用,但别忘了写终止函数
- 逗号表达式展开适合简单场景
- C++17 折叠表达式是未来趋势,建议掌握
好了,可变参数模板的内容就这些。记住一点:参数包展开的本质是编译期展开,不是运行时循环。理解了这个,你就能写出更灵活、更高效的泛型代码。
公众号:蓝海资料掘金营,微信deep3321