C++11 引入可变参数模板(variadic template),可以接受任意数量的模板参数,这对模板的用法是一个很大的改进。然而,可变参数模板的使用也很复杂,在 C++11 中,模板参数包不能直接展开,需要使用递归或者逗号表达式的方式展开,写法比较繁琐。C++17 引入了折叠表达式的语法,用来对可变参数模板中的参数做展开。

本文介绍与之相关的内容,方便有需要的读者在自己的项目中实践可变参数模板特性。

基础

什么是可变参数模板

模板参数列表可变的模板,如下示例:

1
2
3
4
// 常规模板
template<typename T> void f(T arg);
// 可变参数模板
template<typename... T> void f(T... args);

注意其中的省略号用法,两个地方出现了省略号:

  • 在模板参数列表中,... 出现在 typename 后边,用来表示 T 是一个可变长的模板参数
  • 在模板函数参数列表中,... 出现在模板参数类型 T 后边,用来表示 args 是一个模板参数包 我们在函数内部需要使用 args 时,要遵循模板参数包的使用规则。

注意,参数的数量范围是 [0, N) ,包括 0,也就是说可以实例化没有参数的模板函数。

不想展开参数包

有些时候,我们不需要展开参数包,那也是可以的,可以直接使用整个参数包,比如:

1
2
3
4
5
6
7
8
9
10
11
// 获取模板参数包的大小
template<typename... T>
void f(T... args) {
cout << sizeof...(args) << endl;
}
// 传递给另一个可变参数模板函数
template<typename... T> void another_f(T... args);
template<typename... T>
void f(T... args) {
another_f(args);
}

还有一些更复杂的用法,我在最后一节介绍。

不同的包展开方式

递归展开

通过递归展开的实现代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这个函数存在是必要的
void print() {
std::cout << std::endl;
}
// 递归展开可变参数列表
template <typename T, typename... Ts>
void print(T arg, Ts... args) {
std::cout << arg << ", ";
print(args...);
}

int main() {
print("1", "2", 3, 4);
return 0;
}

无参的 print 函数是必要的,处理没参数的情况,也处理递归调用最后一层出口,因为我们上边提到,Ts... 是包括 0 个模板参数的。

逗号表达式展开

递归展开的方式需要引入递归,还得单独实现出口函数,比较啰嗦。可以使用逗号表达式展开,实现代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
void print_item(T arg) {
std::cout << arg << ", ";
}
template <typename... T>
void print(T... args) {
int arr[] = { (print_item(args), 0)... };
std::cout << std::endl;
}
int main() {
print("1", "2", 3, 4);
return 0;
}

其中逗号表达式中第一项,表示对逗号表达式按序求值(也就是调用 print_item),之后 { ... } 是利用初始化列表的语法,对一个 (print_item(args), 0) 的逗号表达式做大小为 sizeof...(args) 的初始化,大概等效于伪代码 { (print_item(args[1]), 0), (print_item(args[1]), 0), ..., (print_item(args[N]), 0)}

0 在这里的用途只是让逗号表达式 (print_item(args), 0) 的类型是 int,从而避免编译器报错,int 类型赋给 int arr[] 是合法的,如果不写 0,int arr[] = { print_item(args)... }; ,编译器会报错无法把 void 类型绑定到 int 的 array 上。

展开后的形式为(伪代码):

1
2
3
4
5
6
7
8
9
template <typename... T>
void print(T... args) {
int arr[] = {
(print_item("1"), 0),
(print_item("2"), 0),
(print_item(3), 0),
(print_item(4), 0)
};
}

与其叫逗号表达式的展开法,不如叫初始化列表的展开法,因为逗号表达式并不是必须的,而初始化列表却是必须的,进而,没有被调用的 int arr[] 也是必须存在的,或者你也可以真的需要它(见后文章节)。比如说,不使用逗号表达式的办法是让 print_item 返回 int 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
int print_item(T arg) {
std::cout << arg << ", ";
return 0;
}
template <typename... T>
void print(T... args) {
int arr[] = { print_item(args)... };
std::cout << std::endl;
}
int main() {
print("1", "2", 3, 4);
return 0;
}

这里可以留意下 ... 的位置,当它位于参数左边时,表示这个参数的类型是 T... 也就是变长类型 T 的类型,此时参数时折叠状态,比如 T... args 中的 args;当它位于参数(或表达式)右边时,表示将折叠参数展开,比如 args...,展开的参数列表并不是逗号表达式,而是用逗号分隔开的表达式列表,C++标准规定了可以接受的范围。

折叠表达式展开(C++17)

折叠表达式展开的语法看起来更奇怪,但它符合一种约定的语法形式,具体的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 一元右折叠
template <typename... T>
void printR(T... args) {
((std::cout << args << ", "), ...) << std::endl;
}
// 一元左折叠
template <typename... T>
void printL(T... args) {
(..., (std::cout << args << ", ")) << std::endl;
}
int main() {
printR("1", "2", 3, 4);
printL("1", "2", 3, 4);
return 0;
}

两种折叠的展开形式等价于(伪代码):

