C++ 可变参数模板

C++ 中的可变参数模板 (Variadic Templates)是 C++11 引入的最重要和最强大的特性之一,是泛型编程的基石。它允许我们创建可以接受任意数量、任意类型参数的函数模板或类模板。它以一种完全类型安全、在编译期展开的方式,解决了传统 C 语言中可变参数(如 printf)的类型安全问题。

可变参数模板之前的可变参数编程

在 C++11 引入可变参数模板之前,如果我们想实现一个接受可变数量参数的函数,通常有两种方式:

A. C 风格的可变参数 (<cstdarg>)

最典型的例子就是 printfscanf

1
2
3
4
5
6
7
8
9
10
11
#include <cstdarg>
#include <cstdio>

void C_style_log(const char* format, ...) {
va_list args;
va_start(args, format); // 初始化
vprintf(format, args); // 使用
va_end(args); // 清理
}

// 调用: C_style_log("Log entry: %s %d\n", "hello", 42);

这种方式有几个致命的缺陷

  1. 非类型安全:编译器不会检查传入的参数类型是否与格式化字符串匹配。C_style_log("%d %d", "hello", "world"); 这样的代码可以编译通过,但在运行时会导致未定义行为。
  2. 无法处理非 POD 类型:对于拥有构造函数、析构函数的 C++ 对象(例如 std::string),直接通过 va_arg 传递和获取是危险且通常不可行的。
  3. 笨拙的语法:需要手动调用 va_start, va_arg, va_end 等宏,容易出错。

B. 函数重载

我们可以为不同数量的参数编写多个重载版本。

1
2
3
4
5
void my_func() { /* ... */ }
void my_func(int a) { /* ... */ }
void my_func(int a, double b) { /* ... */ }
void my_func(int a, double b, const std::string& c) { /* ... */ }
//......

这种方式虽然类型安全,但显然扩展性极差。我们无法穷举所有可能的参数组合,代码冗余且难以维护。

C++11 的解决方案:

为了解决上述问题,C++11 引入了可变参数模板。其核心目标是:在编译期,以一种类型安全、高度泛化的方式处理任意数量的任意类型参数。


可变参数模板

可变参数模板引入了两个关键概念:模板参数包 (Template Parameter Pack)函数参数包 (Function Parameter Pack)。它们都使用省略号 ... 来表示。

1
2
3
4
5
6
// 模板参数包 (typename... Args)
template<typename... Args>
// 函数参数包 (Args... args)
void some_function(Args... args) {
// ... 函数体 ...
}
  • typename... Args:这里的 Args 不是一个单一的类型,而是一个类型的集合,我们称之为“模板参数包”。它可以包含 0个或多个类型。
  • Args... args:这里的 args 也不是一个单一的变量,而是由多个参数组成的函数参数包

问题的关键在于我们如何使用 args 这个参数包。它不能像普通数组一样用 for 循环遍历,因为它的元素数量和类型要在编译的时候才确定。为此,C++新增了**包展开 (Pack Expansion)**的语法规则。我们通过 ... 将参数包“展开”成独立的元素。它是理解和使用可变参数模板的核心。

包展开

包展开是 C++ 编译器在编译期执行的一种操作。它不是一个函数,也不是一个类,而是一个语法规则。作用是将一个模式 (Pattern) 应用到参数包的每一个元素上,然后生成一串由逗号分隔的列表。

核心语法

包展开的语法非常简洁:

pattern...

这里有两个关键部分:

  1. pattern (模式)
    这是一个包含至少一个模板参数包函数参数包的 C++ 表达式或声明。这个模式定义了我们想对包中每一个元素做什么样的操作。
    例如:

    • f(args) (将每个参数传递给函数 f)
    • std::forward<Args>(args) (对每个参数进行完美转发)
    • T* (将每个类型 T 变成 T*)
    • static_cast<T>(v) (将每个类型 T 用于类型转换)
  2. ... (省略号)
    这是“展开运算符”。当它跟在模式后面时,它指示编译器:“请把左边的这个 pattern 复制多份,每一份对应参数包中的一个元素,并将它们用逗号 , 分隔开。”

示例

让我们用一个简单的例子来形象化这个过程:

