第35章 可变参数模板(C++11 C++17 C++20)

可变参数模板是C++11标准引入的一种新特性,顾名思义就是类模板或者函数模板的形参个数是可变的。作为一个模板元编程的爱好者,刚看到这个特性的时候是非常激动的,因为这个特性能很大程度上加强模板的能力。举两个例子,熟悉C++标准库的读者肯定知道std::bind1ststd::bind2nd两个函数模板,两个函数能够绑定一个对象到函数或者函数对象,不过它们有一个很大的限制——只能绑定一个对象。为了解决这个问题,C++标准委员会在2005年的C++技术报告中(tr1)提出了新的函数模板std::bind,该函数可以将多个对象绑定到函数或者函数对象上,不过由于缺乏可变参数模板的支持,这里所谓的多个也是有限制的,比如在boost中最多是9个,后来GCC和Visual Studio C++的标准库沿用了这个设定。无独有偶,这份技术报告中还提出了std::tuple类型,该类型能够存储多种类型的对象,当然这里的多种类型的数量同样有限制,比如在boost中这个数量最多为10,后来GCC和Visual Studio C++的标准库也沿用了这个设定。可以看出这两个函数模板和类模板对于可变参数都有很强烈的需求,于是在C++11标准支持可变参数模板以后,std::bindstd::tuple就被改写为可以接受任意多个模板形参的版本了。

template<class …Args>
void foo(Args …args) {}

template<class …Args>
class bar {
public:
  bar(Args …args) {
       foo(args…);
  }
};

在上面的代码中class …Args是类型模板形参包,它可以接受零个或者多个类型的模板实参。Args …args叫作函数形参包,它出现在函数的形参列表中,可以接受零个或者多个函数实参。而args…是形参包展开,通常简称包展开。它将形参包展开为零个或者多个模式的列表,这个过程称为解包。这里所谓的模式是实参展开的方法,形参包的名称必须出现在这个方法中作为实参展开的依据,最简单的情况为解包后就是实参本身。

以上这些语法概念看起来可能会有点复杂。不过没关系,结合下面的例子后读者会发现这些语法实际上非常自然:

template<class …Args>
void foo(Args …args) {}

int main()
{
  unsigned int x = 8;
    foo();          // foo()
  foo(1);           // foo<int>(int)
  foo(1, 11.7);     // foo<int,double>(int,double)
  foo(1, 11.7, x);  // foo<int,double,unsigned int>(int,double,unsigned int)
}

以上是一个变参函数模板,它可以接受任意多个实参,编译器会根据实参的类型和个数推导出形参包的内容。另外,C++11标准中变参类模板虽然不能通过推导得出形参包的具体内容,但是我们可以直接指定它:

template<class …Args>
class bar {};

int main()
{
  bar<> b1;
  bar<int> b2;
  bar<int, double> b3;
  bar<int, double, unsigned int> b4;
}

需要注意的是,无论是模板形参包还是函数形参包都可以与普通形参结合,但是对于结合的顺序有一些特殊要求。

在类模板中,模板形参包必须是模板形参列表的最后一个形参:

template<class …Args, class T>
class bar {};
bar<int, double, unsigned int> b1;     // 编译失败,形参包并非最后一个

template<class T, class …Args>
class baz {};
baz<int, double, unsigned int> b1;     // 编译成功

但是对于函数模板而言,模板形参包不必出现在最后,只要保证后续的形参类型能够通过实参推导或者具有默认参数即可,例如:

template<class …Args, class T, class U = double>
void foo(T, U, Args …args) {}

foo(1, 2, 11.7);    // 编译成功

虽然以上介绍的都是类型模板形参,但是实际上非类型模板形参也可以作为形参包,而且相对于类型形参包,非类型形参包则更加直观:

template<int …Args>
void foo1() {};

template<int …Args>
class bar1 {};

int main()
{
  foo1<1, 2, 5, 7, 11>();
  bar1<1, 5, 8> b;
}

虽然上一节已经简单介绍了可变参数模板的基本语法,但是读者应该已经注意到,节中的例子并没有实际用途,无论是函数模板foo还是类模板bar,它们的主体都是空的。实际上,它们都缺少了一个最关键的环节,那就是形参包展开,简称包展开。只有结合了包展开,才能发挥变参模板的能力。需要注意的是,包展开并不是在所有情况下都能够进行的,允许包展开的场景包括以下几种。

1.表达式列表。

2.初始化列表。

3.基类描述。

