请选择 进入手机版 | 继续访问电脑版

C++模板元编程(三):从简单案例中学习

发表于 2023-1-23 22:10:51 显示全部楼层 0 336

3. Learn TMP in Use (Part I)
本章中,我们通过几个正经一点的例子,来进一步了解 TMP,以及了解什么是 Metafunction Convention。这些例子大部分来自标准库的 <type_traits>。

3.1 Example 1: Type Manipulation
3.1.1 is_reference

下面的这个模板可以判定一个类型是不是引用类型:


  1. template <typename T> struct is_reference      { static constexpr bool value = false; };    // #1
  2. template <typename T> struct is_reference<T&>  { static constexpr bool value = true; };     // #2
  3. template <typename T> struct is_reference<T&&> { static constexpr bool value = true; };     // #3

  4. std::cout << is_reference<int>::value << std::endl;    // 0
  5. std::cout << is_reference<int&>::value << std::endl;   // 1
  6. 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
除了判定一个引用类型外,我们还可以移除一个类型的引用:


  1. template <typename T> struct remove_reference      { using type = T; };     // #1
  2. template <typename T> struct remove_reference<T&>  { using type = T; };     // #2
  3. template <typename T> struct remove_reference<T&&> { using type = T; };     // #3

  4. // case 1:
  5. int&& i = 0;
  6. remove_reference<decltype(i)>::type j = i;    // equivalent to: int j = i;

  7. // case 2:
  8. template <typename T>
  9. void foo(typename remove_reference<T>::type a_copy) { a_copy += 1; }

  10. foo<int>(i);    // passed by value
  11. 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 返回这个类模板的相应实例。我们举例说明:


  1. // non-type metadata (or numerical metadata)
  2. template <bool b>
  3. struct bool_ { static constexpr bool value = b; };

  4. // metafunction
  5. template <typename T> struct is_reference      { using type = bool_<false>; };
  6. template <typename T> struct is_reference<T&>  { using type = bool_<true>; };
  7. 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 是这样定义的:


  1. template <typename T, T v>
  2. struct integral_constant {
  3.   static constexpr T value = v;
  4.   using value_type = T;
  5.   using type = integral_constant;   // using injected-class-name
  6.   constexpr operator value_type() const noexcept { return value; }
  7.   constexpr value_type operator()() const noexcept { return value; }
  8. };
复制代码


这是我们在 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:


  1. // alias
  2. template <bool B> using bool_constant = integral_constant<bool, B>;
  3. using true_type  = bool_constant<true>;
  4. using false_type = bool_constant<false>;
复制代码


有了这些定义,is_reference 的定义就变成了:


  1. template <typename T> struct is_reference      { using type = false_type; };
  2. template <typename T> struct is_reference<T&>  { using type = true_type; };
  3. template <typename T> struct is_reference<T&&> { using type = true_type; };
复制代码


对它的调用就变成了:


  1. std::cout << is_reference<int>::type::value;  // 0
  2. std::cout << is_reference<int>::type();       // 0, implicit cast: false_type --> bool
  3. std::cout << is_reference<int>::type()();     // 0
复制代码


3.2.3 use public inheritance
当一个 Metafunction 使用另一个 Metafunction 的结果作为返回值时,不用自己定义 type 成员了,只需要直接继承另一个 Metafunction 即可!比如,我们可以这样实现 is_reference:


  1. template <typename T> struct is_reference      : public false_type {};
  2. template <typename T> struct is_reference<T&>  : public true_type {};
  3. template <typename T> struct is_reference<T&&> : public true_type {};
复制代码


由于 true_type 和 false_type 内部定义了一个名为 “type” 的成员,而且这个成员指的是它们自己,所以直接继承过来,is_reference 内部也就有了一个名为 “type” 的成员了。类似地,我们可以实现一个新的 Metafunction,它判定一个类型是不是 int 或引用类型:


  1. // another metafunction implemented by inheritance.
  2. template <typename T> struct is_int_or_reference : public is_reference<T> {};
  3. template <> struct is_int_or_reference<int> : public true_type {};

  4. // metafunction call
  5. std::cout << is_int_or_reference<int>::value;  // 1
  6. std::cout << is_int_or_reference<int>();       // 1
  7. std::cout << is_int_or_reference<int>()();     // 1
复制代码


公有继承和直接定义“type” 成员,这两种方式效果类似,但有一些细微的差别,例如继承的时候不仅 “type” 成员被继承过来了,“value” 也被继承了过来。我们在 TMP 中会尽可能地使用这种继承的方式,而不是每次都去定义type。因为这种方式实现的代码更简洁,也更具有一致性:当一个 Metafunction 依赖另一个 Metafunction 时,就是应该直接获取另一个 Metafunction 的全部内容。这种继承的形式可能一开始看不习惯,但用多了就会觉得真香。

另外,我们在定义类模板时,使用 struct 关键字,而不使用 class 关键字,这样就可以省略继承时的 public 关键字,以及类模板定义内部的 public 关键字了。

我们再来看一个特殊的 Metafunction,可能是 TMP 中最简单的一个 Metafunction 了:


  1. template <typename T>
  2. struct type_identity { using type = T; };

  3. type_identity<int>::type i;    // equivalent to: int i;
  4. type_identity 这个模板接受一个形参 T,并返回 T 本身。你可能会疑惑这样的一个东西有什么用,实际上它非常有用,结合前面提到的公有继承,它可以让你在一行代码内就写完一个 Metafunction:

  5. // with type_identity, we can implement remove_reference like this:
  6. template <typename T> struct remove_reference      : type_identity<T> {};
  7. template <typename T> struct remove_reference<T&>  : type_identity<T> {};
  8. 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:


  1. // variable template
  2. template <typename T> inline constexpr bool is_reference_v = is_reference<T>::value;