1
2
3
4
template<typename... Ts>
void my_printer(Ts... args) {
another_function(some_function(args)...);
}

当我们调用 my_printer(10, 'a', 3.14); 时:

  1. 编译器识别出参数包 args 包含三个元素:10 (int), 'a' (char), 3.14 (double)。
  2. 它看到包展开 some_function(args)...
  3. 模式some_function(args)
  4. 编译器开始“盖戳”:
    • 第一个元素 10 -> 应用模式 -> some_function(10)
    • 第二个元素 'a' -> 应用模式 -> some_function('a')
    • 第三个元素 3.14 -> 应用模式 -> some_function(3.14)
  5. 最后,用逗号将它们连接起来,得到一串列表:some_function(10), some_function('a'), some_function(3.14)
  6. 这串列表被替换回原来的位置,最终 another_function 的调用就变成了:
    another_function(some_function(10), some_function('a'), some_function(3.14));

整个过程完全在编译期完成。

包展开主要有三种主流的使用方式:递归函数模板、初始化列表展开,以及 C++17 引入的折叠表达式。

递归函数模板

这是最基础、最经典的包展开方式。思路是:定义一个处理“一个参数”和“多个参数”的模板。在处理多个参数的版本中,我们先处理第一个参数,然后将剩余的参数包递归地传给自己,直到参数包为空。

它巧妙地回避了单次展开所有元素, 只处理第一个元素,然后利用函数参数列表的包展开 func(rest...),将剩余的包传递给下一次递归调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

// 递归的终止条件:当参数包中只剩最后一个参数时,调用此版本
void print(const T& arg) {
std::cout << arg << std::endl; // 打印最后一个参数并换行
}

// 递归步骤:处理第一个参数,并将剩余参数包传给下一次调用
template<typename T, typename... Args>
void print(const T& firstArg, const Args&... args) {
std::cout << firstArg << ", "; // 打印当前参数 输出 1,
print(args...); // 递归调用,参数包 `args` 被展开并传递, 也就是print("hello",3.14,'a')
}

int main() {
print(1, "hello", 3.14, 'a');
// 输出: 1, hello, 3.14, a
}

当编译器看到 print(1, "hello", 3.14, 'a') 时,它会进行如下的模板实例化:

  1. print<int, const char*, double, char>(1, "hello", 3.14, 'a')
    • 打印 1,
    • 调用 print<const char*, double, char>("hello", 3.14, 'a')
  2. print<const char*, double, char>("hello", 3.14, 'a')
    • 打印 "hello",
    • 调用 print<double, char>(3.14, 'a')
  3. print<double, char>(3.14, 'a')
    • 打印 3.14,
    • 调用 print<char>('a')
  4. print<char>('a')
    • 匹配到递归终止的非可变参数版本 void print(const T& arg)
    • 打印 'a' 并换行,递归结束。

这个递归展开的过程在编译期完成,编译器会为我们生成一系列函数调用链,是完全类型安全的。

sizeof... 运算符

我们可以使用 sizeof...(Args)sizeof...(args) 来获取参数包中的元素数量。

1
2
3
4
5
template<typename... Args>
void count_args(Args... args) {
std::cout << "Number of arguments: " << sizeof...(args) << std::endl;
}
// count_args(1, 2, 3); // 输出: Number of arguments: 3

初始化列表展开

初始化列表展开的核心思想是利用C++ 保证初始化列表中的元素会按顺序求值的规则,将我们真正想执行的操作(通常是带副作用的函数调用)藏在逗号表达式的左边,以此来“欺骗”编译器为我们展开参数包。

