17、变参模板:可变参数模板的展开模式,递归继承与折叠表达式
变参模板,说白了就是让模板能接受任意数量的参数。C++11 引入这个特性的时候,我还在一个嵌入式项目里挣扎——那时候为了处理不同数量的参数,得写一堆重载函数,或者用 va_list 那套 C 风格的东西。嗯,现在回想起来,那真是刀耕火种的年代。
这一章我们重点聊三个东西:参数包的展开模式、递归继承的套路、以及 C++17 带来的折叠表达式。我个人觉得,这三个点掌握了,变参模板你就吃透了八成。
17.1 参数包与展开基础
先看一个最简单的例子。假设我们要打印任意数量的参数:
template<typename... Args>
void print(Args... args) {
// 这里的 args 就是一个参数包
// 展开方式:使用逗号表达式
((std::cout << args << " "), ...);
std::cout << std::endl;
}
int main() {
print(1, 2.5, "hello", 'c');
// 输出:1 2.5 hello c
}
这里 Args... 是模板参数包,args... 是函数参数包。展开的时候,... 的位置决定了展开模式。我刚开始学的时候,总搞不清 ... 放左边还是右边。其实记住一条:... 在左边是「从左到右展开」,在右边是「从右到左展开」。不过 C++17 的折叠表达式统一了写法,后面会讲。
展开模式的核心规则:
args...展开为arg1, arg2, ..., argNf(args)...展开为f(arg1), f(arg2), ..., f(argN)f(args...)展开为f(arg1, arg2, ..., argN)
我曾经在代码 review 时看到有人把 f(args...) 和 f(args)... 搞混,结果编译过了但运行结果完全不对。你想想看,一个是把整个包传给一个函数,另一个是对每个元素分别调用函数,差别大了去了。
17.2 递归继承:一种经典的展开手法
在 C++17 之前,没有折叠表达式的时候,我们怎么处理参数包?递归继承是一个很常见的套路。说白了,就是让每个参数对应一个基类,然后层层继承下去。
// 终止类
struct TupleEnd {};
// 递归继承的 tuple 实现
template<typename... Types>
class Tuple;
template<typename First, typename... Rest>
class Tuple<First, Rest...> : public Tuple<Rest...> {
public:
Tuple(First f, Rest... r) : Tuple<Rest...>(r...), value(f) {}
First value;
};
template<>
class Tuple<> : public TupleEnd {}; // 空包特化
// 使用
Tuple<int, double, char> t(1, 2.5, 'a');
这个模式我在一个序列化库中用过。每个基类负责处理一种类型的数据,递归展开后,所有类型的数据都能被正确存储。嗯,这里要注意:递归深度受编译器限制,一般几百层没问题,但别搞成千上万层。
个人经验:递归继承的代码写起来很爽,但调试起来很痛苦。因为编译器报错时,错误信息会展开成一大串模板实例化路径。我建议在递归基类里加一些 static_assert 做前置检查,能省很多 debug 时间。
17.3 折叠表达式:C++17 的优雅解法
C++17 引入了折叠表达式,终于不用再写递归了。折叠表达式有四种形式:
| 形式 | 语法 | 示例(展开结果) |
|---|---|---|
| 一元右折叠 | (pack op ...) |
(arg1 op (arg2 op (... op argN))) |
| 一元左折叠 | (... op pack) |
(((arg1 op arg2) op ...) op argN) |
| 二元右折叠 | (pack op ... op init) |
(arg1 op (arg2 op (... op (argN op init)))) |
| 二元左折叠 | (init op ... op pack) |
((((init op arg1) op arg2) op ...) op argN) |
举个例子,求和函数:
template<typename... Args>
auto sum(Args... args) {
return (args + ...); // 一元右折叠
// 等价于:arg1 + (arg2 + (arg3 + ...))
}
template<typename... Args>
auto sum_left(Args... args) {
return (... + args); // 一元左折叠
// 等价于:((arg1 + arg2) + arg3) + ...
}
int main() {
std::cout << sum(1, 2, 3, 4) << std::endl; // 10
std::cout << sum_left(1, 2, 3, 4) << std::endl; // 10
}
对于加法来说,左右折叠结果一样。但如果是减法就不一样了:
std::cout << (1 - (2 - (3 - 4))) << std::endl; // 右折叠:-2
std::cout << (((1 - 2) - 3) - 4) << std::endl; // 左折叠:-8
避坑指南:我曾经在写一个配置解析器时,用了右折叠处理字符串拼接,结果顺序反了。因为右折叠是从右往左结合,字符串拼接时会把后面的内容拼到前面,导致输出顺序颠倒。后来改成左折叠才解决。所以,如果你的操作不满足交换律,一定要想清楚用左折叠还是右折叠。
17.4 实战:用折叠表达式实现类型安全的 printf
来一个综合例子。我们实现一个简单的类型安全 printf,不用 va_list:
template<typename... Args>
void safe_printf(const char* format, Args... args) {
// 用折叠表达式遍历参数包
size_t idx = 0;
auto print_one = [&](const auto& arg) {
while (*format) {
if (*format == '%' && *(format + 1) == 'd') {
std::cout << arg;
format += 2;
return;
}
if (*format == '%' && *(format + 1) == 's') {
std::cout << arg;
format += 2;
return;
}
std::cout << *format++;
}
};
// 对每个参数调用 print_one
(print_one(args), ...);
// 打印剩余格式字符串
while (*format) {
std::cout << *format++;
}
std::cout << std::endl;
}
int main() {
safe_printf("int: %d, str: %s, end", 42, "hello");
// 输出:int: 42, str: hello, end
}
这里用了逗号表达式配合折叠:(print_one(args), ...) 会依次对每个参数调用 print_one。注意逗号表达式保证从左到右求值,所以参数顺序是安全的。
17.5 知识体系总览
下面这张图总结了变参模板的核心脉络:
17.6 总结与避坑
变参模板用好了,代码会非常简洁优雅。但有几个坑我提醒一下:
- 空包展开:如果参数包为空,折叠表达式会编译失败(除非有默认值)。比如
sum()没有参数时,(args + ...)无法展开。解决方案是用二元折叠提供初始值:(0 + ... + args)。 - 性能问题:递归继承在编译期展开,运行期没有额外开销。但编译时间会变长,尤其是参数很多时。我有个项目用了 50 个参数的 tuple,编译时间从 3 秒飙到 15 秒。
- 调试困难:变参模板的错误信息又长又臭。我建议用
static_assert和if constexpr做前置检查,把错误扼杀在编译早期。
我的习惯:写变参模板时,先写一个固定参数的版本,确认逻辑正确后,再改成变参版本。这样能减少一半的调试时间。
好了,变参模板的核心内容就这些。参数包展开是基础,递归继承是经典套路,折叠表达式是现代解法。三者结合,能写出非常强大的泛型代码。
公众号:蓝海资料掘金营,微信deep3321