跳至内容

变参模板与折叠表达式

使用参数包处理任意数量的模板参数。掌握折叠表达式以优雅地、无需递归地处理包。

参数包

变参模板使用参数包接受任意数量的模板参数。语法使用省略号(...)。有两种包:

1. 模板参数包 ── template ── 捕获零个或多个类型参数。
2. 函数参数包 ── void f(Ts... args) ── 捕获对应的函数参数。

你不能直接在循环中遍历包。相反,你使用 ... 运算符来展开它。关键要点是包展开将一个模式应用于包的每个元素,并生成一个逗号分隔的列表。

sizeof...(Ts) 和 sizeof...(args) 返回包中元素的数量,类型为 constexpr std::size_t。

递归包展开(旧模式)

在 C++17 折叠表达式之前,处理包的标准方式是递归实例化:剥离第一个元素,处理它,然后对剩余部分递归。这需要一个基本情况来终止递归。

recursive_variadic.cpp
#include <iostream>

// Base case: no arguments
void print() {
    std::cout << '\n';
}

// Recursive case: process first, recurse on rest
template <typename First, typename... Rest>
void print(const First& first, const Rest&... rest) {
    std::cout << first;
    if constexpr (sizeof...(rest) > 0) {
        std::cout << ", ";
    }
    print(rest...);  // expand rest, recurse
}

int main() {
    print(1, 2.5, "hello", 'A');
    // Output: 1, 2.5, hello, A

    print(); // just a newline
}

折叠表达式(C++17)

C++17 折叠表达式在大多数情况下消除了对递归模板的需求。折叠表达式将一个二元运算符应用于参数包的所有元素。有四种形式:

- 一元右折叠:(pack op ...) 展开为 e1 op (e2 op (... op eN))
- 一元左折叠:(... op pack) 展开为 ((e1 op e2) op ...) op eN
- 二元右折叠:(pack op ... op init) ── 包含初始值
- 二元左折叠:(init op ... op pack) ── 包含初始值

fold_expressions.cpp
#include <iostream>
#include <string>

// Sum all arguments — unary left fold with +
template <typename... Ts>
auto sum(Ts... args) {
    return (... + args);  // ((a1 + a2) + a3) + ...
}

// Print all arguments — binary left fold with <<
template <typename... Ts>
void print(Ts&&... args) {
    (std::cout << ... << args) << '\n';
    // expands to: ((std::cout << a1) << a2) << a3 ...
}

// Check if ALL arguments satisfy a predicate — unary left fold with &&
template <typename... Ts>
bool all_positive(Ts... args) {
    return (... && (args > 0));  // (a1>0) && (a2>0) && ...
}

// Push multiple elements to a vector — fold over comma operator
#include <vector>
template <typename T, typename... Ts>
void push_all(std::vector<T>& vec, Ts&&... args) {
    (vec.push_back(std::forward<Ts>(args)), ...);
}

int main() {
    std::cout << sum(1, 2, 3, 4, 5) << '\n'; // 15
    print("x=", 10, " y=", 20);               // x=10 y=20
    std::cout << std::boolalpha
              << all_positive(1, 2, 3)   << '\n'  // true
              << all_positive(1, -2, 3)  << '\n'; // false

    std::vector<int> v;
    push_all(v, 10, 20, 30);
    for (int x : v) std::cout << x << ' ';  // 10 20 30
    std::cout << '\n';
}

包展开上下文

包展开(...)可以出现在许多上下文中,不仅限于函数调用参数:

- 函数参数:f(args...) 或 f(transform(args)...)
- 模板参数:std::tuple
- 基类:class Derived : public Bases... { };
- 初始化列表:auto list = { args... };
- Lambda 捕获:[args...] { } 或 [...args = std::move(args)] { }(C++20 初始化捕获)
- Using 声明(C++17):using Bases::operator()...;

模式是 ... 之前出现的内容。在 f(transform(args)...) 中,模式是 transform(args),它展开为 f(transform(a1), transform(a2), transform(a3))。

理解这一点至关重要:f(args)... 与 f(args...) 不同。前者展开为 f(a1), f(a2), f(a3)(逗号表达式中的三次独立调用),而后者展开为 f(a1, a2, a3)(一次调用带三个参数)。

std::tuple 与 std::apply

std::tuple 是标准库的异构容器,建立在变参模板之上。std::apply(C++17)将元组元素解包为参数来调用可调用对象。std::index_sequence 支持对元组进行编译时索引。

tuple_apply.cpp
#include <tuple>
#include <iostream>
#include <string>
#include <utility>

// std::apply calls a function with tuple elements as arguments
void greet(const std::string& name, int age) {
    std::cout << "Hello " << name << ", age " << age << '\n';
}

// Print all tuple elements using index_sequence
template <typename Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
    ((std::cout << (Is == 0 ? "" : ", ") << std::get<Is>(t)), ...);
}

template <typename... Ts>
void print_tuple(const std::tuple<Ts...>& t) {
    std::cout << '(';
    print_tuple_impl(t, std::index_sequence_for<Ts...>{});
    std::cout << ")\n";
}

int main() {
    auto person = std::make_tuple(std::string("Alice"), 30);
    std::apply(greet, person);  // Hello Alice, age 30

    auto data = std::make_tuple(1, 3.14, std::string("hello"));
    print_tuple(data);  // (1, 3.14, hello)
}
常见陷阱

变参模板最常见的错误:

1. 忘记 ... ── 写 args 而不是 args... 会导致包未展开,产生编译错误。
2. 在错误的位置展开 ── f(g(args)...) 与 f(g(args...)) 含义非常不同。前者为每个元素调用一次 g;后者用所有元素调用一次 g。
3. 空包 ── (... + args) 对于空包在大多数运算符下是格式错误的。使用带初始值的二元折叠:(0 + ... + args)。
4. sizeof... 返回的是计数,不是字节大小 ── sizeof...(args) 给出包元素的数量,而不是它们的合并内存大小。

最佳实践

尽可能优先使用折叠表达式而非递归变参模板。它们更简洁、更易读,且通常编译更快。使用带显式初始值的二元折叠来安全处理空包。将递归展开保留给折叠表达式不够用的场景 ── 例如,当你需要成对处理元素时,或者当处理每个元素需要不同的逻辑时。

关键要点
  • 变参模板使用 typename... Ts(模板参数包)和 Ts... args(函数参数包)
  • sizeof...(args) 在编译时返回包中元素的数量
  • 包展开将模式应用于每个元素:f(transform(args)...) 对每个元素调用 transform
  • 折叠表达式(C++17)取代递归展开:(... + args) 对所有元素求和
  • 使用带初始值的二元折叠来安全处理空包
  • std::apply 将元组解包为函数参数;std::index_sequence 支持编译时索引

测验 — 检验你的知识

(20 经验值)

1. 对于 `args = {a, b, c}`,`(... + args)` 展开为什么?

2. `f(args...)` 和 `f(args)...` 之间有什么区别?

3. `sizeof...(args)` 返回什么?