1
2
3
4
template<typename... Args>
void some_function(Args... args) {
int dummy[] = { (expression(args), 0)... };
}
  1. expression(args)
    这是我们想对参数包中每一个参数 arg 执行的操作。例如,在 print 函数里,这个表达式就是 (std::cout << arg << ", ")

  2. (expression(args), 0)
    这里使用了逗号操作符。它首先执行 expression(args)(也就是打印我们的参数),然后逗号操作符会丢弃这个表达式的结果,并返回 0。所以,无论 expression(args) 返回什么类型(std::cout << ... 返回的是 std::ostream&),整个 (..., 0) 表达式的结果永远是 int 类型的 0

  3. { ... }...
    这是包展开的核心部分。... 会将 {} 中的模式 (expression(args), 0) 应用到参数包 args 的每一个元素上。
    假如我们调用 some_function(a, b, c),那么 { (expression(args), 0)... } 会被展开成:
    { (expression(a), 0), (expression(b), 0), (expression(c), 0) }
    由于逗号操作符的作用,这个初始化列表最终会变成 {0, 0, 0}

  4. int dummy[] = ...;
    我们创建了一个临时的 int 数组,就叫 dummy(哑变量)吧,用上面生成的 {0, 0, 0} 来初始化它。这个数组本身没有任何用处,我们只是需要一个“语法容器”来触发初始化列表的求值。因为 C++ 语言保证,为了初始化这个数组,列表中的每个表达式都必须被按顺序求值。

    为了避免编译器因为 dummy 变量未使用而发出警告,可以将其转换为 (void)dummy;

示例:使用初始化列表展开实现 print

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

template<typename... Args>
void print_init_list(const Args&... args) {
int dummy[] = { (std::cout << args << ", ", 0)... };
(void)dummy; // 避免 "unused variable" 警告
std::cout << std::endl;
}

int main() {
print_init_list(1, "world", 4.2); // 输出: 1, world, 4.2,
}

折叠表达式(C++17)

C++17 引入了折叠表达式,它本身就是一种特殊的包展开形式,专门用于将一个二元运算符(如 +, &&, ,)应用到整个参数包上,极大地简化了对参数包的二元操作。

语法是在 ... 的一侧或两侧放一个二元操作符(如 +, -, *, /, &&, ||, , 等)。

折叠表达式分为一元折叠二元折叠两类,它们各自又分为左折叠右折叠,展开方式各不相同。

  • 一元折叠
    • 一元右折叠 (Unary Right Fold): (pack op ...)
      展开为: (E1 op (E2 op (E3 op ... En)))
      括号是从右边开始结合的。

    • 一元左折叠 (Unary Left Fold): (... op pack)
      展开为: (((E1 op E2) op E3) op ... En)
      括号是从左边开始结合的。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

template<typename... Args>
auto sum(Args... args) {
// ((arg1 + arg2) + arg3) + ...
return (args + ...); // 一元右折叠
}

int main() {
std::cout << sum(1, 2, 3, 4, 5) << std::endl; // 输出: 15
std::cout << sum(1, 2.5, 3) << std::endl; // 输出: 6.5
std::cout << sum() << std::endl; // 编译错误 (通常空包对+无效)
// 但 (args + ... + 0) 可以解决
}
  • 二元折叠
    • 二元右折叠 (Binary Right Fold): (pack op ... op init)
      展开为:(E1 op (E2 op (... op (En op init))))
      从右边开始,最后一个元素先和初始值 init 操作。

    • 二元左折叠 (Binary Left Fold): (init op ... op pack)
      展开为:((((init op E1) op E2) op E3) op ... En)
      从左边开始,初始值 init 先和第一个元素操作。

    • 也就是说,对于二元折叠,需要一个初始值来启动链式调用

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

template<typename... Args>
void print(const Args&... args) {
// 展开成: (std::cout << arg1 ) << arg2 ...
(std::cout << ... << args) << '\n';

// 如果想加分隔符,可以这样:
// 展开成:((std::cout << arg1 << ", "), std::cout << arg2 << ", "), ...
// 这种方式会在最后也输出一个 ", "
( (std::cout << args << ", "), ... );
std::cout << std::endl;
}

int main() {
print(1, "hello", 3.14, 'a');
// 输出: 1, hello, 3.14, a
}

注意事项

对于满足结合律的操作符来说,左折叠和右折叠没有区别,但是对于不满足结合律的操作符,例如减法-和除法/,不同的折叠方式结果会截然不同。
此外,对于逻辑操作符&&||,由于C++短路求值的特性,不同的折叠方向会导致不同结果

  • 所谓的短路求值,即
    • 对于 A && B,如果 Afalse,则 B 不会被求值
    • 对于 A || B,如果 Atrue,则 B 不会被求值

假设我们有一个检查函数,它在检查时会打印信息:

1
2
3
4
bool check(const char* name, bool result) {
std::cout << "Checking " << name << "...\n";
return result;
}

