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, ..., argN
  • f(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 知识体系总览

下面这张图总结了变参模板的核心脉络:

变参模板知识体系 变参模板 参数包展开模式 ... 在左边 vs 右边 f(args...) vs f(args)... 逗号表达式展开 递归继承 逐层派生,每层处理一个参数 终止类 + 递归特化 典型:Tuple 实现 折叠表达式 一元左/右折叠 二元左/右折叠 C++17 引入,替代递归 典型应用:Tuple、variant、类型安全 printf、工厂函数

17.6 总结与避坑

变参模板用好了,代码会非常简洁优雅。但有几个坑我提醒一下:

  • 空包展开:如果参数包为空,折叠表达式会编译失败(除非有默认值)。比如 sum() 没有参数时,(args + ...) 无法展开。解决方案是用二元折叠提供初始值:(0 + ... + args)
  • 性能问题:递归继承在编译期展开,运行期没有额外开销。但编译时间会变长,尤其是参数很多时。我有个项目用了 50 个参数的 tuple,编译时间从 3 秒飙到 15 秒。
  • 调试困难:变参模板的错误信息又长又臭。我建议用 static_assertif constexpr 做前置检查,把错误扼杀在编译早期。

我的习惯:写变参模板时,先写一个固定参数的版本,确认逻辑正确后,再改成变参版本。这样能减少一半的调试时间。

好了,变参模板的核心内容就这些。参数包展开是基础,递归继承是经典套路,折叠表达式是现代解法。三者结合,能写出非常强大的泛型代码。


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