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。

小技巧:sizeof... 可以用于模板参数包,也可以用于函数参数包,结果是一样的。我个人更倾向于用 sizeof...(Args),因为语义更清晰。

递归展开:最经典的方式

参数包不能直接遍历,得通过递归来展开。基本套路是:写一个处理单个参数的版本,再写一个递归版本,每次从参数包里取出第一个参数,剩下的继续递归。

// 递归终止函数
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 检查类型,逐步缩小问题范围。

知识结构图

可变参数模板知识结构 可变参数模板 参数包定义 模板参数包 typename... Args 函数参数包 Args... args 参数包展开方式 递归展开(需终止函数) 逗号表达式展开 折叠表达式(C++17) sizeof... 运算符

这张图把可变参数模板的核心知识点串起来了。从定义到展开方式,再到辅助工具 sizeof...,每一步都有对应的实现方法。我个人建议初学者先从递归展开入手,理解透了再去看折叠表达式——毕竟折叠表达式虽然简洁,但调试起来不如递归直观。

总结一下:
  • 参数包是可变参数模板的核心,用 ... 表示
  • sizeof... 获取参数个数,编译期确定
  • 递归展开最通用,但别忘了写终止函数
  • 逗号表达式展开适合简单场景
  • C++17 折叠表达式是未来趋势,建议掌握

好了,可变参数模板的内容就这些。记住一点:参数包展开的本质是编译期展开,不是运行时循环。理解了这个,你就能写出更灵活、更高效的泛型代码。


公众号:蓝海资料掘金营,微信deep3321