Type traits 练习:像操作 vector 一样操作 tuple 类型

前排提醒:本文仅做学习交流之用,实际项目中如有条件请尽量使用 Constraints and concepts (since C++20) 。

Type traits 是 C++ 中通过 SFINAE (Substitution Failure Is Not An Error) 机制在编译期获取静态类型信息的一种技术。由于单一模板特化匹配失败并不一定造成最终编译失败,因此我们可以构造一些模板的特化,去尝试匹配一个静态类型,如果匹配上了,那说明这个静态类型符合我们规定的一些性质,接下来我们就可以利用这个性质去提取我们想要的信息,从而实现“萃取”。

enable_if 说起

在进入正文之前,我们先举个经典且简单的例子——实现一个 enable_if,初步感受一下 Type traits。

template <bool Cond, typename T = void>
struct enable_if {};

首先 enable_if 的模板参数应该含有判断条件 Cond,以及一个可选的条件成立时返回的类型 T,我们可以写一个 enable_if 的偏特化来实现这个萃取。

template <typename T>
struct enable_if<true, T> {
using type = T;
}

这样,外部在需要对某个类型做一些约束的时候,就可以使用 enable_if 来实现了(enable_if 可以用在很多地方)

// 当 T 不是整数类型时,enable_if 结构体内没有 type,此时模板匹配失败
// 由于已经没有其他模板,因此最终编译失败
template <typename T, typename = enable_if<std::is_integral_v<T>>::type>
void do_sth_on_integral(T &&) {}

以上代码可以在 godbolts 自己尝试一下。

一些有用的工具

再开始研究 Tuple 的类型之前,我们先基于 boost 写个打印参数类型和参数取值的工具函数,方便后续验证和测试。

#include <boost/type_index.hpp>
#include <iostream>
using boost::typeindex::type_id_with_cvr;
template <typename T>
constexpr void print_type() {
std::cout << type_id_with_cvr<T>().pretty_name() << "\n";
}
constexpr void print_value(auto &&value) {
std::cout << value << "\n";
}

声明一个 Tuple

回到主题,我们定义一个 Tuple 来保存类型列表信息(如果对可变模板参数还不了解可以参考这里)。

template <typename... Ts>
struct Tuple {};
print_type<Tuple<int, float>>(); // Tuple<int, float>

注意,由于本文我们只关注对 Tuple 类型的操作,因此这里并不对 Tuple 的定义做任何细化。

concat_type: 合并两个 Tuple 的类型

我们先从一个简单的例子开始,试着合并两个 Tuple 的类型,构造一个新的 Tuple,可以想到默认模板应该是长这样:

template <typename Tup1, typename Tup2>
struct concat_type {};

这个默认模板都没有对 Tup1Tup2 做任何约束,它们甚至都不一定是 Tuple 类型。因此,我们需要假设 Tup1Tup2 是由特定类型序列构成的 Tuple 类型,并基于这个假设构造一个偏特化模板

// 假设 Tup1 是 Tuple<Ts1...>,Tup2 是 Tuple<Ts2...>,基于此编写偏特化模板,
// 如果匹配到这个偏特化模板说明类型参数符合这个假设
template <typename... Ts1, typename... Ts2>
struct concat_type<Tuple<Ts1...>, Tuple<Ts2...>> {
using type = Tuple<Ts1..., Ts2...>;
};
print_type<concat_type<Tuple<int, float>, Tuple<char>>::type>(); // Tuple<int, float, char>

push_front_type: 在 Tuple 前面添加一个类型

类似地,默认模板还是比较好定义的:

template <typename T, typename Tup>
struct push_front_type {};

同样,我们基于 「TupTuple<Ts...> 类型」 这个假设编写偏特化模板:

// 构造一个 push_front_type 的偏特化来容纳特定类型的 Tuple
template <typename T, typename... Ts>
struct push_front_type<T, Tuple<Ts...>> {
using type = Tuple<T, Ts...>; // 然后我们就可以合并声明一个新的 Tuple 类型了
};

push_back_typepop_front_typepop_back_type 也是差不多的,这里不再赘述,留给聪明的读者有空练习~

insert_type: 在 Tuple 中插入一个类型

template <size_t N, typename T, typename Tup>
struct insert_type {};

insert_type 的模板多了一个模板参数 N 表示插入的位置,看起来要难一些。这时候我们需要利用递归的思想——在 Tuple 第 N 个位置插入类型 T,等价于把 Tuple 的第一个类型 T0 提取出来之后,与 Tuple 的剩余类型的第 N - 1 个位置插入类型 T 合并起来,即 insert_type<N, T, Tup<T0, Ts...>>::type == concat_type<Tuple<T0>, insert_type<N - 1, T, typename Tuple<Ts...>>>::type

