|
3. Learn TMP in Use (Part I)
本章中,我们通过几个正经一点的例子,来进一步了解 TMP,以及了解什么是 Metafunction Convention。这些例子大部分来自标准库的 <type_traits>。
3.1 Example 1: Type Manipulation
3.1.1 is_reference
下面的这个模板可以判定一个类型是不是引用类型:
- template <typename T> struct is_reference { static constexpr bool value = false; }; // #1
- template <typename T> struct is_reference<T&> { static constexpr bool value = true; }; // #2
- template <typename T> struct is_reference<T&&> { static constexpr bool value = true; }; // #3
- std::cout << is_reference<int>::value << std::endl; // 0
- std::cout << is_reference<int&>::value << std::endl; // 1
- std::cout << is_reference<int&&>::value << std::endl; // 1
复制代码
is_reference 包含一个主模板和两个偏特化,它接受一个类型 T 作为参数。当 T 传入一个右值引用类型时,编译器会选择 #3 这个模板特化来进行实例化;当 T 传入一个左值引用类型时,编译器会选择 #2 这个模板特化来进行实例化;而当 T 不是引用类型时,#2 和 #3 都不匹配,编译器选择主模板 #1 来进行实例化。想要理解编译器是怎么做出这种决定的,我们假想这样一个过程(为了方便描述,我们记 #1 的形参为 T1,#2 的形参为 T2,#3 的形参为 T3):
对于 is_reference<int> 这个实例化,编译器首先确定了模板的实参为 int,于是它尝试将 int “代入” 模板的特化中看是否匹配,并且要反向推导该特化的形参,看能否推导成功。将 int 代入 #2,是匹配的,T2& 匹配 int,但反向推导 T2 失败,因为没有任何一个类型加上引用符号后能和 int 相等,所以编译器不选择 #2。同理,代入 #3,T3&& 匹配 int,反向推导失败,编译器也不能选择 #3。于是编译器只能选择主模板 #1。
对于 is_reference<int&> 这个实例化,编译器首先确定了模板的实参为 int&,将 int& “代入” 模板的特化中并反向推导。代入 #2,T2& 匹配 int&,但反向推导 T2 = int,成功;代入 #3,T3&& 匹配 int&,反向推导 T3 失败(引用叠加规则在此处不适用);于是选择#2。
3.1.2 remove_reference
除了判定一个引用类型外,我们还可以移除一个类型的引用:
- template <typename T> struct remove_reference { using type = T; }; // #1
- template <typename T> struct remove_reference<T&> { using type = T; }; // #2
- template <typename T> struct remove_reference<T&&> { using type = T; }; // #3
- // case 1:
- int&& i = 0;
- remove_reference<decltype(i)>::type j = i; // equivalent to: int j = i;
- // case 2:
- template <typename T>
- void foo(typename remove_reference<T>::type a_copy) { a_copy += 1; }
- foo<int>(i); // passed by value
- foo<int&&>(i); // passed by value
复制代码
同样的一个主模板和两个偏特化,同样的匹配规则。例如,对于 remove_reference<int&&>,会匹配 #3,#3 中的 T 被推导为 int,于是 remove_reference<int&&>::type 就是 int。这里你可能会有疑问,移除引用的语义是什么?一个变量被移除了引用之后是变成了一份拷贝了吗?这里我们再次强调,TMP 工作在编译期,在编译期没有变量,只有常量和类型,这里的移除引用就是把一个引用类型变成对应的非引用类型。
另一个问题是,“case 2” 中为什么 remove_reference 的前面要加一个 “typename” 关键字?这是因为 remove_reference<T>::type 是一个待决名(Dependent Name),编译器在语法分析的时候还不知道这个名字到底代表什么。对于普通的名字,编译器直接通过名字查找就能知道这个名字的词性。但对于待决名,因为它是什么取决于模板的实参 T,所以直到编译器在语义分析阶段对模板进行了实例化之后,它才能对“type”进行名字查找,知道它到底是什么东西,所以名字查找是分两个阶段的,待决名直到第二个阶段才能被查找。但是在语法分析阶段,编译器就需要判定这个语句是否合法,所以需要我们显式地告诉编译器 “type” 是什么。在 remove_reference<T>::type 这个语法中,type 有三种可能,一是静态成员变量或函数,二是一个类型,三是一个成员模板。编译器要求对于类型要用 typename 关键字修饰,对于模板要用 template 关键字修饰,以便其完成语法分析的工作。
3.2 Metafunction Convention
我们已经看了两个例子了,是时候总结一些元编程的通用原则了,我们称之为 Metafunction Convention。
3.2.1 Metafunction always return a "type"
程序是逻辑和数据的集合。is_reference 和 remove_reference 是两个类模板,但是在 TMP 中,它们接受实参,返回结果,是像函数一样地被使用。我们称这种在编译期“调用”的特殊“函数”为 Metafunction,它代表了 TMP 中的“逻辑”。Metafunction 接受常量和类型作为参数,返回常量或类型作为结果,我们称这些常量和类型为Metadata,它代表了 TMP 中的“数据”。进一步地,我们称常量为 Non-type Metadata (or Numerical Metadata),称类型为 Type Metadata。
但在上面的例子中我们看到,is_reference 的返回值名为 “value”,remove_reference 的返回值名为 “type”,为了形式化上的一致性,Metafunction Convention 规定,所有的 Metafunction 都以 “type” 作为唯一的返回值,对于原本已 “value” 指代的那些常量,使用一个类模板将它们封装起来,Metafunction 返回这个类模板的相应实例。我们举例说明:
- // non-type metadata (or numerical metadata)
- template <bool b>
- struct bool_ { static constexpr bool value = b; };
- // metafunction
- template <typename T> struct is_reference { using type = bool_<false>; };
- template <typename T> struct is_reference<T&> { using type = bool_<true>; };
- template <typename T> struct is_reference<T&&> { using type = bool_<true>; };
复制代码
我们定义了一个名为 bool_ 的类模板,以封装 bool 类型的常量,is_reference 的返回值就变成了 bool_ 和 bool。在调用 is_reference 时,也是使用 “type” 这个名字,如果想访问结果中的布尔值,使用 is_reference<T>::type::value 即可。
注意,Metafunction Convention 的这种规定,并不是 C++ 语言上的要求,而是编程指导上的要求,目的是规范元编程的代码,使其更具可读性和兼容性。标准库、boost、github 上热门的 TMP 库都遵循了这一约定,你也应该遵守。
3.2.2 integral_const
在真实世界的场景中,一个典型的 Non-type Metadata 是这样定义的:
- template <typename T, T v>
- struct integral_constant {
- static constexpr T value = v;
- using value_type = T;
- using type = integral_constant; // using injected-class-name
- constexpr operator value_type() const noexcept { return value; }
- constexpr value_type operator()() const noexcept { return value; }
- };
复制代码
这是我们在 TMP 中最常用的一个 Non-type Metadata,它除了像 bool_ 那样定义了一个 value 外,还定义了:
value_type 指代数据的类型
type 指代自身,即 integral_constant,这个成员使得 integral_constant 变成了一个返回自己的 Metafunction
operator value_type() 是到 value_type 的隐式类型转换,返回 value 的值
value_type operator() 是函数调用运算符重载,返回 value 的值
这些成员,特别是 type,都会使 TMP 变得更方便,后面会看到例子。通常我们在使用时还会定义一些 alias:
- // alias
- template <bool B> using bool_constant = integral_constant<bool, B>;
- using true_type = bool_constant<true>;
- using false_type = bool_constant<false>;
复制代码
有了这些定义,is_reference 的定义就变成了:
- template <typename T> struct is_reference { using type = false_type; };
- template <typename T> struct is_reference<T&> { using type = true_type; };
- template <typename T> struct is_reference<T&&> { using type = true_type; };
复制代码
对它的调用就变成了:
- std::cout << is_reference<int>::type::value; // 0
- std::cout << is_reference<int>::type(); // 0, implicit cast: false_type --> bool
- std::cout << is_reference<int>::type()(); // 0
复制代码
3.2.3 use public inheritance
当一个 Metafunction 使用另一个 Metafunction 的结果作为返回值时,不用自己定义 type 成员了,只需要直接继承另一个 Metafunction 即可!比如,我们可以这样实现 is_reference:
- template <typename T> struct is_reference : public false_type {};
- template <typename T> struct is_reference<T&> : public true_type {};
- template <typename T> struct is_reference<T&&> : public true_type {};
复制代码
由于 true_type 和 false_type 内部定义了一个名为 “type” 的成员,而且这个成员指的是它们自己,所以直接继承过来,is_reference 内部也就有了一个名为 “type” 的成员了。类似地,我们可以实现一个新的 Metafunction,它判定一个类型是不是 int 或引用类型:
- // another metafunction implemented by inheritance.
- template <typename T> struct is_int_or_reference : public is_reference<T> {};
- template <> struct is_int_or_reference<int> : public true_type {};
- // metafunction call
- std::cout << is_int_or_reference<int>::value; // 1
- std::cout << is_int_or_reference<int>(); // 1
- std::cout << is_int_or_reference<int>()(); // 1
复制代码
公有继承和直接定义“type” 成员,这两种方式效果类似,但有一些细微的差别,例如继承的时候不仅 “type” 成员被继承过来了,“value” 也被继承了过来。我们在 TMP 中会尽可能地使用这种继承的方式,而不是每次都去定义type。因为这种方式实现的代码更简洁,也更具有一致性:当一个 Metafunction 依赖另一个 Metafunction 时,就是应该直接获取另一个 Metafunction 的全部内容。这种继承的形式可能一开始看不习惯,但用多了就会觉得真香。
另外,我们在定义类模板时,使用 struct 关键字,而不使用 class 关键字,这样就可以省略继承时的 public 关键字,以及类模板定义内部的 public 关键字了。
我们再来看一个特殊的 Metafunction,可能是 TMP 中最简单的一个 Metafunction 了:
- template <typename T>
- struct type_identity { using type = T; };
- type_identity<int>::type i; // equivalent to: int i;
- type_identity 这个模板接受一个形参 T,并返回 T 本身。你可能会疑惑这样的一个东西有什么用,实际上它非常有用,结合前面提到的公有继承,它可以让你在一行代码内就写完一个 Metafunction:
- // with type_identity, we can implement remove_reference like this:
- template <typename T> struct remove_reference : type_identity<T> {};
- template <typename T> struct remove_reference<T&> : type_identity<T> {};
- template <typename T> struct remove_reference<T&&> : type_identity<T> {};
复制代码
特别是当你使用一些自动格式化代码的工具(如 clang-format)时,如果你采用传统的 using type = ... 的写法,工具就会自动帮你换行,因为一般类的头部和定义体是不能放在一行的。但是通过结合公有继承和 type_identity,类的定义体变成了空的,工具就会允许你在一行内写完一个 Metafunction 了!
说出来你可能不信,就是这么一个看起来有点蠢的 type_identity,在 C++20 被加入到了标准库当中。
3.2.4 useful aliases
为了方便,我们通常还会创建两个东西来简化 Metafunction 的调用。
一、对于返回非类型常量的 Metafunction,我们定义一个 _v 后缀的变量模板(Variable Template),通过它可以方便地获取 Metafunction 返回的 value:
- // variable template
- template <typename T> inline constexpr bool is_reference_v = is_reference<T>::value;
复制代码
二、对于返回一个类型的 Metafunction,我们声明一个 _t 后缀的别名模板(Alias Template),通过它可以方便地获取 Metafunction 返回的 type:
- // alias template
- template <typename T> using remove_reference_t = typename remove_reference<T>::type;
复制代码
效果如下:
- std::cout << is_reference_v<remove_reference_t<int&&>> << std::endl; // output: 0
复制代码
在后面的代码中,为了使代码更简单一些,我们只展示 Metafunction 本身的实现,省略 _v 和 _t 相关的内容。
3.3 Example 2: Metafunction with Multiple Arguments
目前我们举的例子全部是单个模板形参的,本节中我们看一看多个形参的例子。
3.3.1 is_same
假如说我们想判定两个类型是否相等,并以此来写一些逻辑,应该怎么做呢?在 Python 中,我们用 isinstance 就可以了,但在 C++ 中,由于缺乏自省机制,所以普通的代码是不可能实现下面这种效果的:
- int i = 0;
- std::cout << is_same_v<decltype(i), int> << std::endl; // 1
- std::cout << is_same_v<decltype(i), float> << std::endl; // 0
- if (is_same_v<decltype(i), int>) {
- // ...
- } else {
- // ...
- }
复制代码
这里你可能会想到 RTTI(Run-Time Type Information)的机制,但 RTTI 不同编译器的实现可能有差别,它的本意是为了实现 C++ 内部的一些语言机制,主要是动态多态(Dynamic Polymorphism),因此依赖 RTTI 的代码可能不具备可移植性。但是通过 TMP,我们可以实现一个 Metafunction 来达到判定类型的效果,原理非常简单:
- template <typename T, typename U>
- struct is_same : false_type {};
- template <typename T>
- struct is_same<T, T> : true_type {};
复制代码
is_same 是一个类模板,它有两个模板形参,T 和 U,它的主模板继承了 false_type,另外有一个特化继承了 true_type,
这个特化模板匹配 T 和 U 相同的情况。这个过程和我们之前描述的一样,当 T 和 U 相同时,
编译器将 T 和 U 代入模板特化的实参列表里,然后尝试推导特化模板的形参,
因为两个参数相同,所以推导得出一致的结果,匹配特化成功,is_same<T, U>::value == true。
当 T 和 U 不同时,推导失败,fallback 到匹配主模板,这时 is_same<T, U>::value == false。
3.3.2 is_one_of
这个例子展示变长形参,我们将 is_same 推广一下,给定一个类型 T,和一堆类型列表,判定 T 是否包含在这个列表之中。
- template <typename T, typename U, typename... Rest>
- struct is_one_of : bool_constant<is_one_of<T, U>::value || is_one_of<T, Rest...>::value> {};
- template <typename T, typename U>
- struct is_one_of<T, U> : is_same<T, U> {};
- int i = 0;
- std::cout << is_one_of_v<decltype(i), float, double> << std::endl; // 0, #1
- std::cout << is_one_of_v<decltype(i), float, int, double, char> << std::endl; // 1, #2
复制代码
is_one_of 的主模板形参分为三个部分:类型 T,类型 U,和变长形参列表(Parameter Pack)Rest,
另有一个特化模板,只接受两个参数 T 和 U,这个特化的逻辑等效于 is_same。
主模板的递归逻辑是一个析取表达式,判定 T 和 U 是否相等,或者判定 T 是不是包含在剩余的参数中。
这里的 Rest... 是对变长形参列表的展开,当我们要引用一个变长形参列表内的内容时,就需要这样写。
这个递归会一直持续遍历所有的参数,直到只剩下 T 和 最后一个参数,这时匹配模板特化,递归终止。
要注意的是,这个析取表达式的求值与运行时不同,运行时的析取表达式遵循“短路求值(short-circuit evaluation)”的规则,对 a || b 如果 a 为 true,就不再对 b 求值了。
但是在编译期,在模板实例化的时候,析取表达式的短路求值是不生效的,例如对 #2,虽然在遍历到 bool_constant<is_one_of<int, int> || is_one_of<int, double, char>> 的时候,
虽然前面的表达式已经可以确定为 true 了,但是后半部分的表达式 is_one_of<int, double, char> 依旧会被实例化。感兴趣的同学可以尝试用代码证明这个问题。
另外,有一个元编程的小技巧是,有时我们可以通过一个简单地别名来实现一个新的 Metafunction。例如:
- // alias template
- template <typename T>
- using is_integral = is_one_of<T, bool, char, short, int, long, long long>;
复制代码
3.3.3 is_instantiation_of
这个例子展示模板模板形参(Template Template Parameters)。is_instantiation_of 接受两个参数,一个类型,一个模板,它可以判定这个类型是不是这个模板的实例类型。
- std::list<int> li;
- std::vector<int> vi;
- std::vector<float> vf;
- std::cout << is_instantiation_of_v<decltype(vi), std::vector> << std::endl; // 1
- std::cout << is_instantiation_of_v<decltype(vf), std::vector> << std::endl; // 1
- std::cout << is_instantiation_of_v<decltype(li), std::vector> << std::endl; // 0
- std::cout << is_instantiation_of_v<decltype(li), std::list> << std::endl; // 1
- // is_instantiation_of
- template <typename Inst, template <typename...> typename Tmpl>
- struct is_instantiation_of : false_type {};
- template <template <typename...> typename Tmpl, typename... Args>
- struct is_instantiation_of<Tmpl<Args...>, Tmpl> : true_type {};
复制代码
is_instantiation_of 也有一个主模板和一个偏特化,主模板继承 false_type,它匹配当这个类型不是模板的实例时的情况;
特化模板继承自 true_type,它匹配当传入的类型是对应模板的实例时的情况。
这个过程也是和上面一样的,确定实参->代入特化->反向推导,大家可以自行尝试推演一下,我就不赘述了。
3.3.4 conditional
我们已经看到了很多通过类模板特化来实现选择逻辑的例子,更通用地,我们可以实现一个通用的选择,就像是 if 语句那样,如果条件为 true,就走一个分支,条件为 false,就走另一个分支。
conditional 就是编译期的 if:
- template<bool B, typename T, typename F>
- struct conditional : type_identity<T> {};
- template<typename T, typename F>
- struct conditional<false, T, F> : type_identity<F> {};
复制代码
当 B 是 false 时,匹配模板的特化,返回 F;当 B 是 true 时,匹配主模板,返回 T。
这里值得一提的是,通过 conditional,我们可以实现一个类似 “短路求值” 的效果。例如我们用 conditional 来实现 is_one_of:
- // With conditional, we can implement a "short-circuited" is_one_of.
- // For is_one_of<int, float, int, double, char>,
- // is_one_of<int, double, char> will NOT be instantiated.
- template <typename T, typename U, typename... Rest>
- struct is_one_of : conditional_t<
- is_same_v<T, U>, true_type, is_one_of<T, Rest...>> {};
- template <typename T, typename U>
- struct is_one_of<T, U> : conditional_t<
- is_same_v<T, U>, true_type, false_type> {};
复制代码
每一次递归前,我们都先判定了 T 和 剩余所有参数中的第一个 U 是否相等,如果相等,就直接返回 true_type 了,不会再向下递归。
所以在上面那个 #2 的例子中,is_one_of<int, double, char> 不会再被实例化了。这个技巧有时可以用来优化编译时长。
3.4 Example 3: Deal with Arrays
最后一组例子,我们来看在 TMP 中是怎么处理数组类型的。
3.4.1 rank
rank 返回数组的维度。
- std::cout << rank_v<int> << std::endl; // 0
- std::cout << rank_v<int[5]> << std::endl; // 1
- std::cout << rank_v<int[5][5]> << std::endl; // 2
- std::cout << rank_v<int[][5][6]> << std::endl; // 3
- template <typename T>
- struct rank : integral_constant<std::size_t, 0> {}; // #1
- template <typename T>
- struct rank<T[]> : integral_constant<std::size_t, rank<T>::value + 1> {}; // #2
- template <typename T, std::size_t N>
- struct rank<T[N]> : integral_constant<std::size_t, rank<T>::value + 1> { }; // #3
复制代码
rank 包含一个主模板和两个偏特化。
根据模板特化的匹配规则我们知道,当模板实参是数组类型时,会匹配 #2 或 #3 这两个特化,当实参是非数组类型时,匹配主模板。
其中,#2 匹配不定长数组;#3 匹配定长数组。整个递归的过程就是对维度做递归,每次递归 value + 1,就可以得到总维度。
这里我们可以思考一个问题:对于 int[5][6],毫无疑问会匹配到特化 #3,那么这时 #3 的两个参数 T 和 N 被推导为什么呢?是 int[5] 和 6,还是 int[6] 和 5?
答案我不公布了,你可以尝试写代码来测试看看。
3.4.2 extent
extent 接受两个参数,一个数组 T 和一个值 N,它返回 T 的第 N 维的大小。
- std::cout << extent_v<int[3]> << std::endl; // 3
- std::cout << extent_v<int[3][4], 0> << std::endl; // 3
- std::cout << extent_v<int[3][4], 1> << std::endl; // 4
- std::cout << extent_v<int[3][4], 2> << std::endl; // 0
- std::cout << extent_v<int[]> << std::endl; // 0
- template<typename T, unsigned N = 0>
- struct extent : integral_constant<std::size_t, 0> {};
- template<typename T>
- struct extent<T[], 0> : integral_constant<std::size_t, 0> {};
- template<typename T, unsigned N>
- struct extent<T[], N> : extent<T, N-1> {};
- template<typename T, std::size_t I>
- struct extent<T[I], 0> : integral_constant<std::size_t, I> {};
- template<typename T, std::size_t I, unsigned N>
- struct extent<T[I], N> : extent<T, N-1> {};
复制代码
其实,看懂了 extent 的代码,你也就知道上面那个问题的答案了。
extent 共有 4 个偏特化,前两个匹配不定长数组,后两个匹配定长数组,主模板匹配非数组类型。原理类似,我不赘述了。
|
|