复制代码


二、对于返回一个类型的 Metafunction,我们声明一个 _t 后缀的别名模板(Alias Template),通过它可以方便地获取 Metafunction 返回的 type:


  1. // alias template
  2. template <typename T> using remove_reference_t = typename remove_reference<T>::type;
复制代码


效果如下:


  1. 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++ 中,由于缺乏自省机制,所以普通的代码是不可能实现下面这种效果的:


  1. int i = 0;
  2. std::cout << is_same_v<decltype(i), int>   << std::endl;    // 1
  3. std::cout << is_same_v<decltype(i), float> << std::endl;    // 0

  4. if (is_same_v<decltype(i), int>) {
  5.     // ...
  6. } else {
  7.     // ...
  8. }
复制代码


这里你可能会想到 RTTI(Run-Time Type Information)的机制,但 RTTI 不同编译器的实现可能有差别,它的本意是为了实现 C++ 内部的一些语言机制,主要是动态多态(Dynamic Polymorphism),因此依赖 RTTI 的代码可能不具备可移植性。但是通过 TMP,我们可以实现一个 Metafunction 来达到判定类型的效果,原理非常简单:


  1. template <typename T, typename U>
  2. struct is_same : false_type {};

  3. template <typename T>
  4. 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 是否包含在这个列表之中。


  1. template <typename T, typename U, typename... Rest>
  2. struct is_one_of : bool_constant<is_one_of<T, U>::value || is_one_of<T, Rest...>::value> {};

  3. template <typename T, typename U>
  4. struct is_one_of<T, U> : is_same<T, U> {};

  5. int i = 0;
  6. std::cout << is_one_of_v<decltype(i), float, double> << std::endl;               // 0, #1
  7. 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。例如:


  1. // alias template
  2. template <typename T>
  3. 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 接受两个参数,一个类型,一个模板,它可以判定这个类型是不是这个模板的实例类型。


  1. std::list<int>     li;
  2. std::vector<int>   vi;
  3. std::vector<float> vf;

  4. std::cout << is_instantiation_of_v<decltype(vi), std::vector> << std::endl;  // 1
  5. std::cout << is_instantiation_of_v<decltype(vf), std::vector> << std::endl;  // 1
  6. std::cout << is_instantiation_of_v<decltype(li), std::vector> << std::endl;  // 0
  7. std::cout << is_instantiation_of_v<decltype(li), std::list>   << std::endl;  // 1

  8. // is_instantiation_of
  9. template <typename Inst, template <typename...> typename Tmpl>
  10. struct is_instantiation_of : false_type {};

  11. template <template <typename...> typename Tmpl, typename... Args>
  12. 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:


  1. template<bool B, typename T, typename F>
  2. struct conditional : type_identity<T> {};

  3. template<typename T, typename F>
  4. struct conditional<false, T, F> : type_identity<F> {};
复制代码


当 B 是 false 时,匹配模板的特化,返回 F;当 B 是 true 时,匹配主模板,返回 T。
这里值得一提的是,通过 conditional,我们可以实现一个类似 “短路求值” 的效果。例如我们用 conditional 来实现 is_one_of:


  1. // With conditional, we can implement a "short-circuited" is_one_of.
  2. // For is_one_of<int, float, int, double, char>,
  3. // is_one_of<int, double, char> will NOT be instantiated.

  4. template <typename T, typename U, typename... Rest>
  5. struct is_one_of : conditional_t<
  6.   is_same_v<T, U>, true_type, is_one_of<T, Rest...>> {};

  7. template <typename T, typename U>
  8. struct is_one_of<T, U> : conditional_t<
  9.   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 返回数组的维度。


  1. std::cout << rank_v<int> << std::endl;              // 0
  2. std::cout << rank_v<int[5]> << std::endl;           // 1
  3. std::cout << rank_v<int[5][5]> << std::endl;        // 2
  4. std::cout << rank_v<int[][5][6]> << std::endl;      // 3

  5. template <typename T>
  6. struct rank : integral_constant<std::size_t, 0> {};                          // #1

  7. template <typename T>
  8. struct rank<T[]> : integral_constant<std::size_t, rank<T>::value + 1> {};    // #2

  9. template <typename T, std::size_t N>
  10. 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 维的大小。


  1. std::cout << extent_v<int[3]> << std::endl;        // 3
  2. std::cout << extent_v<int[3][4], 0> << std::endl;  // 3
  3. std::cout << extent_v<int[3][4], 1> << std::endl;  // 4
  4. std::cout << extent_v<int[3][4], 2> << std::endl;  // 0
  5. std::cout << extent_v<int[]> << std::endl;         // 0


  6. template<typename T, unsigned N = 0>
  7. struct extent : integral_constant<std::size_t, 0> {};

  8. template<typename T>
  9. struct extent<T[], 0> : integral_constant<std::size_t, 0> {};

  10. template<typename T, unsigned N>
  11. struct extent<T[], N> : extent<T, N-1> {};

  12. template<typename T, std::size_t I>
  13. struct extent<T[I], 0> : integral_constant<std::size_t, I> {};

  14. template<typename T, std::size_t I, unsigned N>
  15. struct extent<T[I], N> : extent<T, N-1> {};
复制代码


其实,看懂了 extent 的代码,你也就知道上面那个问题的答案了。
extent 共有 4 个偏特化,前两个匹配不定长数组,后两个匹配定长数组,主模板匹配非数组类型。原理类似,我不赘述了。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

shop_da_admin

管理员

39

主题

40

帖子

247

积分
Ta的主页 发消息

网友分享更多 >

  • 机器学习的统计学知识
  • 漳州盛泰水产
  • 玉川茶家