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

C++模板元编程(四):深入模板

发表于 2023-1-23 22:11:40 显示全部楼层 0 812

4. Template in Depth
读到这里,恭喜你,你对 TMP 已经有不错的认知了!
但我们还没充分展示 TMP 的威力,比如前面提到的 Function Template 都还没派上用场。
本章中我们将了解一些进阶的模板知识,并在下一章将它们应用到例子中去。

4.1 Template Arguments Deduction of Function Template
函数模板的实参推导发生在名字查找(Name Lookup)之后,重载决议(Overload Resolution)之前。
如果函数模板实参推导失败,那么编译器不会直接报错,而是将这个模板从函数的重载集中无声地删除。[4][5]


  1. template <typename T, typename U> void foo(T, U) {}     // #1
  2. template <typename T> void foo(T, T) {}                 // #2
  3. void foo(float, int) {}                                 // #3

  4. foo(1, 1.0f);  // call #1, deduction of #2 faild
  5.                // with 1st arg, deduced T = int; with 2nd arg, deduced T = float
复制代码


例如,在上面的例子中,foo 有两个函数模板和一个普通函数,对 #2 的实参推导失败了,但并不会发生错误,编译器会匹配模板 #1。
我们简单地阐述一下这个过程中发生了什么:

  • 首先,编译器看到了对函数 foo 的一个调用。
  • 编译器通过名字查找,找到所有名为 “foo” 的函数和函数模板,找到了#1,#2,和#3。
  • 对每个函数模板,编译器尝试通过函数实参(1, 1.0f)来推断模板的实参。
  • 对#1,T 被推导为 int,U 被推导为 float,OK。
  • 对#2,通过第一个参数推导 T 为 int,通过第二个参数推导 T 为 float,失败,#2 被移出重载集。
  • 编译器对当前重载集(#1和#3)进行重载决议,#1 被选中
  • 编译器对 #1 进行替换(Substitution)以完成实例化。


4.2 Template Arguments Deduction of Class Template
类模板的实参推导与函数模板不同,当同时定义了主模板和模板特化时,实参推导只考虑主模板,模板特化不参与实参推导。[6]。
如果主模板实参推导出错,那么编译器直接报错。


  1. template <typename T, typename U>
  2. struct S           { S(T a, U b) { std::cout << is_same_v<decltype(b), float> } };  // #1

  3. template <typename T>
  4. struct S<T, float> { S(T a, T b) { std::cout << is_same_v<decltype(b), float> } };  // #2

  5. template <>
  6. struct S<int, int> { S(int a, int b) { std::cout << is_same_v<decltype(b), float> } };  // #3

  7. S s(1, 1.0f);     // match #2, output: 0
复制代码


例如在上面的例子中,编译器通过主模板的构造函数推导出 T=int, U=float,这个模板实参的最佳匹配结果是特化#2,
所以最终调用的构造函数是 #2 实例化后的 S<int, float>::S(int, int),第二个实参 1.0f 被隐式转型为了 int。
而假设你用特化 #2 去做模板实参推导的话,这个推导是失败的,这也反证了编译器并不是用模板特化推导实参的。
我们也简单阐述一下这里发生的事:

  • 首先,编译器看到变量“s”的定义。
  • 编译器通过名字查找,找到名为“S” 的类或类模板,这里应该只找到一个,否则会抛出一个类型重定义的错误。
  • 编译器找到了类模板 S,对 S 的主模板,编译器尝试根据构造函数实参(1, 1.0f)推导模板实参,得到 T=int, U=float。
  • 编译器根据模板实参去匹配最优的特化,匹配到 #2。
  • 编译器对 #2 进行替换(Substitution)以完成实例化。



4.3 Make Choice between Primary Template and Its Specializations

在前面的许多例子中,我们已经简单阐述了编译器是如何在主模板和特化模板之间做出选择的。现在,我们来更加准确地描述这一过程。

在模板的实例化(Instantiation)中,当所有的模板实参都确定了之后(这些实参可能是显式指定的、推导的、或者从默认实参中获取的),
编译器就需要在模板的主模板和所有显式特化中选择一个来做替换(Substitution)。规则是:

对每一个模板特化,先判断它能不能匹配该实例化(判断过程我们在3.1.1 is_reference中讲了)。
如果只有一个模板特化能匹配模板实参,那么就选择这个特化;
如果有多个模板特化都能匹配模板实参,那么通过这些特化的偏序关系来决定哪个模板特化的特化程度更高,其中特化程度最高的那个将会被选中。
如果特化程度最高的模板特化不止一个,也就是说,存在多个模板特化,它们的特化程度无法比较高低,那么编译器会报错,实例化失败。
如果没有任何一个模板特化能匹配模板实参,那么主模板被选中。

不严谨地说,“A 的特化程度比 B 高” 意味着 A 接受的参数是 B 接受的参数的子集。

严谨地说,对两个模板特化 A 和 B,编译器首先会将它们转换成两个虚构的函数模板 fa 和 fb,模板特化的形参被转换为函数的形参:

  1. template <typename T, typename U, typename... Args> struct S {};  // primary template
  2. template <typename T, typename U> struct S<T, U> {};              // specialization #A
  3. template <typename T> struct S<T, int> {};                        // specialization #B

  4. // #A is converted to: template <typename T, typename U> void fa(S<T, U>);
  5. // #B is converted to: template <typename T> void fb(S<T, int>);
复制代码


然后,模板特化 A 和 B 的优先级规则,就被转换为了函数模板重载的优先级规则了。
这个规则就是 Partial Ordering Rule,我们在下一节介绍。在这里我们看到,通过一个巧妙的转换,特化决议和重载决议对优先级的排序就被归一到了同一个算法里,它们是一致的。

4.4 Partial Ordering Rule
对于两个函数模板 fa 和 fb,怎么判定它们两个谁的特化程度更高呢?
这个过程也是一个代入+推导的过程。为了方便,我们定义一个过程名字就叫“代入推导”(这个名字我自己起的,方便我阐述),用 fa 代入推导 fb 是指:

记 fb 的模板形参为 T。假设 fa 的模板实参是 U,那么 fa 的函数实参类型(记为A)就可以用 U 来表示出来。
我们用 A 去代入 fb 的函数形参列表(记为P),并且尝试由此推导 fb 的模板形参 T,这里推导的意思就是尝试用 U 来表示出 T。

如果用 fa 代入推导 fb 成功,并且用 fb 代入推导 fa 失败,那么我们称 fa 比 fb 的特化程度更高。这就是模板的偏序规则。


  1. template<typename T> void foo(T);        // #1
  2. template<typename T> void foo(T*);       // #2
  3. template<typename T> void foo(const T*); // #3

  4. const int* p;
  5. foo(p);   // template augument deduction succeeded for all templates,
  6.           // so overload resolution picks them all.

  7. // partial ordering
  8. // deduce #1 from #2: void(T) from void(U*): P=T, A=U*: deduction ok: T=U*
  9. // deduce #2 from #1: void(T*) from void(U): P=T*, A=U: deduction fails
  10. // #2 is more specialized than #1 with regards to T
  11. // deduce #2 from #3: void(T*) from void(const U*): P=T*, A=const U*: ok
  12. // deduce #3 from #2: void(const T*) from void(U*): P=const T*, A=U*: fails
  13. // #3 is more specialized than #2 with regards to T
  14. // result: #3 is most specialized.
复制代码


规则有点复杂,我们还是通过例子来理解。我们尝试对比 #1 和 #2 的偏序关系。
首先,尝试用 #2 来代入推导 #1,我们记 #1 的模板形参为 T,并假设给 #2 传入一个实参 U,那么 #1 和 #2 就被表示为这样两个函数模板:


  1. template <typename T>   void foo(T);  // #1
  2. template <typename T=U> void foo(U*); // #2
复制代码


我们将下面的函数实参 U 代入上面的函数形参,也就是将 #1 写为 template <typename T> void foo(T=U*),然后推导T,得出 T=U,代入推导成功。
然后,我们尝试用 #1 来代入推导 #2,我们记 #2 的模板形参为 T,并给 #1 传入一个实参 U,那么 #1 和 #2 就被表示为:


  1. template <typename T=U> void foo(U);  // #1
  2. template <typename T>   void foo(T*); // #2
复制代码


然后将上面的函数实参 U 代入下面,也就是将 #2 写为 template <typename T> void foo(T*=U),然后推导T,失败。
所以我们得出结论:#2 的特化程度比 #1 高。

这个规则之所以称为“偏序”规则,是因为函数模板的重载集(或类模板的偏特化集)是一个偏序集合,集合元素之间的关系是偏序关系。
也就是说,并不是任意两个函数模板都是可比较的,有时你无法比较两个模板谁的特化程度更高。
如果从 fa 代入推导 fb 成功,且从 fb 代入推导 fa 成功;
或者从 fa 代入推导 fb 失败,且从 fb 代入推导 fa 失败,
那么 fa 和 fb 就被认为是无法比较的,或者说特化程度相同的。
例如:


  1. template<typename T> void foo(T, T*);    // #1
  2. template<typename T> void foo(T, int*);  // #2

  3. foo(0, new int(1)); // template augument deduction succeeded for all templates,
  4.                     // so overload resolution picks them all.
  5. // partial ordering:
  6. // #1 from #2: void(T,T*) from void(U1,int*): P1=T, A1=U1: T=U1; P2=T*, A2=int*: T=int; fails
  7. // #2 from #1: void(T,int*) from void(U1,U2*) : P1=T A1=U1: T=U1; P2=int* A2=U2*; fails
  8. // neither is more specialized w.r.t T, the call is ambiguous

复制代码


在这里两个方向的代入推导操作都失败了,所以重载决议时无法判定哪个的优先级更高,编译器抛出一个歧义错误。

4.5 Template Overloads vs Template Specializations

函数模板既可以重载,又可以(全)特化,它们之间是什么关系呢?特化会不会影响重载呢?

答案是,函数模板的每一个重载都是主模板(Primary Template),在重载决议的时候,只考虑非模板函数和函数模板的主模板,模板的特化不在重载集的范畴之内。
并且,对于一个函数调用,是先进行重载决议,确定使用哪个主模板,再考虑是否使用该模板的特化。

我们看第一个例子,下面哪一个 foo 会被实例化?


  1. template <typename T> void foo(T);     // #1
  2. template <>           void foo(int*);  // #2
  3. template <typename T> void foo(T*);    // #3
  4. foo(new int(1));  // which one will be called?
复制代码


答案是 #3。就像我们说的,编译器会在 #1 和 #3 中先进行重载决议,#2 只是 #1 的一个特化,不予考虑。
重载决议选择了 #3,而且 #3 也没有特化,那么就会实例化 #3 这个主模板。虽然我们能看出来 #2 才是这次调用的一个完美匹配,但很可惜,规则如此。

第二个例子,哪个 foo 会被实例化呢?


  1. template <typename T> void foo(T);     // #1
  2. template <typename T> void foo(T*);    // #3
  3. template <>           void foo(int*);  // #2
  4. foo(new int(1));  // which one will be called?
复制代码


这个例子只是把 #2 和 #3 调换了一下位置,答案就变了,这次编译器会选择 #2。
因为调换了位置之后,#2 变成了 #3 的一个特化了。
这里我们看到,编译器在决定一个模板特化属于哪一个主模板的时候,只会从它已经“看见”的主模板里选择。
在第一个例子中,编译器在为 #2 寻找主模板的时候,还没看见 #3,所以认为它是 #1 的特化。

第三个例子,那个 foo 会被实例化呢?


  1. template <typename T> void foo(T);     // #1
  2. template <>           void foo(int*);  // #2
  3. template <typename T> void foo(T*);    // #3
  4. template <>           void foo(int*);  // #4
  5. foo(new int(1));  // which one will be called?
复制代码


没错,是#4。

最后,值得说明的是,编译器在重载决议的时候为什么不考虑特化呢?
因为模板的特化并不引入一个名字(Name)。

所以在名字查找的时候,模板特化就直接是被忽略的,或者说是和主模板视为一体的,它不是一个独立的名字。
还记得我们在 4.2 Template Arguments Deduction of Class Template 中讲的吗?
类模板的实参推导不考虑其特化,归根结底也是同一个原因:模板的特化并不引入一个名字。

所以我们一定要注意,编译器是按步骤做事的,
第一步,名字查找;
第二步,实参推导;
第三步,重载决议;
第四步,选择特化;
第五步,做替换生成实例。

4.6 SFINAE

SFINAE 的全程是:Substitution Failure Is Not An Error,替换失败不是一个错误。
这可能是模板编程中最出名的一个规则了,你可能已经听过。替换失败不是一个错误。
什么意思呢?我们将这句话拆分为两半来理解:

Substitution Failure
替换失败是指,如果用实参替换了模板形参后,在模板立即上下文(Immediate Context)中的类型或表达式呈现为非良构(ill-formed)的,那么这种情形就称为替换失败。
一个类型或表达式“非良构”是指代码违背了语法或语义规则。
立即上下文,非正式地说,就是你在模板的声明里看到的内容。

is Not an Error
在函数模板的实例化中,如果发生替换失败,那么这个函数模板就被无声地从重载集中剔除,编译器继续尝试其他重载,而不会抛出一个错误
在类/变量模板偏特化的实例化中,如果发生替换失败,那么这个特化就被从特化集中剔除,编译器继续尝试其他特化,而不会抛出一个错误

要说明的是,如果每个重载(特化)都发生了替换失败,没有其他重载(特化)可用了,那编译器还是会报错,但它不会直接报一个 Substitution Failure,而是会告诉你 “No matching function to call”。另外,前面我们在4.1 Template Arguments Deduction of Function Template中提到的,函数模板实参推导失败的时候也会从重载集剔除,但这个机制不是 SFINAE。

举个例子来进一步理解 SFINAE:


  1. template <typename T>
  2. typename T::value_type foo(T t) {  // int::value is ill-formed, here is a substitution failure
  3.   T::value_type i; // int::value_type is ill-formed, but here is NOT the immediate context of foo,
  4. }                  // compiler will report an error.
  5. foo(1);
复制代码


在这个实例化中,有三处地方发生了替换,一个是函数的返回值,一个是函数的形参列表,一个是函数内部的局部变量声明。
在函数的返回值处,替换后构成了 “ill-formed” 的程序,因为 int::value 是一个非法的名字,但这里是在立即上下文内,所以是一个替换失败。在局部变量 i 的声明处,替换后也构成了 “ill-formed” 的程序,但这里不在立即上下文内,所以是一个硬错误(Hard Error)。

第二个例子,用以说明 SFINAE 在函数模板重载中的作用:


  1. // 1. SFINAE in function template:

  2. template <typename T> void          foo(T) {}   // #1
  3. template <typename T> void          foo(T*) {}  // #2
  4. template <typename T> T::value_type foo(T) {}   // #3

  5. foo(1);  // SFINAE(int::value_type) in #3,
  6.          // #1 is selected by overload resolution, T is deduced as int

  7. foo(new int(1));  // SFINAE(int*::value_type) in #3,
  8.                   // #2 is selected by overload resolution, T is deduced as int

  9. foo<int&&>(1);    // SFINAE(int&&*) in #2, SFINAE(int&&::value_type) in #3, #1 is selected
  10. 在 foo(1) 的实例化中,#3 发生替换失败,从重载集排除,重载决议在 #1 和 #2 中选择了 #1。在 foo(new int(1)) 的实例化中,#3 发生替换失败,重载决议在 #1 和 #2 中选择 #2。在 foo<int&&>(1) 中,#2 和 #3 都发生了替换失败,重载决议选择了 #1。
复制代码


第三个例子,用以说明 SFINAE 在类模板偏特化中的作用:


  1. // 2. SFINAE in class template:

  2. template <typename T, typename U> struct S {};                  // #1
  3. template <typename T> struct S<T, typename T::value_type> {};   // #2

  4. S<int, int>();          // SFINAE in #2, select #1
  5. S<true_type, bool>();   // select #2
  6. S<true_type, int>();    // select #1
复制代码


在 S<int, int> 的实例化中,#2 发生替换失败,编译器选择主模板 #1 来实例化。
在 S<true_type, bool> 的实例化中,没有替换失败发生,编译器选择 #2。
在 S<true_type, int> 的实例化中,没有替换失败发生,但根据匹配规则,#2 不匹配,所以选择 #1.

4.7 Review of Template Instantiation
最后,我们用学到的知识,回顾一下整个实例化的过程。当一个实例化发生时,编译器:

进行名字查找,找到所有匹配该名字的模板
    如果是函数模板,可能会找到一个或多个重载
    如果是类/变量模板,应找到唯一一个主模板,否则抛出重定义错误
确定所有的模板实参,需要推导的,通过主模板来推导
    对函数模板,如果推导失败,那么这个模板从重载集中剔除
    对类/变量模板,如果推导失败,则抛出错误
    对函数模板,进行重载决议,决议时只考虑主模板
偏序规则和 SFINAE 在此发挥作用
    对确定的主模板和它的特化,选择最匹配的那个
    对类/变量模板,因为存在偏特化,偏序规则和 SFINAE 在此发挥作用
    对函数模板,只有全特化,直接匹配就行了
    对最终选定的主模板或特化进行替换,生成真实代码,放到 POI 中
回复

使用道具 举报

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

本版积分规则

shop_da_admin

管理员

39

主题

40

帖子

247

积分
Ta的主页 发消息

网友分享更多 >

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