引言

我一直对C++模板元编程的各种用法非常感兴趣,就想尝试手动实现std::tuple这个类型。期间碰到了不少问题,但在Google和自己的努力之下,都得到了解决。我觉得通过对一些模板工具的实现,能够更加清楚地理解模板元编程。

前置知识

为了更好的理解这篇文章的内容,读者可能需要有以下前置知识

  1. 可变模板参数是什么
  2. 什么是模板类的特化和偏特化
  3. 多态的一些性质

std::tuple的接口及用法

我们要实现自己的tuple,可以先观察stltuple特点、用法和接口,接着可以开始设计我们自己的数据结构,一点一点将tuple实现出来。

tuple对象的声明及初始化

// 显式的声明及初始化
// 模板参数既可以是空、也可以是N多个
std::tuple<int, double, char> t1 {1, 1.0, 'a'};

// 调用make_tuple,声明、初始化
// t1 和 t2的类型是一致的
auto t2 = std::make_tuple(1, 1.0, 'a');

// 空模板参数的tuple
std::tuple<> t3;

// 4个模板参数的tuple
std::tuple<int, char, long, float> t4 {1, 'a', 5L, 3.14f};

tuple对象的声明和初始化方式和特点可以总结为:

  1. 构造函数初始化
  2. make_tuple初始化
  3. tuple可以存储任意多的参数数据

取值和赋值

auto t1 = std::make_tuple(1, 3.14, 'c');

// 取值,将3.14取出来,类型为double
// 输出:3.14
std::cout << std::get<1>(t1) << std::endl;

// 赋值
std::get<1>(t1) = 5.12;
// 输出:5.12
std::cout << std::get<1>(t1) << std::endl;

对于tuple对象的取值和赋值方法及特点,可以总结为:

  1. 只能通过std::get取值和赋值,如std::get<2>可以将tuple中第2个(从0开始)参数取出来
  2. std::get可以对tuple的参数进行赋值,可以看出,std::get返回的是引用

从零开始——实现std::tuple

从这一节开始,我们就要设计我们自己的tuple了~在代码中,我们的tuple将被命名为tu

设计数据结构

我们的tu需要存储任意多的参数,这就需要用到变长模板类。

// code1
template<typename... Ts>
struct tu;

注意:

  1. C++中,structclass在内存结构上是一致的,都可以有构造函数、析构函数,成员函数和成员变量也有公有和私有之分;structclass的唯一区别就是struct的成员默认是public,而class默认是private
  2. 这里只声明并不实现

tu也可以不储存数据,也就是模板参数为空,我们来将空模板类tu特化出来。

// code2
template<> struct tu<>{};
using null_tu = tu<>;
  1. 这里将tu特化实现出来
  2. 第二行using的用法相当于typedef,这一行相当于:typedef tu<> null_tu;usingyuanlitypedef的区别就在于using可以跟模板一起使用,请参考:https://stackoverflow.com/questions/10747810/what-is-the-difference-between-typedef-and-using-in-c11

下面使用递归的模板类,将变长参数Ts的每一个类型取出来。这个类是tu的偏特化。

// code3
template<typename T, typename ...Ts>
struct tu<T, Ts...> : tu<Ts...> {
  T value;
};

这段代码是什么意思呢,举个栗子:

假如我们需要声明tu<int, char, double>,根据第一段代码(code1),code1里的Ts=[int, char, double]。但是因为code1并没有具体的实现,这个时候就匹配到第三段的偏特化代码(code3),相当于[int, char, double]匹配到T, Ts...上,也就是T=intTs=[char, double]。接着,tu<T, Ts...>继承于tu<Ts...>,这就是一个递归的继承了,我们把这个继承关系写清楚,就是struct tu<int, [char, double]> : tu<[char, double]>(伪代码,中括号中表示变长模板参数Ts),将递归展开就是struct tu<int, [char, double]> : tu<char, [double]> : tu<double> : tu<>

这段代码将所有需要的参数类型都存储进去了,接着我们就需要接着实现构造函数了。将code3修改为:

// code4
template<typename T, typename ...Ts>
struct tu<T, Ts...> : tu<Ts...> {
  tu<T, Ts...>()
      : value(T()), tu<Ts...>() {}

  explicit tu<T, Ts...>(const T &v, const Ts &...args)
      : value(v), tu<Ts...>(args...) {}

  T value;
};

先对value进行初始化,再对基类进行初始化操作。这里赋值构造函数其实是不完整的,还是以上面的tu<int, char, double>为例,tu<int, char, double>会调用基类赋值构造函数tu<char, double>tu<char, double>又会调用tu<char>的赋值构造函数,这里也没问题的。但是tu<char>这里会再调用tu<>的赋值构造函数,然而tu<>没有赋值构造函数也不需要复制构造函数。我们这里就需要将只有一个模板参数的tu再偏特化一下,code4需改为:

// code5
template<typename T, typename ...Ts>
struct tu<T, Ts...> : tu<Ts...> {
  tu<T, Ts...>()
      : value(T()), tu<Ts...>() {}

  explicit tu<T, Ts...>(const T &v, const Ts &...args)
      : value(v), tu<Ts...>(args...) {}

  T value;
};
template<typename T>
struct tu<T> : null_tu {
  tu<T>() : value(T()) {}
  explicit tu<T>(const T &v) : value(v) {}
  T value;
};

tu的存取

tu存取的原理

我们现在需要读取tu的数据了,该怎么做呢?比如说tu<int, double, char> t { 1, 3.14, 'a' };,继承关系是这样的:tu<int, double, char>tu<double, char>tu<char>tu<>,除了最后一个tu<>之外,每一层都有一个value成员变量,tu<int, double, char>value可以直接访问到,tu<double, char>value可以通过强制转换成基类指针来访问。

// code6
tu<int, double, char> t { 1, 3.14, 'a' };

// 输出:a
std::cout << ((tu<char>*)&t)->value << std::endl;

// 输出:3.14
std::cout << ((tu<double, char>*)&t)->value << std::endl;

// 输出:1
std::cout << t.value << std::endl;

tu的萃取器

上面这段代码就是访问数据的原理,有了原理,那么我们得知道些什么呢?1,必须知道要取值的类型;2,必须知道要取值的tu的类型。我们下面就要设计一个萃取器来将需要取值的类型和tu类型萃取出来。

首先,声明一个类g_impl,这个类就是我们的萃取器。

// code7
template<std::size_t Index, typename... Ts>
struct g_impl;

同样,这个部分只声明不实现。

这个萃取器g_impl我们希望实现两个功能:

  1. 将第Index个参数的类型取出来
  2. 将第Index个参数对应的类(基类)取出来
// code8
// 将第Index个参数的类型取出来
g_impl<Index, tu<Ts...> >::value_type

// 将第Index个参数对应的类(基类)取出来
g_impl<Index, tu<Ts...> >::tu_type

// 例如
using t = tu<int, double, char>;
// 将第一个类型取出来,就是double
using double_type = g_impl<1, t>::value_type;
// 将第二个类型对应的类(基类)取出来,就是tu<double, char>
using base_type1 = g_impl<1, t>::tu_type;

如何来实现呢?还是要用到递归。来看代码

// code9
template<std::size_t Index, typename T, typename ...Ts>
struct g_impl<Index, tu<T, Ts...> > {
  // 递归推理
  using value_type = typename g_impl<Index - 1, tu<Ts...> >::value_type;
  using tu_type = typename g_impl<Index - 1, tu<Ts...> >::tu_type;
};

// 将递归的推理类型补充完整,Index=0的情况
template<typename T, typename ...Ts>
struct g_impl<0, tu<T, Ts...> > {
  using value_type = T;
  using tu_type = tu<T, Ts...>;
};

// 将递归的推理类型补充完整,tu<>的情况
template<>
struct g_impl<0, nulltu> {
  using value_type = void;
  using tu_type = nulltu;
};

下面我们还是以tu<int, double, char>为例来解释,如果我们要萃取第1个参数的类型double,写全了就是