4.成员初始化列表。

5.函数参数列表。

6.模板参数列表。

7.动态异常列表(C++17已经不再使用)。

8.lambda表达式捕获列表。

9.Sizeof…运算符。

10.对其运算符。

11.属性列表。

虽然这里列出的场景比较多,但是因为大多数是比较常见的场景,所以理解起来应该不会有什么难度。让我们通过几个例子来说明包展开的具体用法:

#include <iostream>

template<class T, class U>
T baz(T t, U u)
{
  std::cout << t << ":" << u << std::endl;
  return t;
}

template<class …Args>
void foo(Args …args) {}

template<class …Args>
class bar {
public:
  bar(Args …args)
  {
       foo(baz(&args, args) …);
  }
};

int main()
{
  bar<int, double, unsigned int> b(1, 5.0, 8);
}

在上面的代码中,baz是一个普通的函数模板,它将实参通过std::cout输出到控制台上。foo是一个可变参数的函数模板,不过这个函数什么也不做。在main函数中,模板bar实例化了一个bar<int, double, unsigned int>类型并且构造了对象b,在它的构造函数里对形参包进行了展开,其中baz(&args, args)…是包展开,而baz(&args, args)就是模式,也可以理解为包展开的方法。所以这段代码相当于:

class bar {
public:
  bar(int a1, double a2, unsigned int a3)
  {
       foo(baz(&a1, a1), baz(&a2, a2), baz(&a3, a3));
  }
};

为了让读者更加清晰地了解编译器对这段代码的处理,下面展示了GCC生成的GIMPLE中间代码的关键部分:

main ()
{
  …
  struct bar b;
  …
  bar<int, double, unsigned int>::bar (&b, 1, 5.0e+0, 8);
  …
}

