C++ 可变参数模板
C++ 中的可变参数模板 (Variadic Templates)是 C++11 引入的最重要和最强大的特性之一,是泛型编程的基石。它允许我们创建可以接受任意数量、任意类型参数的函数模板或类模板。它以一种完全类型安全、在编译期展开的方式,解决了传统 C 语言中可变参数(如 printf)的类型安全问题。
可变参数模板之前的可变参数编程
在 C++11 引入可变参数模板之前,如果我们想实现一个接受可变数量参数的函数,通常有两种方式:
A. C 风格的可变参数 (<cstdarg>)
最典型的例子就是 printf 和 scanf。
1 | |
这种方式有几个致命的缺陷:
- 非类型安全:编译器不会检查传入的参数类型是否与格式化字符串匹配。
C_style_log("%d %d", "hello", "world");这样的代码可以编译通过,但在运行时会导致未定义行为。 - 无法处理非 POD 类型:对于拥有构造函数、析构函数的 C++ 对象(例如
std::string),直接通过va_arg传递和获取是危险且通常不可行的。 - 笨拙的语法:需要手动调用
va_start,va_arg,va_end等宏,容易出错。
B. 函数重载
我们可以为不同数量的参数编写多个重载版本。
1 | |
这种方式虽然类型安全,但显然扩展性极差。我们无法穷举所有可能的参数组合,代码冗余且难以维护。
C++11 的解决方案:
为了解决上述问题,C++11 引入了可变参数模板。其核心目标是:在编译期,以一种类型安全、高度泛化的方式处理任意数量的任意类型参数。
可变参数模板
可变参数模板引入了两个关键概念:模板参数包 (Template Parameter Pack) 和 函数参数包 (Function Parameter Pack)。它们都使用省略号 ... 来表示。
1 | |
typename... Args:这里的Args不是一个单一的类型,而是一个类型的集合,我们称之为“模板参数包”。它可以包含 0个或多个类型。Args... args:这里的args也不是一个单一的变量,而是由多个参数组成的函数参数包。
问题的关键在于我们如何使用 args 这个参数包。它不能像普通数组一样用 for 循环遍历,因为它的元素数量和类型要在编译的时候才确定。为此,C++新增了**包展开 (Pack Expansion)**的语法规则。我们通过 ... 将参数包“展开”成独立的元素。它是理解和使用可变参数模板的核心。
包展开
包展开是 C++ 编译器在编译期执行的一种操作。它不是一个函数,也不是一个类,而是一个语法规则。作用是将一个模式 (Pattern) 应用到参数包的每一个元素上,然后生成一串由逗号分隔的列表。
核心语法
包展开的语法非常简洁:
pattern...
这里有两个关键部分:
pattern(模式)
这是一个包含至少一个模板参数包或函数参数包的 C++ 表达式或声明。这个模式定义了我们想对包中每一个元素做什么样的操作。
例如:f(args)(将每个参数传递给函数f)std::forward<Args>(args)(对每个参数进行完美转发)T*(将每个类型T变成T*)static_cast<T>(v)(将每个类型T用于类型转换)
...(省略号)
这是“展开运算符”。当它跟在模式后面时,它指示编译器:“请把左边的这个pattern复制多份,每一份对应参数包中的一个元素,并将它们用逗号,分隔开。”
示例
让我们用一个简单的例子来形象化这个过程:
1 | |
当我们调用 my_printer(10, 'a', 3.14); 时:
- 编译器识别出参数包
args包含三个元素:10(int),'a'(char),3.14(double)。 - 它看到包展开
some_function(args)...。 - 模式是
some_function(args)。 - 编译器开始“盖戳”:
- 第一个元素
10-> 应用模式 ->some_function(10) - 第二个元素
'a'-> 应用模式 ->some_function('a') - 第三个元素
3.14-> 应用模式 ->some_function(3.14)
- 第一个元素
- 最后,用逗号将它们连接起来,得到一串列表:
some_function(10), some_function('a'), some_function(3.14)。 - 这串列表被替换回原来的位置,最终
another_function的调用就变成了:another_function(some_function(10), some_function('a'), some_function(3.14));
整个过程完全在编译期完成。
包展开主要有三种主流的使用方式:递归函数模板、初始化列表展开,以及 C++17 引入的折叠表达式。
递归函数模板
这是最基础、最经典的包展开方式。思路是:定义一个处理“一个参数”和“多个参数”的模板。在处理多个参数的版本中,我们先处理第一个参数,然后将剩余的参数包递归地传给自己,直到参数包为空。
它巧妙地回避了单次展开所有元素, 只处理第一个元素,然后利用函数参数列表的包展开 func(rest...),将剩余的包传递给下一次递归调用。
1 | |
当编译器看到 print(1, "hello", 3.14, 'a') 时,它会进行如下的模板实例化:
print<int, const char*, double, char>(1, "hello", 3.14, 'a')- 打印
1, - 调用
print<const char*, double, char>("hello", 3.14, 'a')
- 打印
print<const char*, double, char>("hello", 3.14, 'a')- 打印
"hello", - 调用
print<double, char>(3.14, 'a')
- 打印
print<double, char>(3.14, 'a')- 打印
3.14, - 调用
print<char>('a')
- 打印
print<char>('a')- 匹配到递归终止的非可变参数版本
void print(const T& arg) - 打印
'a'并换行,递归结束。
- 匹配到递归终止的非可变参数版本
这个递归展开的过程在编译期完成,编译器会为我们生成一系列函数调用链,是完全类型安全的。
sizeof... 运算符
我们可以使用 sizeof...(Args) 或 sizeof...(args) 来获取参数包中的元素数量。
1 | |
初始化列表展开
初始化列表展开的核心思想是利用C++ 保证初始化列表中的元素会按顺序求值的规则,将我们真正想执行的操作(通常是带副作用的函数调用)藏在逗号表达式的左边,以此来“欺骗”编译器为我们展开参数包。
1 | |
expression(args)
这是我们想对参数包中每一个参数arg执行的操作。例如,在print函数里,这个表达式就是(std::cout << arg << ", ")。(expression(args), 0)
这里使用了逗号操作符。它首先执行expression(args)(也就是打印我们的参数),然后逗号操作符会丢弃这个表达式的结果,并返回0。所以,无论expression(args)返回什么类型(std::cout << ...返回的是std::ostream&),整个(..., 0)表达式的结果永远是int类型的0。{ ... }...
这是包展开的核心部分。...会将{}中的模式(expression(args), 0)应用到参数包args的每一个元素上。
假如我们调用some_function(a, b, c),那么{ (expression(args), 0)... }会被展开成:{ (expression(a), 0), (expression(b), 0), (expression(c), 0) }
由于逗号操作符的作用,这个初始化列表最终会变成{0, 0, 0}。int dummy[] = ...;
我们创建了一个临时的int数组,就叫dummy(哑变量)吧,用上面生成的{0, 0, 0}来初始化它。这个数组本身没有任何用处,我们只是需要一个“语法容器”来触发初始化列表的求值。因为 C++ 语言保证,为了初始化这个数组,列表中的每个表达式都必须被按顺序求值。为了避免编译器因为
dummy变量未使用而发出警告,可以将其转换为(void)dummy;
示例:使用初始化列表展开实现 print
1 | |
折叠表达式(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 | |
- 二元折叠
二元右折叠 (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 | |
注意事项
对于满足结合律的操作符来说,左折叠和右折叠没有区别,但是对于不满足结合律的操作符,例如减法-和除法/,不同的折叠方式结果会截然不同。
此外,对于逻辑操作符&&和||,由于C++短路求值的特性,不同的折叠方向会导致不同结果
- 所谓的短路求值,即
- 对于
A && B,如果A为false,则B不会被求值。 - 对于
A || B,如果A为true,则B不会被求值。
- 对于
假设我们有一个检查函数,它在检查时会打印信息:
1 | |
现在我们用它和参数包 check("A", true), check("B", false), check("C", true) 来折叠:
一元左折叠
(... && args)- 展开为
(check("A", true) && check("B", false)) && check("C", true) - 求值顺序:
check("A", true)被调用,返回true。check("B", false)被调用,返回false。- 此时
(true && false)的结果是false。根据短路规则,&&右边的表达式check("C", true)将不会被求值。
- 输出:
1
2Checking A...
Checking B...
- 展开为
一元右折叠
(args && ...)- 展开为
check("A", true) && (check("B", false) && check("C", true)) - 求值顺序:
- 为了计算最外层
&&的值,编译器需要先计算右边的子表达式(check("B", false) && check("C", true))。 - 为了计算这个子表达式,
check("B", false)被调用,返回false。 - 根据短路规则,
check("C", true)不会被求值。子表达式结果为false。 - 现在表达式变为
check("A", true) && false。check("A", true)被调用。
- 为了计算最外层
- 注意:尽管 C++ 标准规定了
A && B中A先于B求值,但在复杂的嵌套表达式f() && (g() && h())中,g()和h()的求值顺序是相对于f()的。关键在于,为了评估顶层&&,右侧的整个括号(g() && h())必须先被求值。这意味着右折叠破坏了从左到右的自然短路流程。 - 实际输出 (常见编译器如 GCC/Clang):(或者
1
2Checking B...
Checking A...A在B之前,但关键是C永远不会被检查)
- 展开为
可变参数模板与完美转发
可变参数模板经常与完美转发结合使用,这是它们最核心的应用场景之一,主要用于编写工厂函数(如 std::make_unique, std::make_shared)和容器的 emplace 系列方法。
假设我们要写一个工厂函数 createObject,它接受任意参数,并用这些参数去构造一个 T 类型的对象。我们希望传递给 createObject 的参数,能以其原始的值类别(左值或右值)被完美地转发给 T 的构造函数。
- 如果传给
createObject的是右值(临时对象),那么T的移动构造函数应该被调用。 - 如果传给
createObject的是左值(具名变量),那么T的拷贝构造函数应该被调用。
1 | |
输出:
1 | |
template<typename... Args> void func(Args&&... args):这里的Args&&是一个万能引用。当传入左值时,Args被推导为左值引用类型(如int&);当传入右值时,Args被推导为普通类型(如double)。std::forward<Args>(args)...:这是一个条件性的std::move,因为在createObject函数内部,输入的参数不管原始实参是左值还是右值,它都是具名,也就是左值,所以我们需要多这一步。- 如果
args的原始实参是右值,std::forward会将其转换为右值。 - 如果
args的原始实参是左值,std::forward会保持其左值属性。
- 如果
...的两次使用:Args&&... args:将Args参数包中的每个类型T都变成T&&,并应用到函数参数包args。std::forward<Args>(args)...:将std::forward应用到参数包args的每一个元素上。