C++中的可变参数模板

作者:Eli Bendersky

http://eli.thegreenplace.net/2014/variadic-templates-in-c/

回到C++11前夜,编写带有任意参数函数的唯一办法是使用可变参数函数,像printf,使用省略号语法(…)以及伴随的va_族的宏。如果你曾经使用这个方法编写代码,你会知道这有多累赘。除了变成类型不安全外(所有的类型解析必须在运行时在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是一个完全随意的名字,别的什么名字也是可以的)。可变参数模板以你编写递归代码的方式编写——你需要一个基本的情形(上面声明的adder(T v)),以及一个“递归“的通用情形[1]。递归本身发生在调用adder(args…)中。注意如何定义通用的adder——从模板参数包剥除第一个实参到类型T(相应地,实参first)。因此对每次调用,参数包缩短一个参数。最终,遇到基本的情形。

为了更好地感受这个过程,可以使用__PRETTY_FUNCTION__宏[2]。如果我们在上面两个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++模板元编程时,通常会听到“模式匹配”,以及这部分语言如何构成了一个相当完整的编译时函数式语言。

上面展示的例子非常基础——模板实参被逐一剥除直接命中基本情形。下面是模式匹配更为有趣的一个展示:

template<typename T>

bool pair_comparer(T a, Tb) {

  // In real-world code, we wouldn't compare floating point valueslike

  // this. It would make sense to specialize this function forfloating

  // point types to use approximate comparison.

  return a == b;

}

 

template<typename T, typename... Args>

bool pair_comparer(T a, Tb, 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类型不相同。

更有趣的,pair_comparer仅能对偶数个参数工作,因为它们成对剥除,而且基本情形比较两个参数。如下:

pair_comparer(1.5, 1.5, 2, 2, 6, 6, 7)

不能通过编译;编译器抱怨基本情形期望2个实参,但只提供了1个。要改正它,我们可以添加该函数模板的另一个变形:

template<typename T>

bool pair_comparer(T a) {

  return false;

}

这里,我们强制所有奇数个数的序列返回false,因为仅当剩下一个参数时,匹配这个版本。

注意到pair_comparer强制比较对的两个成员具有完全相同的类型。一个简单的变形将允许不同的类型,只要它们可以比较。我把这作为练习留给感兴趣的读者。

性能

如果你关心依赖可变参数模板的代码的性能,无需担心。因为实际上没有涉及递归,我们所有的是在编译时刻预先生成的一系列函数。在实践中,这个序列是相当短的(超过5-6个参数的可变参数函数调用很罕见)。因为现代编译器会进取地内联代码,很可能最终编译成完全没有函数调用的机器代码。实际上你最终得到的,与循环展开没啥两样。

与C形式的可变参数函数相比,这是一个胜利,因为C形式的可变实参必须在运行时解析。Va_宏逐字地操作运行时栈。因此,可变参数模板通常是对可变参数函数的一个性能优化。

类型安全的可变参数函数

在文章的开头我提到了printf,作为一个不使用模板的可变参数函数的例子。不过,正如我们所知,printf与其同类不是类型安全的。如果你将一个数字传递给一个%s格式,可怕的事情可能会发生,而编译器却不能向你给出相关的警告[3]。

可变参数模板如何使得我们可以编写类型安全函数是相当明显的。在printf的情形里,当实现到达一个新的格式指示时,它可以确实地断言传入实参的类型。这个断言将不会在编译时刻发作,但它会发作——产生一条友好的错误消息,而不是未定义行为。

我不再进一步讨论类型安全的printf的实现——它已经被改写过很多次了。一些好的例子参考Stroustrup新版的The C++Programming Language,或Alexandrescu的Variadictemplates are funadic演讲。

数目可变域数据结构

这个用例有趣得多,IMHO,因为它是引入C++11之前不能做到的,至少不花很大力气是不可能的。定制的数据结构(自C以来的struct以及C++中的class)具有编译时刻定义的域。它们可以表示在运行时增长的类型(例如std::vector),但如果你希望添加新的域,这就是编译器必须看到的东西。可变参数模板使得定义具有任意数目域并且在使用时配置这个数目的数据结构成为可能。这样的主要例子是tuple类,这里我想展示如何构建一个这样的类[4]。

你可以编译、使用的完整代码在:variadic-tuple.cpp

让我们以这个类型定义开始:

template <class... Ts> struct tuple {};

 

template <classT, class... Ts>

struct tuple<T, Ts...>: tuple<Ts...> {

  tuple(T t, Ts... ts) :tuple<Ts...>(ts...), tail(t) {}

 

  T tail;

};

我们以基本情形开始——一个名为tuple的空的类定义。后跟从参数包剥除第一个类型,并定义该类型名为tail成员的特化版本。它还派生自用参数包的余下部分具现的tuple。这是一个在没有更多类型可以被剥除时就停止,并且层次架构的基础是一个空的tuple的递归定义。为了更好地了解生成的数据结构,让我们使用一个具体的例子:

tuple<double, uint64_t, constchar*> 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<constchar*> {

  uint64_t tail;

}

 

struct tuple<const char*> : tuple {

  const char* tail;

}

 

struct tuple {

}

在最初的3元素tuple中数据成员的布局将是:

 [const char* tail,uint64_t tail, double tail]

注意因为空基类优化,空的基类不占据任何空间。利用Clang布局倾印特性,我们可以验证:

*** Dumping AST Record Layout

   0 | structtuple<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]