现在我们用它和参数包 check("A", true), check("B", false), check("C", true) 来折叠:

  • 一元左折叠 (... && args)

    • 展开为 (check("A", true) && check("B", false)) && check("C", true)
    • 求值顺序:
      1. check("A", true) 被调用,返回 true
      2. check("B", false) 被调用,返回 false
      3. 此时 (true && false) 的结果是 false。根据短路规则,&& 右边的表达式 check("C", true) 将不会被求值
    • 输出:
      1
      2
      Checking A...
      Checking B...
  • 一元右折叠 (args && ...)

    • 展开为 check("A", true) && (check("B", false) && check("C", true))
    • 求值顺序:
      1. 为了计算最外层 && 的值,编译器需要先计算右边的子表达式 (check("B", false) && check("C", true))
      2. 为了计算这个子表达式,check("B", false) 被调用,返回 false
      3. 根据短路规则,check("C", true) 不会被求值。子表达式结果为 false
      4. 现在表达式变为 check("A", true) && falsecheck("A", true) 被调用。
    • 注意:尽管 C++ 标准规定了 A && BA 先于 B求值,但在复杂的嵌套表达式 f() && (g() && h()) 中,g()h() 的求值顺序是相对于 f() 的。关键在于,为了评估顶层 &&,右侧的整个括号 (g() && h()) 必须先被求值。这意味着右折叠破坏了从左到右的自然短路流程
    • 实际输出 (常见编译器如 GCC/Clang):
      1
      2
      Checking B...
      Checking A...
      (或者 AB 之前,但关键是 C 永远不会被检查)

可变参数模板与完美转发

可变参数模板经常与完美转发结合使用,这是它们最核心的应用场景之一,主要用于编写工厂函数(如 std::make_unique, std::make_shared)和容器的 emplace 系列方法。

假设我们要写一个工厂函数 createObject,它接受任意参数,并用这些参数去构造一个 T 类型的对象。我们希望传递给 createObject 的参数,能以其原始的值类别(左值或右值)被完美地转发给 T 的构造函数。

  • 如果传给 createObject 的是右值(临时对象),那么 T 的移动构造函数应该被调用。
  • 如果传给 createObject 的是左值(具名变量),那么 T 的拷贝构造函数应该被调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <utility> // for std::forward

struct Widget {
Widget(int&, double&&) {
std::cout << "Constructor with (int&, double&&)\n";
}
Widget(const Widget&) {
std::cout << "Copy constructor\n";
}
Widget(Widget&&) {
std::cout << "Move constructor\n";
}
};

// 工厂函数
template<typename T, typename... Args>
T createObject(Args&&... args) {
// Args&&... args 是转发引用
// std::forward<Args>(args)... 将参数以原始值类别转发
return T(std::forward<Args>(args)...);
}

int main() {
int i = 42;
createObject<Widget>(i, 3.14); // i是左值, 3.14是右值

Widget w(i, 1.0);
createObject<Widget>(w); // w是左值,调用拷贝构造
createObject<Widget>(std::move(w)); // w被转为右值,调用移动构造
}

输出:

1
2
3
Constructor with (int&, double&&)
Copy constructor
Move constructor
  1. template<typename... Args> void func(Args&&... args):这里的 Args&& 是一个万能引用。当传入左值时,Args 被推导为左值引用类型(如 int&);当传入右值时,Args 被推导为普通类型(如 double)。
  2. std::forward<Args>(args)...:这是一个条件性的 std::move,因为在createObject函数内部,输入的参数不管原始实参是左值还是右值,它都是具名,也就是左值,所以我们需要多这一步。
    • 如果 args 的原始实参是右值,std::forward 会将其转换为右值。
    • 如果 args 的原始实参是左值,std::forward 会保持其左值属性。
  3. ... 的两次使用:
    • Args&&... args:将 Args 参数包中的每个类型 T 都变成 T&&,并应用到函数参数包 args
    • std::forward<Args>(args)...:将 std::forward 应用到参数包 args 的每一个元素上。

C++ 可变参数模板
https://guts.homes/2025/08/12/cpp-Variadic-Templates/
作者
guts
发布于
2025年8月12日
许可协议