首先得说一下, 我自己实现c++的tuple只是为了学习里面的主要思路和技巧了, 至于里面浩如烟海的一堆无关细节, 因能力有限只能一并忽略...也就是说我所实现的tuple并不甚严谨, 只能算个玩具, 若有错误还请指正...(求生欲拉满
1. 试图将对tuple读取元素函数的下标参数作为函数参数而非模板非类型参数.
比如我们想实现一个读取tuple里面元素的函数tuple_get, 并希望使用下标i来读取每个元素, 就像数组一样. 这时候有两种选择:
//例如对于一个tuple</* some types... */>类型的对象tup,
//以及一个接受任意tuple所实例化的类型的对象, 和所要获得的元素的下标i, 并返回对应元素(其引用)的函数模板tuple_get,
//1. 将下标参数作为函数参数:
tuple_get(tup, i) = /* new value */;
//2. 将下标参数作为函数模板的非类型参数:
tuple_get<i>(tup) = /* new value */;
那么这时应该选择哪种方式比较好呢?
事实上, 我们只能选择第二种方式, 将下标参数作为函数模板的非类型参数.
原因很简单, 这个tuple_get函数模板必须在编译时就知道它要返回什么类型的值, 因为函数并不能在运行时返回任意的类型! 而使用函数模板, 就可以在编译时确定返回值, 本质上是编译器为tuple里的每个元素都生成一个对应的获取函数. 反过来, 如果将下标作为函数参数, 那么每个tuple类型实际上只会生成一个函数, 而这个函数到底应该返回什么类型呢? 它可没法动态地通过下标来获取要返回的类型. 要知道c++里可不是像python, js之类的解释型语言, 它没有动态类型这些说法! 如果说你想返回一个指针或者引用之类的什么东西, 然后在调用处进行指针强转, 那么使用这个tuple还不如直接使用void*类型的数组呢. 这样元素的内存空间都要在运行时开辟, 简直与Java的路子无异. 这将使性能大打折扣.
将下标作为模板非类型参数, 显然这时的下标必须是一个编译时即可算得的常量, 诸如整型字面量, 带有constexpr修饰的变量等都可以作为参数. 这个tuple_get函数模板在编译时就可以得知他要返回的类型.如果有对tuple进行遍历的需求, 可以使用模板元编程之类的方法实现.
2. 将tuple读取元素的函数作为tuple的成员函数.
还是关于通过下标获取指定元素的函数:
//假设我们已经编写了一个获取某个tuple指定下标元素的类型的元函数: get_element_t<typename TupleT, size_t Index>
//对于c++14及之后的版本, 可以直接使用auto来让编译器去推导返回值.
//1. 将函数作为tuple的成员函数:
template<typename... Ts>
struct tuple {
//我们忽略掉诸多细节
...
template <size_t Index>
constexpr get_element_t<tuple, Index>& get();
...
};
//2. 将函数作为非成员函数:
template <size_t Index, typename TupleT>
constexpr get_element_t<TupleT, Index>& tuple_get(TupleT& tup);
在这里constexpr的修饰符可以使编译器对这些函数在编译期即计算出结果(要返回的元素引用), 如果不加constexpr, 在模板递归的时候可能会多出很多层函数调用, 这是得不偿失的. 在这里的两种写法, 本人其实是比较喜欢第一种的, 因为这样和其他数据结构中获取元素的.get/.at函数的风格一致. 然而, 在c++11, 对于constexpr修饰的非静态成员函数有个麻烦的限制: 它们都会被自动加上const修饰, 也就是const成员函数. 这使得这些函数返回的所有成员变量都带上了const属性, 也就是说, 这个get函数所返回的元素引用是不可改变的! (事实上, 上面的那个get函数的返回值类型必须是const get_element_t<tuple, Index>&, 否则会报错) 这样的一个获取元素却不能修改元素值的函数显然不能让我们满意(或许使用const_cast转换走返回值的常量性是个好主意, 但本人没有测试过).而对于普通函数, 则没有这样的限制.因此更好的写法是选择第二种写法. 自c++14起, constexpr被取消了这样的限制, 因此两种应该是皆可的.
3. 试图通过 ? : 三元运算符表达式代替模板特化实现编译期选择语义.
依旧拿get函数讨论. 因为我们写的函数都要带上constexpr, 在c++14之前不允许多行语句, 所以常常使用三元运算符代替if, 函数递归代替循环, 对于c++14之后的代码也可参考.
你可能会写出这样似是而非的玩意:
//对tuple节点的简单定义:
//空节点
template <typename...>
struct tuple_node {};
template <typename T, typename... Ts>
//实际上使用private继承+友元函数的方式更好, 但在此方便起见使用public继承
struct tuple_node<T, Ts...>: public tuple_node<Ts...> {
using next_t = tuple_node<Ts...>;
T value;
}
...
//get_impl函数由get函数调用, 用来递归求得返回值
//TargetIndex是要获取的元素的下标; ThisIndex是当前递归状态的下标, 可以理解为for循环里的i;
//ReturnT即要获取的元素的类型, 由上层调用函数直接给出; NodeT即当前递归状态时的Tuple节点类型, 由函数参数推导得出
template <size_t TargetIndex, size_t ThisIndex, typename ReturnT, typename NodeT>
constexpr ReturnT& get_impl(NodeT& node) {
return ThisIndex < Index ?
//未达到指定下标则继续向深层递归, 其中NodeT::next_t是NodeT的父类类型, 在tuple_node的类模板里使用typedef或using定义,
//整个static_cast转换将node转换为下一层node
get_impl<TargetIndex, ThisIndex + 1, ReturnT>(static_cast<typename NodeT::next_t>(node)) :
//达到指定下标则返回元素值
node.value;
}
问题在于get_impl里的三元运算符, 乍一看好像毫无破绽, 然而一编译, 爆出的一坨坨翔一样的错误能让你直接网抑云... 当你耗死了一堆脑细胞找到了真正的报错解释, 却发现编译器说这个三元运算符两个分支里表达式的值类型不匹配? 在表面上, 我们代码里两个分支的值类型是一样的, 都是我们要的ReturnT类型, node.value不解释, 对于另一个分支里的递归, 它的返回值同样也是ReturnT. 那么问题出在哪呢?
我们可以更仔细地想想, 假如ThisIndex == Index时会发生什么? 这时已经达到了递归尾, 按照我们所希望的情况, 应该返回第二个分支node.value. 然而这时我们很容易忽略掉第一个分支的返回值. 虽然已经到达递归尾, 这个分支实际上已经不会reach到, 但编译器依旧会继续生成下一层的函数来推断函数的返回值! 后果就是会编译器会一直递归下去直到这个tuple的尾节点, 它已经没有next_t的定义, 也没有父类了, 因此报错.
事实上, 像类似的这种模板递归, 使用三元运算符来进行判断的函数, 基本都没法通过编译. 因为它没有模板SFINAE的功能——特化失败即抛弃这个分支, 而非报错. 正确的方法就是使用模板偏特化来实现选择语义. 虽然函数模板不能偏特化, 但是我们可以通过外覆一个类模板, 将偏特化转嫁给它, 自身作为静态成员函数的方式曲线救国, 实现偏特化.
另外, 自c++17始, constexpr if的新特性已经可以替代部分情况下的偏特化了, 这样同样也可以实现编译期选择语义.
To be continued...