一元右折叠:(std::cout << "1" << ", ", (std::cout << "2" << ", ", (std::cout << 3 << ", ", std::cout << 4 << ", ")))

一元左折叠:(((std::cout << "1" << ", ", std::cout << "2" << ", "), std::cout << 3 << ", "), std::cout << 4 << ", ")

回归到折叠表达式的定义中:

1
2
3
4
5
6
7
8
// 一元左折叠表达式
(pack op ...)
// 一元右折叠表达式
(... op pack)
// 二元左折叠表达式
(pack op ... op init)
// 二元右折叠表达式
(init op ... op pack)

其中:

  • op 是运算符,支持多种运算符
  • pack 是包含未展开参数包的表达式
  • init 是顶层参数,是二元折叠展开的出口,是不包含未展开参数包的表达式
  • ... 是折叠标记
  • 注意 () 不能省略。

如果使用一元表达式实现二元表达式的目的,需要给定一个二元出口的特化版本(即空参数包),而直接使用二元表达式,可以用 init 来指定出口值。

再回头看前边的示例,其中 ((std::cout << args << ", "), ...) 中,pack 是 (std::cout << args << ", ")op 是 ,,也就是逗号表达式的逗号运算符。

左折叠和右折叠的区别就是先展开左侧参数,还是先展开右侧参数,这在一些要求结合顺序的运算符连接时很重要,比如 - 和 /

使用二元折叠表达式实现的代码为:

1
2
3
4
5
6
7
8
9
10
// 二元右折叠表达式
template <typename... T>
void printR(T... args) {
((std::cout << args), ... , void()) << std::endl;
}
// 二元左折叠表达式
template <typename... T>
void printL(T... args) {
(void(0), ... , (std::cout << args)) << std::endl;
}

我们示例中逗号运算符是二元运算符,之所以可以使用一元折叠表达式而不需要指定特化版本,原因是有一些一元折叠表达式拥有默认的空参数包值,是 void(),另外还有 && 的默认空参数值是 true,|| 的默认空参数值是 false,它们也不需要定义特化版本(只有这 3 个特例)。

我项目中用到的一个例子,更直观的展示一下折叠表达式在语义上的用法:

1
2
3
4
constexpr int
sum(std::convertible_to<int> auto ... i) {
return (0 + ... + i);
}

其中 std::convertible_to<> 是 C++20 引入的一种 concept,它用来约束 i 的类型必须满足可转换为特定类型的条件;auto 作为泛型参数的用法也是 C++20 中的特性,本质还是模板,这里可以忽略。

折叠表达式和逗号表达式的对比

折叠表达式 (args op ...) 和逗号表达式展开 ((args, 0)...) 的区别是,折叠表达式更灵活,它将折叠参数按 op 连接,并返回连接之后的整体参数列表,op 可以是逗号,此时和逗号表达式的意义是一样的。

比如,C++11 中的 args... 的展开虽然是通过逗号分隔,但它并不是逗号表达式,即:

1
2
3
4
args...;   // compiler error

template<typename ... T> inline void pass(T && ...) {}
pass(args...); // compiler pass

参数展开时处理参数的函数,如 print 必须提供一个无参的终止类型。 args... 参数包用逗号表达式展开时,没有明确的展开顺序,所以在展开且求值的过程中,顺序是随机的。如果想严格展开顺序,就只能使用折叠表达式,折叠表达式的左折叠和右折叠可以约束展开顺序。

不展开参数包

模板参数包可以以 T... 类型来传递,比如将可变长参数模板和完美转发结合起来,实现一个可以创建任意类型的模板工厂函数。

举例来说,假设我们的目标是通过定义函数 createT 来实现以下用例:

1
2
3
4
5
6
7
8
9
10
11
struct MyType {
MyType(int, int) {}
};
int main() {
int lvalue(2000);

int T1 = createT<int>(); // 不带构造参数的 int 类型对象构造
auto T2 = createT<int>(2011); // 带有 1 个右值的 int 类型对象构造
auto T3 = createT<int>(lvalue); // 带有 1 个左值的 int 类型对象构造
auto T4 = createT<MyType>(lvalue, 2011); // 带有左值和右值作为构造参数的自定义类型对象构造
}

实现非常简单,但要注意语法,其中的 Args && ... 是万能引用类型:

1
2
3
4
template <typename T, typename ... Args>
T createT(Args && ... args) {
return T(std::forward<Args>(args)...);
}

标准库中 std::make_unique 等工厂函数就是这么实现的:

1
2
3
4
template <typename T, typename ... Args>
std::unique_ptr<T> make_unique(Args && ... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

这里,我们将 args 整个打包传递给了内部的 std::forward,注意其语法。

可变长基类继承结构

还有一些有意思的用法,比如以下代码:

1
2
3
4
5
template <typename... Base>
struct Derived : Base ... {
Derived();
using Base::func...;
};

它表示 Derived 可由模板类中模板参数指定的所有类型作为基类。

其中的 using Base::func... 可等价于 using 所有在模板参数列表中的基类类型中的 func 函数。