bar<int, double, unsigned int>::bar (struct bar * const this, int args#0,  double args#1, unsigned int args#2)
{
  args_2.0_1 = args#2;
  _2 = baz<unsigned int*, unsigned int> (&args#2, args_2.0_1);
  args_1.1_3 = args#1;
  _4 = baz<double*, double> (&args#1, args_1.1_3);
  args_0.2_5 = args#0;
  _6 = baz<int*, int> (&args#0, args_0.2_5);
  foo<int*, double*, unsigned int*> (_6, _4, _2);
}

baz<unsigned int*, unsigned int> (unsigned int * t, unsigned int u) {
  …
}

baz<double*, double> (double * t, double u) {
  …
}

baz<int*, int> (int * t, int u) {
  …
}
…

可以看到,在bar的构造函数中分别调用了3个不同的baz函数,然后将它们的计算结果作为参数传入foo函数中。接着,稍微修改一下这个例子:

template<class …T>
int baz(T …t)
{
  return 0;
}

template<class …Args>
void foo(Args …args) {}

template<class …Args>
class bar {
public:
  bar(Args …args)
  {
       foo(baz(&args…) + args…);
  }
};

int main()
{
  bar<int, double, unsigned int> b(1, 5.0, 8);
}

在上面这段代码中形参包又是怎么解包的?要理解这个解包过程,需要将其分为两个部分:第一个部分是对函数模板baz(&args)的解包,其中&args…是包展开,&args是模式,这部分会被展开为baz(&a1, &a2, &a3);第二部分是对foo(baz(&args) + args)的解包,由于baz(&args)已经被解包,因此现在相当于解包的是foo(baz(&a1, &a2, &a3) + args),其中baz(&a1, &a2, &a3) + args…是包展开,baz(&a1, &a2, &a3) + args是模式,最后的结果为foo(baz(&a1, &a2, &a3) + a1, baz(&a1, &a2, &a3) + a2, baz(&a1, &a2, &a3) + a3)

在我们刚刚看到的这些例子中包展开的模式都还算是比较常规的,而实际上模式还可以更加灵活,例如:

#include <iostream>

int add(int a, int b) { return a + b; };
int sub(int a, int b) { return a - b; };

template<class …Args>
void foo(Args (*…args)(int, int))
{
  int tmp[] = {(std::cout << args(7, 11) << std::endl, 0) …};
}

int main()
{
  foo(add, sub);
}

这个例子比之前看到的都要复杂一些,首先函数模板foo的形参包不再是简单的Argsargs,而是Args (*…args)(int, int),从形式上看这个形参包解包后将是零个或者多个函数指针。为了让编译器能自动推导出所有函数的调用,在函数模板foo的函数体里使用了一个小技巧。函数体内定义了一个int类型的数组tmp,并且借用了逗号表达式的特性,在括号中用逗号分隔的表达式会以从左往右的顺序执行,最后返回最右表达式的结果。在这个过程中std::cout << args(7, 11) << std::endl得到了执行。(std::cout << args(7, 11) << std::endl, 0)…是一个包展开,而(std::cout << args(7, 11) << std::endl, 0)是包展开的模式。

我们已经见识了很多函数模板中包展开的例子,但是这些并不是包展开的全部,接下来让我们了解一下在类的继承中形参包以及包展开是怎么使用的:

#include <iostream>

template<class …Args>
class derived : public Args…
{
public:
  derived(const Args& …args) : Args(args) … {}
};

class base1
{
public:
  base1() {}
  base1(const base1&) 
  {
       std::cout << "copy ctor base1" << std::endl;
  }
};

class base2
{
public:
  base2() {}
  base2(const base2&)
  {
       std::cout << "copy ctor base2" << std::endl;
  }
};

int main()
{
  base1 b1;
  base2 b2;
  derived<base1, base2> d(b1, b2);
}

在上面的代码中,derived是可变参数的类模板,有趣的地方是它将形参包作为自己的基类并且在其构造函数的初始化列表中对函数形参包进行了解包,其中Args(args)…是包展开,Args(args)是模式。

到此为止读者应该对形参包和包展开有了一定的理解,现在是时候介绍另一种可变参数模板了,这种可变参数模板拥有一个模板形参包,请注意这里并没有输入或者打印错误,确实是模板形参包。之所以在前面没有提到这类可变参数模板,主要是因为它看起来过于复杂:

template<template<class …> class …Args>
class bar : public Args<int, double>… {
public:
  bar(const Args<int, double>& …args) : Args<int, double>(args) … {}
};

template<class …Args>
class baz1 {};

template<class …Args>
class baz2 {};

int main()
{
  baz1<int, double> a1;
  baz2<int, double> a2;
  bar<baz1, baz2> b(a1, a2);
}

可以看到类模板bar的模板形参是一个模板形参包,也就是说其形参包是可以接受零个或者多个模板的模板形参。在这个例子中,bar<baz1, baz2>接受了两个类模板baz1baz2。不过模板缺少模板实参是无法实例化的,所以bar实际上继承的不是baz1baz2两个模板,而是它们的实例baz1<int, double>baz2<int, double>。还有一个有趣的地方,template<template <class> classArgs>似乎存在两个形参包,但事实并非如此。因为最里面的template<class>只说明模板形参是一个变参模板,它不能在bar中被展开。

但是这并不意味着两个形参包不能同时存在于同一个模式中,要做到这一点,只要满足包展开后的长度相同即可,让我们看一看提案文档中的经典例子:

template<class…> struct Tuple {};
template<class T1, class T2> struct Pair {};
template<class …Args1>
struct zip {
  template<class …Args2>
  struct with {
       typedef Tuple<Pair<Args1, Args2>…> type;
  };
};

int main()
{
  zip<short, int>::with<unsigned short, unsigned>::type t1;  // 编译成功
  zip<short>::with<unsigned short, unsigned>::type t2;       // 编译失败,形参
                                                             // 包长度不同
}

在上面的例子中,可变参数模板zip的形参包Args1with的形参包Args2同时出现在模式Pair<Args1, Args2>中,如果要对Pair<Args1, Args2>…进行解包,就要求Args1Args2的长度相同。编译器能够成功编译t1t1的类型为Tuple<Pair<short, unsigned short>, Pair<int, unsigned>>,但是编译器在编译t2时会提示编译失败,因为Args1形参包中只有一个实参,而Args2中有两个实参,它们的长度不同。

现在回头看一看这些例子,我们会发现例子里包展开的场景基本上涵盖了常用的几种,包括表达式、初始化列表、基类描述、成员初始化列表、函数形参列表和模板形参列表等。在剩下没有涉及的几种场景中,还有一种可能会偶尔用到,那就是lambda表达式的捕获列表:

template<class …Args> void foo(Args …args) {}

template<class …Args>
class bar
{
public:
  bar(Args …args) {
       auto lm = [args …]{ foo(&args…); };
       lm();
  }
};

int main()
{
  bar<int, double> b2(5, 8.11);
}

在以上代码的lambda表达式lm的捕获列表里,args…是一个包展开,而args是模式。比较有趣的是,除了捕获列表里的包展开,在lambda表达式的函数体内foo(&args)还有一个包展开,而这里的包展开是&args…,模式为&args。接下来看一个实际生产中可能会用到的例子:

template<class F, class… Args>
auto delay_invoke(F f, Args… args) {
    return [f, args…]() -> decltype(auto) {
        return std::invoke(f, args…);
    };
}

上面这段代码实现了一个delay_invoke,目的是将函数对象和参数打包到一个lambda表达式中,等到需要的时候直接调用lambda表达式实例,而无须关心参数如何传递。

最后值得强调一下的是函数模板推导的匹配顺序:在推导的形参同时满足定参函数模板和可变参数函数模板的时候,编译器将优先选择定参函数模板,因为它比可变参数函数模板更加精确,比如:

#include <iostream>

template<class… Args> void foo(Args… args)
{
  std::cout << "foo(Args… args)" << std::endl;
}

template<class T1, class… Args> void foo(T1 a1, Args… args)
{
  std::cout << "foo(T1 a1, Args… args)" << std::endl;
}

template<class T1, class T2> void foo(T1 a1, T2 a2)
{
  std::cout << "foo(T1 a1, T2 a2)" << std::endl;
}

int main()
{
  foo();
  foo(1, 2, 3);
  foo(1, 2);
}

上面的代码编译运行的结果是:

foo(Args… args)
foo(T1 a1, Args… args)
foo(T1 a1, T2 a2)

可以看到,当foo()没有任何实参的时候,编译器使用foo(Argsargs)来匹配,因为只有它支持零参数的情况。当foo(1,2,3)有3个实参的时候,编译器不再使用foo(Argsargs)来匹配,虽然它能够匹配3个实参,但是它不如foo (T1 a1, Argsargs)精确,所以编译器采用了foo(T1 a1, Argsargs)来匹配3个参数。foo(1,2)有两个参数,编译器再次抛弃了foo(T1 a1, Argsargs),因为这时候有更加精确的定参函数模板foo(T1 a1, T2 a2)

我们知道sizeof运算符能获取某个对象类型的字节大小。不过当sizeof之后紧跟…时其含义就完全不同了。sizeof…是专门针对形参包引入的新运算符,目的是获取形参包中形参的个数,返回的类型是std::size_t,例如:

#include <iostream>

template<class …Args> void foo(Args …args)
{
  std::cout << "foo sizeof…(args) = " << sizeof…(args) << std::endl;
}

template<class …Args>
class bar
{
public:
  bar() {
       std::cout << "bar sizeof…(Args) = " << sizeof…(Args) << std::endl;
  }
};

int main()
{
  foo();
  foo(1,2,3);

  bar<> b1;
  bar<int, double> b2;
}
foo sizeof…(args) = 0
foo sizeof…(args) = 3
bar sizeof…(Args) = 0
bar sizeof…(Args) = 2

在C++11标准中,要对可变参数模板形参包的包展开进行逐个计算需要用到递归的方法,比如下面的求和函数:

#include <iostream>

template<class T>
T sum(T arg)
{
  return arg;
}

template<class T1, class… Args>
auto sum(T1 arg1, Args …args)
{
  return arg1 + sum(args…);
}

int main()
{
  std::cout << sum(1, 5.0, 11.7) << std::endl;
}

在上面的代码中,当传入函数模板sum的实参数量等于1时,编译器会选择调用T sum(T arg),该函数什么也没有做,只是返回实参本身。当传入的实参数量大于1时,编译器会选择调用auto sum(T1 arg1, Argsargs),注意,这里使用C++14的特性将auto作为返回类型的占位符,把返回类型的推导交给编译器。这个函数将除了第一个形参的其他形参作为实参递归调用了sum函数,然后将其结果与第一个形参求和。最终编译器生成的结果应该和下面的伪代码类似:

sum(double arg)
{
  return arg;
}

sum(double arg0, double args1)
{
  return arg0 + sum(args1);
}

sum(int arg1, double args1, double args2)
{
  return arg1 + sum(args1, args2);;
}

int main()
{
  std::cout << sum(1, 5.0, 11.7) << std::endl;
}

在前面的例子中,我们提到了利用数组和递归的方式对形参包进行计算的方法。这些都是非常实用的技巧,解决了C++11标准中包展开方法并不丰富的问题。不过实话实说,递归计算的方式过于烦琐,数组和括号表达式的方法技巧性太强也不是很容易想到。为了用更加正规的方法完成包展开,C++委员会在C++17标准中引入了折叠表达式的新特性。让我们使用折叠表达式的特性改写递归的例子:

#include <iostream>

template<class… Args>
auto sum(Args …args)
{
  return (args + …);
}

int main()
{
  std::cout << sum(1, 5.0, 11.7) << std::endl;
}

如果读者是第一次接触折叠表达式,一定会为以上代码的简洁感到惊叹。在这份代码中,我们不再需要编写多个sum函数,然后通过递归的方式求和。需要做的只是按照折叠表达式的规则折叠形参包(args +)。根据折叠表达式的规则,(args +)会被折叠为arg0 + (arg1 + arg2),即1 + (5.0 + 11.7)。

到此为止,读者应该已经迫不及待地想了解折叠表达式的折叠规则了吧。那么接下来我们就来详细地讨论折叠表达式的折叠规则。

在C++17的标准中有4种折叠规则,分别是一元向左折叠、一元向右折叠、二元向左折叠和二元向右折叠。上面的例子就是一个典型的一元向右折叠:

( args op … )折叠为(arg0 op (arg1 op … (argN-1 op argN)))

对于一元向左折叠而言,折叠方向正好相反:

( … op args )折叠为((((arg0 op arg1) op arg2) op …) op argN)

二元折叠总体上和一元相同,唯一的区别是多了一个初始值,比如二元向右折叠:

( args op … op init )折叠为(arg0 op (arg1 op …(argN-1 op (argN op init)))

二元向左折叠也是只有方向上正好相反:

( init op … op args )折叠为(((((init op arg0) op arg1) op arg2) op …) op argN)

虽然没有提前声明以上各部分元素的含义,但是读者也能大概看明白其中的意思。这其中,args表示的是形参包的名称,init表示的是初始化值,而op则代表任意一个二元运算符。值得注意的是,在二元折叠中,两个运算符必须相同

在折叠规则中最重要的一点就是操作数之间的结合顺序。如果在使用折叠表达式的时候不能清楚地区分它们,可能会造成编译失败,例如:

#include <iostream>
#include <string>

template<class… Args>
auto sum(Args …args)
{
  return (args + …);
}

int main()
{
  std::cout << sum(std::string("hello "), "c++ ", "world") << std::endl;     // 编译错误
}

上面的代码会编译失败,理由很简单,因为折叠表达式(args +)向右折叠,所以翻译出来的实际代码是(std::string("hello ") + ("c++ " + "world"))。但是两个原生的字符串类型是无法相加的,所以编译一定会报错。要使这段代码通过编译,只需要修改一下折叠表达式即可:

template<class… Args>
auto sum(Args …args)
{
  return (… + args);
}

这样翻译出来的代码将是((std::string("hello ") + "c++ ") + "world")。而std::string类型的字符串可以使用+将两个字符串连接起来,于是可以顺利地通过编译。

最后让我们来看一个有初始化值的例子:

#include <iostream>
#include <string>

template<class …Args>
void print(Args …args)
{
  (std::cout << … << args) << std::endl;
}

int main()
{
  print(std::string("hello "), "c++ ", "world");
}

在上面的代码中,print是一个输出函数,它会将传入的实参输出到控制台上。该函数运用了二元向左折叠(std::cout <<<< args),其中std::cout是初始化值,编译器会将代码翻译为(((std::cout << std::string("hello ")) << "c++ ") << "world") << std::endl;

一元折叠表达式对空参数包展开有一些特殊规则,这是因为编译器很难确定折叠表达式最终的求值类型,比如:

template<typename… Args>
auto sum(Args… args)
{
  return (args + …);
}

在上面的代码中,如果函数模板sum的实参为空,那么表达式args +…是无法确定求值类型的。当然,二元折叠表达式不会有这种情况,因为它可以指定一个初始化值:

template<typename… Args>
auto sum(Args… args)
{
  return (args + … + 0);
}

这样即使参数包为空,表达式的求值结果类型依然可以确定,编译器可以顺利地执行编译。为了解决一元折叠表达式中参数包为空的问题,下面的规则是必须遵守的。

1.只有&&||,运算符能够在空参数包的一元折叠表达式中使用。

2.&&的求值结果一定为true

3.||的求值结果一定为false

4.,的求值结果为void()

5.其他运算符都是非法的。

#include <iostream>

template<typename… Args>
auto andop(Args… args)
{
  return (args && …);
}

int main()
{
  std::cout << std::boolalpha << andop();
}

在上面的代码中,虽然函数模板andop的参数包为空,但是依然能成功地编译运行并且输出计算结果true

从C++17标准开始,包展开允许出现在using声明的列表内,这对于可变参数类模板派生于形参包的情况很有用,例如:

#include <iostream>
#include <string>

template<class T>
class base {
public:
  base() {}
  base(T t) : t_(t) {}
private:
  T t_;
};

template<class… Args>
class derived : public base<Args>…
{
public:
  using base<Args>::base…;
};

int main()
{
  derived<int, std::string, bool> d1 = 11;
  derived<int, std::string, bool> d2 = std::string("hello");
  derived<int, std::string, bool> d3 = true;
}

在上面的代码中,可变参数类模板derived继承了通过它的形参包实例化的base类模板。using base<Args>::base…将实例化的base类模板的构造函数引入了派生类derived。于是我们可以看到,derived<int, std::string, bool>具有了base<int>base<std::string>base<bool>的构造函数。

读者应该还记得,我们在介绍lambda表达式使用可变参数模板时列出了这样一个例子:

template<class F, class… Args>
auto delay_invoke(F f, Args… args) {
    return [f, args…]() -> decltype(auto) {
        return std::invoke(f, args…);
    };
}

当时留下了一个问题没有解决,那就是按值捕获的性能问题。假设该delay_ invoke传递的实参都是复杂的数据结构且数据量很大,那么这种按值捕获显然不是一个理想的解决方案。当然了,引用捕获更加不对,在delay_invoke的使用场景下很容易造成未定义的结果。那么我们该怎么办?其实有一个办法,它需要结合初始化捕获和移动语义,让我们将代码修改为:

template<class F, class… Args>
auto delay_invoke(F f, Args… args) {
  return[f = std::move(f), tup = std::make_tuple(std::move(args) …)]() 
        -> decltype(auto) {
       return std::apply(f, tup);
  };
}

上面的代码首先使用了std::make_tuplestd::move将参数打包到std::tuple中,这个过程使用移动语义消除了对象的复制;接下来为了方便地展开std::tuple中的参数,需要将std::invoke修改为std::apply。虽然在这个例子中性能问题解决了,但事情还没完,尤其是当我们需要用lambda表达式调用确定的函数时,例如:

template <class… Args>
auto delay_invoke_foo(Args… args) {
    return [args…]() -> decltype(auto) {
        return foo(args…);
    };
}

如果还是按照刚刚的办法使用std::tuple打包参数,那么代码会变得难以理解:

template <class… Args>
auto delay_invoke_foo(Args… args) {
  return[tup = std::make_tuple(std::move(args) …)]() -> decltype(auto) {
       return std::apply([](auto const&… args) -> decltype(auto) {
            return foo(args…);
            }, tup);
  };
}

幸运的是,在C++20标准中我们有了更好的解决方案,标准支持lambda表达式初始化捕获的包展开。以上代码可以修改为:

template <class… Args>
auto delay_invoke_foo(Args… args) {
    return […args=std::move(args)]() -> decltype(auto) {
        return foo(args…);

    };
}

上面的代码变得非常简洁!需要注意的是,捕获列表中…的位置在args之前,这一点和简单的捕获列表是有区别的。

回过头来看最初的示例代码,在C++20标准环境下我们可以将其修改为:

template<class F, class… Args>
auto delay_invoke(F f, Args… args) {
  return[f = std::move(f), …args = std::move(args)]() -> decltype(auto) {
       return std::invoke(f, args…s);
  };
}

可以看出在省略了std::tuple以后代码也变得清晰了不少。

本章详细介绍了可变参数模板特性,该特性可以说是新标准中最重要的模板相关的特性。熟悉模板元编程的读者应该很清楚,过去想实现一个可以处理多个模板形参的模板只能机械化地重复代码,为了减少这种机械的重复,有些代码库会使用C++宏编程的技巧,比如boostloki等。但是众所周知,C++宏编程对于代码的编写和调试是非常不友好的,一旦出现问题很难排查出原因。可变参数模板的出现正好能解决这个问题,丰富的包展开和折叠表达式功能也让原本晦涩难懂的模板元编程代码变得更加容易理解。对于不用编写模板元编程的程序员来说,本章的内容也有重要的意义,因为在标准库中已经有很多地方使用了该特性,比如std::tuplestd:: variantstd::bind等。理解可变参数模板特性有助于正确地使用它们。