Intro.std::tuple
的使用场景不算很多,但是std::tuple
的实现手法中却蕴含了模板元编程的很多思想和方法,下面我们来浅析std::tuple
的实现和其背后的“黑魔法”。
Part 1. 用例
我们先来了解std::tuple
的使用:auto t = std::tuple {1, 2.0f, "abc"};
std::cout << std::get<0>(t) << ":" << std::get<1>(t) << ":" << std::get<2>(t) << std::endl;
*该程序会输出1:2:abc
可以很轻松地看出来,我们在第一行创建了一个std::tuple
的对象,在第二行获取其中的元素并输出。
但是你可能会好奇:当我们获取std::tuple
内的元素时,为什么会采用模板的方式传入索引,而非像常规的数组或者STL容器一样使用[]
运算符或者at()
函数?
这就涉及到std::tuple
的设计和实现了,下面让就我们一起来了解。
Part 2. 实现std::tuple
的引入,解决了std::pair
只能作为二元组的问题,使得多元组可以出现在C++中。
std::tuple
的定义如下:
template<typename ...T> struct tuple;
可以看到,std::tuple
接受了一个模板形参包,而C++提供的展开形参包的方法只有一种,我们无法直接获取形参包中某一具体形参的类型,而std::get
又要求获取指定索引处的元素,那么,该如何解决这个问题呢?
这里我们需要用到模板实例化的知识:
不严谨地来讲,相同类型的每个不同模板实例化的结果都可以看作是相互不同的类型,例如类型:template<T1, T2> class A;
虽然A<int, int>
和A<int, double>
都是模板A的实例化,但是它们是两种类型(且在底层中的符号表示也不同),因此,我们就可以拥有类似这样的关系:class A<int, int> : public A<int, double>
这也是我们逐步解开形参包的基础。
你也许会略有疑惑,不要着急,看完下面的说明,你会恍然大悟的:
上面我们提到,我们可以在同一个类的不同模板的实例化间构建继承关系,所以,我们可以利用这一性质做出如下写法:template<typename ...T> struct tuple;
template<> struct tuple<> { };
template<typename T1, typename ...Tr>
struct tuple<T1, Tr...> : tuple<Tr...> {
// ...
};
这个继承关系看起来有点绕,接下来让我们逐步来拆解它:
例如,当我们使用tuple时,传入了类型参数T1, T2, T3, T4,由于<T...>tuple
只是个声明,下方有它的特化<T1, Tr...>tuple
,所以匹配到<T1, Tr...>tuple
,准备将其实例化为tuple<T1, T2, T3, T4>
;
而其继承tuple<Tr...>
,于是准备将tuple<Tr...>
实例化为tuple<T1, T2, T3>
;
而tuple<Tr...>
类型又继承下一个tuple<Tr...>
类型,于是编译器准备实例化tuple<T1, T2
>
以此类推,最终编译器匹配到tuple<>
时,发现已有特化,于是依据该特化进行实例化,进而依次实例化其它tuple
类型,最终形成如下的继承关系:template<> struct tuple<> { };
template<> struct<T1> : tuple<> { /* ... */};
template<> struct tuple<T1, T2> : tuple<T1> { /* ... */};
//...
template<> struct tuple<T1, T2, T3, T4> : tuple<T1, T2, T3> { /* ... */ };
于是,我们便能通过tuple的不同实例化来访问目标元素,像这样:template<typename T1, typename ...Tr>
struct tuple<T1, Tr...> : tuple<Tr...> {
T1 value;
using base_type = tuple<Tr...>;
tuple() { }
tuple(T1 v, Tr... args) : value(v), base(args...) { }
};
这样实例化后,模板参数最多的(也是继承关系中最底层的子类),其T1类型就是第一个元素的类型,这样依次继承,模板参数越少,继承关系越高,含有的元素位置在元组中越靠后。
只要我们访问对应类型的value,我们就可以获取指定位置的目标元素。
当我们使用tuple时,我们只能直接访问到tuple<T...>
类型,那么,我们要如何访问对应类型的value呢?
因为继承关系的存在,使得我们可以进行类型转换,例如,通过把tuple<T2, T1>
转换成tuple<T1>
类型,我们就可以获取
的第二个元素。tuple<T2, T1>
所以,我们的get
函数可以如此实现:template<int index, typename Tuple>
auto& get(Tuple& t) {
return static_cast<get_tuple_type<index, Tuple>::type>(t).value;
}
接下来的难点,在于获取目标元组的类型,即<int, T>get_tuple_type
的实现。
这一点,我们也可以依照tuple
的实现方式来“照葫芦画瓢”:template<int sz, typename Tuple> get_tuple_type {
using type = typename get_tuple_type<sz - 1, Tuple::base_type>::type;
};
template<typename Tuple> get_tuple_type<0, Tuple> {
using type = Tuple;
};
这样,在使用get_tuple_type
时就会不断向上查找给定元组的父类,直到找到给定位置的父类,返回其类型。(越界会编译失败,因为越界会导致所有类型被遍历,最后只剩下tuple<>,结构体内部为空,对tuple<>::type的访问是无效的)
这样,我们终于能够正常地创建和访问tuple了。
Part 3. 结语
除了构建、访问,tuple显然还需满足其它要求,才能成为一个合格的工具,例如元组间的比较、交换、复制、大小计算、元组的解包、与标准库其它类的适配等等要求,本文只对tuple的实现进行浅析,并借此来学习C++中模板元编程的手法和技巧之一——利用继承关系进行递归解包形参包,并且利用类型转换来访问继承关系中的任一类型对象。至于与tuple相关的其它函数或者类的实现,或者更严格的tuple设计,希望读者看完本文有所启发,也能自行探究tuple的奥秘和领略C++模板的“魅力”。