的确,数据结构的大小与成员的内部布局正如所预期。

好了,上面的struct定义让我们创建元组,但对它们我们还没有太多可做的。访问元组的方式是使用函数模板get[5],因此让我们看一下它怎么工作。首先,我们必须定义一个辅助类来让我们访问一个元组的第k个元素的类型:

template <classT, class... Ts>

structelem_type_holder<0, tuple<T, Ts...>> {

  typedef T type;

};

 

template <size_t k, classT, class... Ts>

struct elem_type_holder<k,tuple<T, Ts...>> {

  typedeftypename elem_type_holder<k- 1, tuple<Ts...>>::type type;

};

Elem_type_holder是另一个可变参数模板。它接受一个数字k及我们感兴趣的tuple类型作为模板参数。注意这是一个编译时模板元编程构造——它作用在常量及类型上,而不是运行时对象。例如,给定elem_type_holder<2,some_tuple_type>,我们将得到以下的伪展开:

struct elem_type_holder<2, tuple<T, Ts...>> {

  typedef typenameelem_type_holder<1, tuple<Ts...>>::type type;

}

 

struct elem_type_holder<1, tuple<T, Ts...>> {

  typedef typenameelem_type_holder<0, tuple<Ts...>>::type type;

}

 

struct elem_type_holder<0, tuple<T, Ts...>> {

  typedef T type;

}

这样,elem_type_holder<2,some_tuple_type>从元组的开头剥除两个类型,并将其类型设置为第三个的类型,这正是我们需要的。手握这个,我们可以实现get

template <size_t k, class... Ts>

typename std::enable_if<

    k == 0, typenameelem_type_holder<0, tuple<Ts...>>::type&>::type

get(tuple<Ts...>& t) {

  return t.tail;

}

 

template <size_t k, classT, class... Ts>

typename std::enable_if<

    k != 0, typenameelem_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的两个模板重载间选择——一个用于k0时,一个用于剥除第一个类型并递归的通用情形,就像可变参数函数模板那样。

因为它返回一个引用,我们可以使用get来读、写元组元素:

tuple<double, uint64_t, constchar*> t1(12.2, 42, "big");

 

std::cout << "0thelem is " <<get<0>(t1) << "\n";

std::cout << "1thelem is " <<get<1>(t1) << "\n";

std::cout << "2thelem is " <<get<2>(t1) << "\n";

 

get<1>(t1) = 103;

std::cout << "1thelem is " <<get<1>(t1) << "\n";

用于catch-all函数的可变参数模板

这里我发现了另一个有趣的例子。它不同于在本文中已经展示的例子,因为它没有真正使用传统的递归方式实现可变参数模板。相反,它使用它们来表达“任何模板参数可以到这”的概念。

假设我们希望编写一个可以打印标准库容器的函数。我们希望它工作在任意容器上,并且我们还希望用户能尽可能少地写代码,因此我们不希望操作迭代器。我们只想print_container(c)可作用于任何容器c。下面是第一种做法:

template <template <typename, typename> classContainerType,

          typename ValueType,

          typename AllocType>

void print_container(constContainerType<ValueType, AllocType>& c) {

  for (constauto& v : c) {

    std::cout << v<< ' ';

  }

  std::cout << '\n';

}

许多STL容器是可以通过值类型与分配器类型参数化的模板;例如vectorlistdeque等等。因此我们可以这样写:

std::vector<double> vd{3.14, 8.1, 3.2, 1.0};