// 偏特化 1
template <size_t N, typename T, typename T0, typename... Ts>
struct insert_type<N, T, Tuple<T0, Ts...>> {
using type = concat_type<Tuple<T0>, typename insert_type<N - 1, T, Tuple<Ts...>>::type>::type; // 这里 typename 表明 ::type 是类型而不是其他东西
};

接下来我们需要对递归终止的情况做偏特化:

// 偏特化 2: 当 N == 0 且 Tuple 仍剩余部分类型时,类型 T 插入到当前 Tuple 的最前方
template <typename T, typename T0, typename... Ts>
struct insert_type<0, T, Tuple<T0, Ts...>> {
using type = Tuple<T, T0, Ts...>;
};
// 偏特化 3: 当 N == 0 且 Tuple 已经没有类型时,我们直接返回一个 Tuple<T>
template <typename T>
struct insert_type<0, T, Tuple<>> {
using type = Tuple<T>;
};

可能有小伙伴会问,为什么需要 T0?去掉 T0 后,两个偏特化模板不就能合并为一个了,看起来还更简洁:

template <typename T, typename... Ts>
struct insert_type<0, T, Tuple<Ts...>> {
using type = Tuple<T, Ts...>;
};

这里主要是因为这个偏特化与偏特化 1 特化程度相同(一个对 N 更加特化,一个对 Tup 更加特化),因此编译器无法决议使用哪个偏特化模板

另外,erase_typeinsert_type 是类似的,留给读者实现啦~

get_type: 获取 Tuple 特定下标的类型

定义好默认模板:

template <size_t N, typename Tup>
struct get_type {};

同样使用递归的思路,如果当前 N 不为 0,我们就忽略 Tuple 中的第一个类型,取从第二个类型开始的第 N - 1 个,以此类推。

因此,在下面的代码中,我们假设当前 Tuple 中是存在一个以上类型的,那我们就可以把第一个萃取出来,写一个 get_type 的偏特化模板:

template <size_t N, typename T, typename... Ts>
struct get_type<N, Tuple<T, Ts...>> {
// 利用 get_type<N, Tuple<T, Ts...> == get_type<N - 1, Tuple<Ts...>> 递推式
using type = get_type<N - 1, Tuple<Ts...>>::type;
};

以上的递归偏特化并没有一个终止条件,我们需要加上当 N == 0 的情况:

template <typename T, typename... Ts>
struct get_type<0, Tuple<T, Ts...>> {
using type = T; // 当 N == 0 时,我们直接取当前 tuple 的第一个类型
};

这样我们就实现了对特定下表 Tuple 类型的获取。

print_type<get_type<1, Tuple<int, float>>::type>(); // float
print_type<get_type<2, Tuple<int, float>>::type>(); // compile error: no type named 'type' in 'get_type<0, Tuple<>>'

front_type: 获取 Tuple 的第一个类型

前面我们已经实现了 get_type,因此 front_type 实际上直接继承 get_type 就可以了:

template <typename Tup>
struct front_type : public get_type<0, Tup> {};
print_type<get_type<0, Tuple<int, float>>::type>(); // int

然而,back_type 就没有那么直接了,因为我们首先需要知道 Tuple 中有多少个类型。可以在实现 size_type 之后再继承 get_type 实现(留给读者做作业 🥵 懒)

empty_type: 判断 Tuple 类型是否为空

这个也很简单,只是此时结构体中包含的不再是 type,而是标识 Tuple 类型是否为空的静态布尔值常量了。

template <typename Tup>
struct empty_type {
static constexpr bool value = false;
};
template <>
struct empty_type<Tuple<>> {
static constexpr bool value = true;
};
print_value(empty_type<Tuple<int>>::value); // 0
print_value(empty_type<Tuple<>>::value); // 1

size_type: 获取 Tuple 类型的个数

template <typename Tup>
struct size_type {};
template <>
struct size_type<Tuple<>> {
static constexpr size_t value = 0;
};
template <typename T0, typename... Ts>
struct size_type<Tuple<T0, Ts...>> {
static constexpr size_t value = 1 + size_type<Tuple<Ts...>>::value;
};
print_value(size_type<Tuple<int, float, char>>::value); // 3
print_value(size_type<Tuple<int>>::value); // 1
print_value(size_type<Tuple<>>::value); // 0

后语

至此,通过对 Tuple 类型操作的简单实现,我们应该能对 Type traits 有个初步的体会了,如果觉得知识不够巩固,有空的话自己写一遍吧~

以上代码均分享在 godbolts 上了,大家感兴趣的话可以在此基础上添加一些 type traits,那...先这样啦 🥳