C++ Template: 可变参数模板 Variadic Templates

整理可变参数模板、参数包展开、折叠表达式和变长基类的常见写法。

可变参数模板 Variadic Templates 是 C++11 引入的一种特性, 它允许模板接收可变数量的模板参数或函数参数, 这使得编写灵活、通用的代码变得更加容易

可变参数模板使用 ... (省略号) 表示可以接受多个参数, 这些参数可以是 模板参数函数参数

主要有两种形式:

  1. 模板参数包: 定义在模板参数列表中, 例如 typename... Args

  2. 函数参数包: 定义在函数参数列表中, 例如 Args... args

函数模板中的可变参数

用可变参数模板编写接受多个参数的函数

#include <iostream>
 
template<typename... Args>
void print(Args... args) {
    (std::cout << ... << args) << '\n'; // 使用折叠表达式
}
 
int main() {
    print(1, 2, 3, "Hello", 4.5); // 输出:123Hello4.5
    return 0;
}

这里, Args... args函数参数包, 它包含了传入的所有参数

类模板中的可变参数

可变参数也可以应用于类模板

template<typename... Args>
class Tuple {
    // 可变参数包存储为类型
};
 
Tuple<int, double, std::string> t; // 包含多个模板参数

递归展开可变参数包

在不使用折叠表达式的情况下, 可以递归处理参数包

#include <iostream>
 
// 递归函数模板
template<typename T, typename... Rest>
void print(T first, Rest... rest) {
    std::cout << first << " ";
    print(rest...); // 递归调用,展开剩余参数
}
 
// 基本情况:参数包为空时的函数
void print() {
    std::cout << '\n';
}
 
int main() {
    print(1, 2, 3, "Hello", 4.5); // 输出:1 2 3 Hello 4.5
    return 0;
}

折叠表达式 Fold Expressions

折叠表达式 Fold Expressions 是C++17引入的一个新特性, 用于简化对可变参数模板的操作, 它允许我们通过二元操作符对参数包中的所有参数进行操作, 并可以选择一个初始值

无初始值的折叠

  • 左折叠 ( ... op pack )

    ((pack1 op pack2) op pack3) op ... op packN
    template<typename... T>
    auto leftFold(T... args) {
        return (... + args); // 左折叠
    }
     
    leftFold(1, 2, 3, 4); // 等价于 ((1 + 2) + 3) + 4
  • 右折叠 ( pack op ... )

    pack1 op (pack2 op (pack3 op ... op packN))
    template<typename... T>
    auto rightFold(T... args) {
        return (args + ...); // 右折叠
    }
     
    rightFold(1, 2, 3, 4); // 等价于 1 + (2 + (3 + 4))

带初始值的折叠

  • 左折叠 ( init op ... op pack )

    (((init op pack1) op pack2) op pack3) op ... op packN
    template<typename... T>
    auto leftFoldWithInit(T... args) {
        return (0 + ... + args); // 左折叠,初始值为 0
    }
     
    leftFoldWithInit(1, 2, 3, 4); // 等价于 (((0 + 1) + 2) + 3) + 4
  • 右折叠 ( pack op ... op init )

    pack1 op (pack2 op (pack3 op ... op (packN op init)))
    template<typename... T>
    auto rightFoldWithInit(T... args) {
        return (args + ... + 0); // 右折叠,初始值为 0
    }
     
    rightFoldWithInit(1, 2, 3, 4); // 等价于 1 + (2 + (3 + (4 + 0)))

实例

简单的加法折叠

template<typename... T>
auto foldSum(T... s) {
    return (... + s); // 左折叠
}

调用

int sum = foldSum(1, 2, 3, 4); // 结果为 10

二叉树路径遍历
可以使用折叠表达式来遍历二叉树的路径

struct Node {
    int value;
    Node* left;
    Node* right;
    Node(int i=0) : value(i), left(nullptr), right(nullptr) {}
};
 
auto left = &Node::left;
auto right = &Node::right;
 
template<typename T, typename... TP>
Node* traverse(T np, TP... paths) {
    return (np ->* ... ->* paths); // 使用折叠表达式遍历路径
}
 
int main() {
    Node* root = new Node{0};
    root->left = new Node{1};
    root->left->right = new Node{2};
 
    Node* node = traverse(root, left, right); // 遍历到 root->left->right
}