// code10
// 伪代码,[double, char]表示可变模板参数 ...Ts
struct g_impl<1, tu<int, [double, char]> > {
  using value_type = typename g_impl<0, tu<double, char> >::value_type;
};

我们再将typename后面的g_impl<0, tu<double, char> >补全:

// code11
// 伪代码,[char]表示可变模板参数 ...Ts
struct g_impl<0, tu<double, [char]> > {
  // 当Index为0时,就不需要再递归了,就是当前的类型
  using value_type = double;
}

tu_type的递归推倒也是一样的原理,大家可以自行推倒理解。

tu的取值赋值函数

有了类型的萃取器,我们就能知道目标参数类型和参数对应的类(基类)。再结合上面取值的原理

template<std::size_t Index, typename ...Ts>
typename g_impl<Index, tu<Ts...> >::value_type& g(tu<Ts...> &t) {
  using tu_t = typename g_impl<Index, tu<Ts...> >::tu_type;
  return ((tu_t *)&t)->value;
}

g_impl部分就是萃取器。注意,返回值是typename g_impl<Index, tu<Ts...> >::value_type&,是个引用类型。使用引用返回值,就可以实现对tu的数据的需改了。

完整代码

// 变长模板参数
// 只声明不实现
template<typename ...Ts>
struct tu;

// 空模板参数的特化
template<> struct tu<>{};

using nulltu = tu<>;

// 递归的类型
// 偏特化,相当于tu的“重载函数”
template<typename T, typename ...Ts>
struct tu<T, Ts...> : tu<Ts...> {
  using base_type = tu<Ts...>;
  using this_type = tu<T, Ts...>;
  using type = T;

  tu<T, Ts...>()
      : value(T()), base_type() {}

  explicit tu<T, Ts...>(const T &v, const Ts &...args)
      : value(v), base_type(args...) {}

  T value;
};

// 将递归的类型补充完整
template<typename T>
struct tu<T> : nulltu {
  using base_type = nulltu;
  using this_type = tu<T>;
  using type = T;
  tu<T>() : value(T()) {}
  explicit tu<T>(const T &v) : value(v) {}
  T value;
};

// 实现g<0>(tu)的声明
template<std::size_t Index, typename ...Ts>
struct g_impl;

// 相当于第二个参数传入tu的重载
// 将T分离出来
template<std::size_t Index, typename T, typename ...Ts>
struct g_impl<Index, tu<T, Ts...> > {
  // 递归推理
  using value_type = typename g_impl<Index - 1, tu<Ts...> >::value_type;
  using tu_type = typename g_impl<Index - 1, tu<Ts...> >::tu_type;
};

// 将递归的推理类型补充完整,Index=0的情况
template<typename T, typename ...Ts>
struct g_impl<0, tu<T, Ts...> > {
  using value_type = T;
  using tu_type = tu<T, Ts...>;
};

// 将递归的推理类型补充完整,tu<>的情况
template<>
struct g_impl<0, nulltu> {
  using value_type = nulltu;
  using tu_type = nulltu;
};

// typename g_impl<Index, tu<Ts...> >::value_type& 为返回类型的推理
// tu_t为第Index个基类的类型
// 强制转换成基类的类型,并访问变量
template<std::size_t Index, typename ...Ts>
typename g_impl<Index, tu<Ts...> >::value_type& g(tu<Ts...> &t) {
  using tu_t = typename g_impl<Index, tu<Ts...> >::tu_type;
  return ((tu_t *)&t)->value;
}

// 实现make_tuple函数
template<typename ...Ts>
tu<Ts...> make_tu(const Ts &...args) {
  return tu<Ts...>{args...};
}

总结

在搞清楚tuple的原理之后,发现了几个特点:

  1. 可变模板参数都是通过递归来获取每个参数的类型
  2. 递归类型推倒的时候需要将所有递归类型的情况都想清楚,必须将类型补充完整
  3. 模板元编程大概就是SFINAE(substitution failure is not an error,匹配失败不是错误)吧(关于SFINAE是什么,请看https://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error

就写到这里吧~希望对看到文章的人能有所帮助~

Last modification:November 13, 2018
If you think my article is useful to you, please feel free to appreciate