Variadic templates in C++
By Eli Bendersky
Litost_Cheng 译
在翻译过程中,为了便于理解,在原文的基础上,添加了一定的译者注,或是补充示例,针对文中的错误,与不足,望批评指正
在C++11
之前,编写带有任意数量参数的函数, 唯一方法是使用带有冒号语法(…)以及伴随的va_
系列宏的可变参数函数,例如printf函数。如果你曾使用过这种方法写过代码,你就会知道这种方式有多笨重。除了是非类型安全(所有类型的解析都必须在运行时使用va_arg
进行强制类型转换来完成)的以外,实际使用起来也很难上手。va_
系列宏执行低层次的内存操作,平时遇到的很多段错误都是由于在使用可变参数时没有足够小心造成的。
但是,使用这种方法尤其令我困扰的是:将一些在编译时就清楚知道的事情,拖到了运行时(关于何为编译时、运行时,需要有一个简单的介绍)。我们在编写可变参数函数的时候,并不知道其所有的用途。但是,在编译器在将所有的代码链接起来时,这点事确定的。编译器能够知道函数都在何处被调用,以及传递的参数类型(对于C++而言,类型的解析式是在编译时完成的)。
可变参数模板是C++11当中的一个新特性。我们终于有了一个可以编写类型安全、在编译时对处理参数逻辑(而不是运行时)的可变参数函数的方式(注:这句时真的长)。可变参数模板不仅可以用来实现参数个数可变的函数。在本篇文章中,作者想要示范其中的一些功能。
我们通过一个将所有参数“加”到一起的函数来进行深入研究:
template<typename T>
T adder(T v) {
return v;
}
template<typename T, typename... Args>
T adder(T first, Args... args) {
return first + adder(args...);
}
以下是我们可以调用它的几种方式:
long sum = adder(1, 2, 3, 8, 7);
std::string s1 = "x", s2 = "aa", s3 = "bb", s4 = "yy";
std::string ssum = adder(s1, s2, s3, s4);
只要编译器能够将+
操作符应用于该参数类型,adder
就可以接受任意数量的参数。检测工作是由编译器在编译时完成的。这一切并没有什么神奇之处,只是遵循了C++通常的模板和重载的处理规则。
typename... Args
被称作模板参数包
,而Args... args
被称作函数参数包
(Args
可以如同其它参数一样,随意命名)。可变参数模板的编写方式与编写递归代码的方式相同——需要一个base case
(如上边声明的adder(T v)
)(注:个人理解:所谓的base case
相当于递归的“终止条件”)一个用来递归的“general cases”(注:根据需要,用户可能需要实现多个general cases,具体的案例见下文))1。
需要注意的是general case
——adder(T first, Args... args)
是如何定义的:第一个参数从参数包中剥离出来,并视为类型T
(即参数first
)。
随着后续每次调用,参数包依次变短2。
我们可以使用__PRETTY_FUNCTION__
宏,以便更好的理解该过程(用法详见)。如果我们将下述代码插入到上述两个adder
实现的首行:
std::cout << __PRETTY_FUNCTION__ << "\n";
在执行adder(1, 2, 3, 8, 7)
时,我们将会看到如下信息:
T adder(T, Args...) [T = int, Args = <int, int, int, int>]
T adder(T, Args...) [T = int, Args = <int, int, int>]
T adder(T, Args...) [T = int, Args = <int, int>]
T adder(T, Args...) [T = int, Args = <int>]
T adder(T) [T = int]
在我们阅读关于C++模板元编程的相关文章时,经常听到的可能就是模式匹配
,以及该语言这一部分如何构成一个相当完整的编译时函数语言。
上边是一个非常基础的例子——模板参数被一个个的剥离,直到触发了base case
。以下是关于模式匹配
的一个更有趣的展示:
template<typename T>
bool pair_comparer(T a, T b) {
// In real-world code, we wouldn't compare floating point values like
// this. It would make sense to specialize this function for floating
// point types to use approximate comparison.
return a == b;
}
template<typename T, typename... Args>
bool pair_comparer(T a, T b, Args... args) {
return a == b && pair_comparer(args...);
}
pair_comparer
接受任意数量的参数,当且仅当它们成对相等时,才会返回true
。对参数的类型没有强制要求——所有可以比较的都可以。例如:
pair_comparer(1.5, 1.5, 2, 2, 6, 6)
会返回true
。如果我们把第二个参数改成1,由于double
与int
类型不一致(注:实际测试过程,确如文中所说,编译器会报错。但是此处为什么不会发生所谓的隐式类型转换呢?),会导致编译失败。
更有趣的是,由于无论是base case
还是general case
, 参数都是被成对的剥离的,因此pair_comparer
也只适用于偶数个参数的情况。如下的代码:
pair_comparer(1.5, 1.5, 2, 2, 6, 6, 7)
无法编译。原因是编译器发现base case
需要两个参数,但实际只提供了一个。为了解决该问题,我们可以添加另一个可变参数模板:
template<typename T>
bool pair_comparer(T a) {
return false;
}
我们强制奇数个参数的序列返回false
,原因是只有一个单个参数版本的函数被配到。
请注意,上述的pair_comparer
强制要求被比较的参数对
,都必须有完全相同的类型。一个简单的变体,就可以允许使用不同类型,前提是只要循序他们可以进行比较3。
template <typename TA, typename TB>
bool pair_comparer(TA a, TB b)
{
return a == b;
}
template <typename TA, typename TB, typename... Args>
bool pair_comparer(TA a, TB b, Args... args)
{
return a == b && pair_comparer(args...);
}
在以上原文所给的示例中,针对参数个数为偶数的情况,是以template
作为base case
来起到终止递归
的作用,其实,我们也可以采用以下一种实现:
template <typename... Args>
bool pair_comparer(Args... args)
{
std::cout << __PRETTY_FUNCTION__ << "\n";
return true;
}
template <typename T, typename... Args>
bool pair_comparer(T a, T b, Args... args)
{
std::cout << __PRETTY_FUNCTION__ << "\n";
return a == b && pair_comparer(args...);
}
其调用结果如下:
bool pair_comparer(T, T, Args ...) [with T = const char*; Args = {const char*, const char*}]
bool pair_comparer(T, T, Args ...) [with T = const char*; Args = {}]
bool pair_comparer(Args ...) [with Args = {}]
在上边的例子中,我们用template
取代template
作为base case
,并默认返回true
。通过该例子,其实是想要表明:具体使用那个实现作为base case
,是需要根据,具体的调用,由编译器去决定的。
如果担心依赖于可变参数模板的代码的性能,那你大可放心。由于实际上并没有涉及到递归,所以我们所拥有的只是在编译时预先生成的一系列函数调用。实际上这个函数调用相当短(具有超过5-6个参数的可变参数调用很少见)(注:感觉此处作者想表达的意思是说,由于编译器的支持,在使用可变参数模板的过程中,并不会涉及大量的函数调用,而都是通过内敛函数来实现的)。由于现代编译器正在积极地使用内敛代码,因此最终编译生成的可能是没有函数调用的机器码。
可变参数模板相较于C风格的变参函数而言,后者由于参数必须在运行时解析(va_
族宏,实际上是在操作运行时堆栈),在这点上,前者可被看作是对后者的一种性能优化。
我曾在文章的开头提到过一个作为未使用模板的变参函数——printf
。众所周知,printf
一类的实现并不是类型安全的。如果你将一个整数传给了%s
格式符,可能会发生一些糟糕的事情,但是编译器并不会警告你4。
可变参数模板相当明显的告诉我们如何编写出类型安全的代码。就拿printf
而言,当实现(注:作者在此处指的应该是指用可变参数模板,重新实现printf
)遇到了一个新的格式化指令时,它实际上可以断言传递的参数的类型。断言不会在编译时触发,但是他会触发——并会生成一个更好的错误消息,而不是说一个未定义的行为。
我不会进一步讨论类型安全的printf
的实现,原因是它已经在其它文章中,被讨论了很多次。一些好的例子可以参见Stroustrup
的《The C++ Programming Language》,以及Alexandrescu
的Variadic templates are funadic的演讲。
如我直言,这个用例是更有趣的,因为在C++11
之前,这几乎是不可能实现的。
自定义数据结构(自C
时代以来的结构体,和C++
中的类)都具有编译时的字段。
它们可以表示在运行时增长的类型(例如std :: vector
)但是如果要添加新字段,则要编译器支持。可变参数模板使得定义具有任意数量的字段,并且可以按使用情况配置字段数量的数据结构,成为可能。主要的例子就是tuple
类,我想在这里展示一下如何构造一个我们自己的tuple
类5。
一个可以使用并编译的完整代码,见这里:variadic-tuple.cpp。
我们先从类型定义开始讲起:
template <class... Ts> struct tuple {};
template <class T, class... Ts>
struct tuple<T, Ts...> : tuple<Ts...> {
tuple(T t, Ts... ts) : tuple<Ts...>(ts...), tail(t) {}
T tail;
};
我们从base case
——一个名为tuple
的空的类模板的定义,开始讲起。后面的特化(注:个人理解,和我们前面一直提到的general case
是一个意思)从参数包中剥离出第一个类型,并将其赋给了一个名为tail
的成员变量。它后续继续派生于带有参数包剩余部分的tuple
实例6。直到没有可被剥离的类型时,这个递归的定义才终止,此时层次结构的基础是一个空的tuple
。
为了更好的了解生成的数据结构,我们先看一个具体的例子:
tuple<double, uint64_t, const char*> t1(12.2, 42, "big");
忽略构造函数,以下是创建tuple
的一个大概过程:
struct tuple<double, uint64_t, const char*> : tuple<uint64_t, const char*> {
double tail;
}
struct tuple<uint64_t, const char*> : tuple<const char*> {
uint64_t tail;
}
struct tuple<const char*> : tuple {
const char* tail;
}
struct tuple {
}
原始的3元素tuple
的数据成员的布局将是:
[const char* tail, uint64_t tail, double tail]
为了厘清真实的构造过程中,我们使用上文提到的__PRETTY_FUNCTION__
,对构造函数进行修改:
template <class... Ts>
struct tuple
{
tuple()
{
std::cout << __PRETTY_FUNCTION__ << "\n";
}
};
template <class T, class... Ts>
struct tuple<T, Ts...> : tuple<Ts...>
{
tuple(T t, Ts... ts) : tuple<Ts...>(ts...), tail(t)
{
std::cout << __PRETTY_FUNCTION__ << "\n";
}
T tail;
};
实际的输出为:
tuple<Ts>::tuple() [with Ts = {}]
tuple<T, Ts ...>::tuple(T, Ts ...) [with T = const char*; Ts = {}]
tuple<T, Ts ...>::tuple(T, Ts ...) [with T = long unsigned int; Ts = {const char*}]
tuple<T, Ts ...>::tuple(T, Ts ...) [with T = double; Ts = {long unsigned int, const char*}]
根据C++先构造基类,再构造成员对象,最后构造派生类的原则,我们可以发现这与上文提到的构造过程是一致的。
由于空基优化,最终的空基并不会消耗空间。使用 Clang的布局转储功能,我们可以一探究竟:
*** Dumping AST Record Layout
0 | struct tuple<double, unsigned long, const char *>
0 | struct tuple<unsigned long, const char *> (base)
0 | struct tuple<const char *> (base)
0 | struct tuple<> (base) (empty)
0 | const char * tail
8 | unsigned long tail
16 | double tail
| [sizeof=24, dsize=24, align=8
| nvsize=24, nvalign=8]
可见,实际上的数据结构的大小和成员的内存布局和预期其实是一致的。
上文,我们只是定义了tuple
,但还不能用它来做一些其它的工作。在std::tuple
中,元组中各元素的访问是通过get
函数模板7,接下来我们看看它是如何工作的。首先,我们必须定义一个可以让我们访问tuple
中第k
个元素的helper
:
template <size_t, class> struct elem_type_holder;
template <class T, class... Ts>
struct elem_type_holder<0, tuple<T, Ts...>> {
typedef T type;
};
template <size_t k, class T, class... Ts>
struct elem_type_holder<k, tuple<T, Ts...>> {
typedef typename elem_type_holder<k - 1, tuple<Ts...>>::type type;
};
elem_type_holder<2, some_tuple_type>
会从初始的元组中剥离两个类型,并将type
的类型设置为初始元组中我们需要的第三个类型。有了这个,我们就可以实现get
:
template <size_t k, class... Ts>
typename std::enable_if<
k == 0, typename elem_type_holder<0, tuple<Ts...>>::type&>::type
get(tuple<Ts...>& t) {
return t.tail;
}
template <size_t k, class T, class... Ts>
typename std::enable_if<
k != 0, typename elem_type_holder<k, tuple<T, Ts...>>::type&>::type
get(tuple<T, Ts...>& t) {
tuple<Ts...>& base = t;
return get<k - 1>(base);
}
[ enable_if][]用于在get
的两个模板重载中二选一。
一个应用于当k
是0的情况,另一个则应用于general case
。
由于其返回的是一个引用,因此我们可以使用get
来对元组中的元素执行读写操作。
tuple<double, uint64_t, const char*> t1(12.2, 42, "big");
std::cout << "0th elem is " << get<0>(t1) << "\n";
std::cout << "1th elem is " << get<1>(t1) << "\n";
std::cout << "2th elem is " << get<2>(t1) << "\n";
get<1>(t1) = 103;
std::cout << "1th elem is " << get<1>(t1) << "\n";
…
…
发表日期:2019年05月20日
更多内容:
从技术实现来讲,由于调用的是不同的函数,所以并不是递归。根据每次用到的参数包的长度,编译器最终生成了不同的函数。用递归来解释它,只是为了便于理解。 ↩︎
原文为So with each call, the parameter pack gets shorter by one parameter. 随着每次调用,参数包都会缩短一个参数。但是在实际的应用中,可能存在多个general case
,每次调用剥离的参数个数需要根据实际情况确定。 ↩︎
原文中,作者是希望读者可以自己找到答案,并没有给出该实现。以上仅是译者的一种实现。如有不妥,还请各位指正。 ↩︎
公平来讲,现在编译器是会警告你的(Clang几乎是肯定会
);但这只是针对printf
系列函数而言。其他一些可变参数代码,则需要你自己考虑该问题。 ↩︎
std::tuple
时C++11
标准库中的一部分,我在这里所展示的是一个相对更复杂的版本。 ↩︎
原文为It also derives from the tuple instantiated with the rest of the pack ↩︎
get
是一个独立的函数,而不是一个成员方法,原因在于将它作为一个成员使用时,会很尴尬(注:原始是否和一般将<<
和>>
实现为友元时一样的呢?) ↩︎