带空格的打印函数
默认的折叠表达式不会在参数之间添加空格, 为了解决这个问题, 我们可以创建一个辅助类 AddSpace

template<typename T>
class AddSpace {
private:
    T const& ref;
public:
    AddSpace(T const& r) : ref(r) {}
    
    friend std::ostream& operator<<(std::ostream& os, AddSpace<T> s) {
        return os << s.ref << ' ';
    }
};
 
template<typename... Args>
void print(Args... args) {
    (std::cout << ... << AddSpace(args)) << '\n';
}
 
int main() {
    print(1, 2, 3, "Hello"); // 输出: "1 2 3 Hello "
}

AddSpace 类使用了 类模板参数推导 Class Template Argument Deduction, 以简化类型的推导过程

特殊情况

对于空参数包

  • && 操作符返回 true
  • || 操作符返回 false
  • , 操作符返回 void()

模板参数包 和 函数参数包

模板参数包函数参数包 互相关联的, 模板参数包负责推导类型, 而函数参数包负责接收参数值, 可以理解为, 模板参数包定义了类型的可变性, 而函数参数包实现了具体值的操作

使用模板参数包

模板参数包用在类模板或函数模板的定义中

类型计数器
模板参数包用于处理类型信息

#include <iostream>
#include <type_traits>
 
template<typename... Types>
struct TypeCounter {
    static constexpr std::size_t count = sizeof...(Types);
};
 
int main() {
    std::cout << "Number of types: " << TypeCounter<int, double, char>::count << '\n';
    return 0;
}

输出

Number of types: 3

静态断言类型一致性 有时, 我们可能只用模板参数包来处理类型, 而函数参数并不涉及参数包

#include <iostream>
#include <type_traits>
 
template<typename T, typename... Args>
void checkTypes() {
    static_assert((std::is_same_v<T, Args> && ...), "All types must be the same!");
}
 
int main() {
    checkTypes<int, int, int>(); // 正常编译
    // checkTypes<int, double, int>(); // 编译错误:类型不一致
    return 0;
}

同时使用模板参数包和函数参数包

函数参数包在没有模板参数包的基础下使用, 例如函数重载或递归处理

递归打印

#include <iostream>
 
// 使用函数参数包处理变长参数
void print() {
    std::cout << "End of parameters.\n";
}
 
template<typename T, typename... Rest>
void print(T first, Rest... rest) {
    std::cout << first << " ";
    print(rest...); // 递归调用
}
 
int main() {
    print(1, 2.5, "hello", 'c'); // 输出:1 2.5 hello c End of parameters.
    return 0;
}

函数参数包无需模板参数推导

当函数模板有显式的模板参数时, 函数参数包并不依赖模板参数推导

类型已知但参数个数未知

#include <iostream>
 
template<typename T>
void printArgs(T arg) {
    std::cout << arg << '\n';
}
 
template<typename T>
void printAllArgs(T firstArg, ...) { // 使用变长参数
    printArgs(firstArg);
}
 
int main() {
    printAllArgs<int>(42, 3.14, "hello"); // 只打印 42,因为不展开参数
    return 0;
}

这种用途可能有限, 因为它不会执行参数展开

变长基类 Variadic Base Classes

派生类 不会自动继承基类的 () 运算符 (或其他 非虚成员函数), 除非显式使用 using 声明将基类的 operator() 引入派生类的作用域, 这是 C++ 的一个语言设计特性, 用于避免潜在的命名冲突

可以使用可变参数模板定义 变长基类, 然后通过 using 声明将基类的成员 (如 operator()) 引入派生类, 这种技术非常有用, 尤其在需要组合多个功能时,例如实现策略模式或函数对象的组合

例如有以下几个基础类

Customer: 表示客户, 包含一个 name 字段及其访问器

class Customer {
private:
    std::string name;
 
public:
    Customer(std::string const& n) : name(n) {}
    std::string getName() const { return name; }
};

CustomerEq: 用于比较两个 Customer 对象是否相等

struct CustomerEq {
    bool operator()(Customer const& c1, Customer const& c2) const {
        return c1.getName() == c2.getName();
    }
};

CustomerHash: 用于计算 Customer 对象的哈希值

struct CustomerHash {
    std::size_t operator()(Customer const& c) const {
        return std::hash<std::string>()(c.getName());
    }
};

变长基类的定义