print_container(vd);

 

std::list<int> li{1, 2, 3, 5};

print_container(li);

这如期望那样工作。不过,如果我们尝试对map使用它,我们得到应该编译错误:

std::map<std::string, int> msi{{"foo", 42},{"bar", 81}, {"bazzo", 4}};

print_container(msi);

^~~~~~~~~~~~~~~

error: no matching function for call to 'print_container'

note: candidate template ignored: substitution failure :

      template templateargument has different template

      parameters than itscorresponding template template parameter

这是因为map是由4个,而不是2个模板参数参数化的模板。对set有同样的问题,它有三个模板实参。这令人讨厌——然而print_container函数的内容对于所有这些容器都应该相同,声明必须不同。我们要怎样做才能避免重复代码呢?可变参数模板就是药方:

template <template <typename, typename...> classContainerType,

          typename ValueType, typename... Args>

void print_container(constContainerType<ValueType, Args...>& c) {

  for (constauto& v : c) {

    std::cout << v<< ' ';

  }

  std::cout << '\n';

}

这段代码的意思是——ContainerType是一个本身带有任意数量模板参数的模板模板参数。我们不关心这,只要编译器在调用时类型推导它们。这个版本的函数将可用于mapsetunordered_map及其他容器[6]。要支持映射我们要做一个小的补充是:

// Implement << for pairs: this is needed to print outmappings where range

// iteration goes over (key, value) pairs.

template <typename T, typename U>

std::ostream& operator<<(std::ostream& out, const std::pair<T,U>& p) {

  out << "[" <<p.first << ", " << p.second << "]";

  return out;

}

用于转发的可变参数模板

一个有点相关的例子是本身不进行什么操作,但需要将所有的实参转发给其他模板或函数的模板。这最终被证明非常有用,因为从一个模板参数的角度,C++具有一个内在“可变参数的”常用构造——构造函数。给定一个泛化类型T,要调用T的构造函数,我们可能需要传入任意数目的实参。不像在编译时刻指明实参的函数类型,只给出一个泛化类型T,我们不知道它有哪些构造函数,以及这些构造函数接受多少实参。

这一个非常重要的例子是C++14后标准库里的std::make_unique方法。我们希望能够这样使用它:

std::unique_ptr<FooType> f =std::make_unique<FooType>(1, "str", 2.13);

FooType是一个任意类型且可以任意方式构造。如何让make_unqiue知道其构造函数的声明?使用可变参数模板,它不需要知道!通常make_unique实现如下:

template<typename T, typename... Args>

unique_ptr<T> make_unique(Args&&... args)

{

    return unique_ptr<T>(newT(std::forward<Args>(args)...));

}

目前忽略&&语法与std::forward;我将在将来的文章里谈论它们。对于我们当前谈论来说重要的是,可变参数模板的使用,向new表达式里c的构造函数传达了“任何数目的实参都可以到这里来”,并将传递它们。

参考链接

在准备本文时,我发现了这些有用的资源:

  1. The C++ Programming Language第4版(Bjarne Stroustrup)在第28章对可变参数模板有精彩的讨论。
  2. 这个StackOverflow线程用于print_conatiner例子,也在可变参数模板上下文里提及__PRETTRY_FUNCTION__
  3. Louis Brandy的C++可变参数模板,致怀疑者
  4. 来自Going Native 2012,Andrei Alexandrescu的“Variadic templates are funadic”演讲非常有用——它也是我tuple例子实现的基础。
  5. 最后,如果可变参数模板的递归使用让你想起函数式语言里的模式匹配,你是对的!Bartosz Milewski的文章深入探讨了这个话题。

[1] 技术上,这不是递归,因为调用了不同的函数。编译器最终为每个不同长度的被使用的参数包生成不同的函数。不过,按递归来思考它是有用的。

[2] 它是gcc的一个扩展,Clang也支持它。

[3] 公平地说,现代编译器可能会给出警告(Clang几乎总是会);但这只是printf族函数的特例。在其他可变参数代码里,你只能靠自己。

[4] std::tuple是C++11中标准库的一部分,它比我在这里展示的版本要复杂。

[5] Get是一个独立的函数,而不是方法,因为作为一个方法使用是笨拙的。因为它要求显式模板参数特化,不能使用推导,我们必须编写像tup.template get<2>()这样代码来使用它,这太难看,太啰嗦。

[6] 读者练习:仍然有一个C++11容器不能工作。哪一个?

你可能感兴趣的:(C++)