如何展开可变参数模板的参数包
C++11 引入可变参数模板(variadic template),可以接受任意数量的模板参数,这对模板的用法是一个很大的改进。然而,可变参数模板的使用也很复杂,在 C++11 中,模板参数包不能直接展开,需要使用递归或者逗号表达式的方式展开,写法比较繁琐。C++17 引入了折叠表达式的语法,用来对可变参数模板中的参数做展开。
本文介绍与之相关的内容,方便有需要的读者在自己的项目中实践可变参数模板特性。
基础
什么是可变参数模板
模板参数列表可变的模板,如下示例:
1 | // 常规模板 |
注意其中的省略号用法,两个地方出现了省略号:
- 在模板参数列表中,
...出现在 typename 后边,用来表示T是一个可变长的模板参数 - 在模板函数参数列表中,
...出现在模板参数类型T后边,用来表示args是一个模板参数包 我们在函数内部需要使用args时,要遵循模板参数包的使用规则。
注意,参数的数量范围是 [0, N) ,包括 0,也就是说可以实例化没有参数的模板函数。
不想展开参数包
有些时候,我们不需要展开参数包,那也是可以的,可以直接使用整个参数包,比如:
1 | // 获取模板参数包的大小 |
还有一些更复杂的用法,我在最后一节介绍。
不同的包展开方式
递归展开
通过递归展开的实现代码为:
1 | // 这个函数存在是必要的 |
无参的 print 函数是必要的,处理没参数的情况,也处理递归调用最后一层出口,因为我们上边提到,Ts... 是包括 0 个模板参数的。
逗号表达式展开
递归展开的方式需要引入递归,还得单独实现出口函数,比较啰嗦。可以使用逗号表达式展开,实现代码为:
1 | template <typename T> |
其中逗号表达式中第一项,表示对逗号表达式按序求值(也就是调用 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 | template <typename... T> |
与其叫逗号表达式的展开法,不如叫初始化列表的展开法,因为逗号表达式并不是必须的,而初始化列表却是必须的,进而,没有被调用的 int arr[] 也是必须存在的,或者你也可以真的需要它(见后文章节)。比如说,不使用逗号表达式的办法是让 print_item 返回 int 类型:
1 | template <typename T> |
这里可以留意下 ... 的位置,当它位于参数左边时,表示这个参数的类型是 T... 也就是变长类型 T 的类型,此时参数时折叠状态,比如 T... args 中的 args;当它位于参数(或表达式)右边时,表示将折叠参数展开,比如 args...,展开的参数列表并不是逗号表达式,而是用逗号分隔开的表达式列表,C++标准规定了可以接受的范围。
折叠表达式展开(C++17)
折叠表达式展开的语法看起来更奇怪,但它符合一种约定的语法形式,具体的实现为:
1 | // 一元右折叠 |
两种折叠的展开形式等价于(伪代码):
一元右折叠:(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 | // 一元左折叠表达式 |
其中:
op是运算符,支持多种运算符pack是包含未展开参数包的表达式init是顶层参数,是二元折叠展开的出口,是不包含未展开参数包的表达式...是折叠标记- 注意
()不能省略。
如果使用一元表达式实现二元表达式的目的,需要给定一个二元出口的特化版本(即空参数包),而直接使用二元表达式,可以用 init 来指定出口值。
再回头看前边的示例,其中 ((std::cout << args << ", "), ...) 中,pack 是 (std::cout << args << ", "), op 是 ,,也就是逗号表达式的逗号运算符。
左折叠和右折叠的区别就是先展开左侧参数,还是先展开右侧参数,这在一些要求结合顺序的运算符连接时很重要,比如 - 和 /。
使用二元折叠表达式实现的代码为:
1 | // 二元右折叠表达式 |
我们示例中逗号运算符是二元运算符,之所以可以使用一元折叠表达式而不需要指定特化版本,原因是有一些一元折叠表达式拥有默认的空参数包值,是 void(),另外还有 && 的默认空参数值是 true,|| 的默认空参数值是 false,它们也不需要定义特化版本(只有这 3 个特例)。
我项目中用到的一个例子,更直观的展示一下折叠表达式在语义上的用法:
1 | constexpr int |
其中 std::convertible_to<> 是 C++20 引入的一种 concept,它用来约束 i 的类型必须满足可转换为特定类型的条件;auto 作为泛型参数的用法也是 C++20 中的特性,本质还是模板,这里可以忽略。
折叠表达式和逗号表达式的对比
折叠表达式 (args op ...) 和逗号表达式展开 ((args, 0)...) 的区别是,折叠表达式更灵活,它将折叠参数按 op 连接,并返回连接之后的整体参数列表,op 可以是逗号,此时和逗号表达式的意义是一样的。
比如,C++11 中的 args... 的展开虽然是通过逗号分隔,但它并不是逗号表达式,即:
1 | args...; // compiler error |
参数展开时处理参数的函数,如 print 必须提供一个无参的终止类型。 args... 参数包用逗号表达式展开时,没有明确的展开顺序,所以在展开且求值的过程中,顺序是随机的。如果想严格展开顺序,就只能使用折叠表达式,折叠表达式的左折叠和右折叠可以约束展开顺序。
不展开参数包
模板参数包可以以 T... 类型来传递,比如将可变长参数模板和完美转发结合起来,实现一个可以创建任意类型的模板工厂函数。
举例来说,假设我们的目标是通过定义函数 createT 来实现以下用例:
1 | struct MyType { |
实现非常简单,但要注意语法,其中的 Args && ... 是万能引用类型:
1 | template <typename T, typename ... Args> |
标准库中 std::make_unique 等工厂函数就是这么实现的:
1 | template <typename T, typename ... Args> |
这里,我们将 args 整个打包传递给了内部的 std::forward,注意其语法。
可变长基类继承结构
还有一些有意思的用法,比如以下代码:
1 | template <typename... Base> |
它表示 Derived 可由模板类中模板参数指定的所有类型作为基类。
其中的 using Base::func... 可等价于 using 所有在模板参数列表中的基类类型中的 func 函数。
本文同步发布在知乎账号下:如何展开可变参数模板的参数包 - 知乎 (zhihu.com)