为了将 CustomerHashCustomerEqoperator() 同时引入一个类中, 我们定义了 Overloader 模板类, 它可以继承任意数量的基类, 并将这些基类的 operator() 引入到派生类

template<typename... Bases>
struct Overloader : Bases... {
    using Bases::operator()...; // C++17: 将所有基类的 operator() 引入当前类
};
  • 继承所有基类: Overloader 使用可变参数模板继承了任意数量的基类
  • 引入基类的成员: using Bases::operator()...; 将每个基类的 operator() 声明引入当前作用域

组合 CustomerHashCustomerEq

using CustomerOP = Overloader<CustomerHash, CustomerEq>;

CustomerOP 是一个同时从 CustomerHashCustomerEq 派生的类, 并且具有两个 operator()

  • 一个用于哈希计算 (来自 CustomerHash)
  • 一个用于相等性比较 (来自 CustomerEq)

用于 std::unordered_set

std::unordered_set 是 C++ 标准库中的哈希表实现, 模板参数定义如下

template<
    typename Key,                              // 键的类型
    typename Hash = std::hash<Key>,            // 哈希函数,默认是 std::hash<Key>
    typename KeyEqual = std::equal_to<Key>,    // 相等比较函数,默认是 std::equal_to<Key>
    typename Allocator = std::allocator<Key>   // 内存分配器,默认是 std::allocator<Key>
>
class unordered_set;

从定义中可以看到:

  1. Key: 键的类型, 即集合中存储的元素类型

  2. Hash: 用于计算键的哈希值的类型 (必须是一个可调用对象, 通常实现 operator())

  3. KeyEqual: 用于判断两个键是否相等的类型 (必须是一个可调用对象, 通常实现 operator())

  4. Allocator: 分配器类型 (通常使用默认值即可)

我们可以将 CustomerOP 用作哈希函数和比较函数的类型

std::unordered_set<Customer, CustomerHash, CustomerEq> coll1; // 使用单独的哈希和比较
std::unordered_set<Customer, CustomerOP, CustomerOP> coll2;  // 使用组合的功能对象
  • coll1: 需要分别传入 CustomerHash (提供 operator(), 用于计算 Customer 对象的哈希值) 和 CustomerEq (提供 operator(), 用于比较两个 Customer 是否相等)
  • coll2: 只需传入 CustomerOP, 因为它同时具备哈希和比较的能力

完整代码

#include <string>
#include <unordered_set>
#include <iostream>
 
// 定义 Customer 类
class Customer {
private:
    std::string name;
 
public:
    Customer(std::string const& n) : name(n) {}
    std::string getName() const { return name; }
};
 
// 比较器:比较两个 Customer 对象是否相等
struct CustomerEq {
    bool operator()(Customer const& c1, Customer const& c2) const {
        return c1.getName() == c2.getName();
    }
};
 
// 哈希器:计算 Customer 对象的哈希值
struct CustomerHash {
    std::size_t operator()(Customer const& c) const {
        return std::hash<std::string>()(c.getName());
    }
};
 
// 定义 Overloader 模板类
template<typename... Bases>
struct Overloader : Bases... {
    using Bases::operator()...; // 将所有基类的 operator() 引入作用域
};
 
int main() {
    // 定义组合的功能对象类型
    using CustomerOP = Overloader<CustomerHash, CustomerEq>;
 
    // 使用单独的哈希和比较
    std::unordered_set<Customer, CustomerHash, CustomerEq> coll1;
 
    // 使用组合后的功能对象
    std::unordered_set<Customer, CustomerOP, CustomerOP> coll2;
 
    coll2.emplace("Alice");
    coll2.emplace("Bob");
 
    Customer c("Alice");
    if (coll2.find(c) != coll2.end()) {
        std::cout << c.getName() << " is found!" << std::endl;
    }
 
    return 0;
}

输出

Alice is found!

总结

在 C++ 中, 继承基类的 operator() 并不能自动将其引入派生类的作用域 (尤其是模板基类), 为了能够直接在派生类中使用基类的 operator(), 需要显式声明 using

using Bases::operator()...; // 将所有基类的 operator() 引入当前类

通过使用 Overloader, 将多个功能对象合并为一个类, 无需手动实现多个 operator(), 通过可变参数模板, 可以组合任意数量和类型的功能对象

using Bases::operator()...;C++17 的特性, 允许使用 折叠表达式 将多个基类的成员引入当前作用域