C++模板系列01:模板的特化与偏特化
笔记摘抄自C++ Template 进阶指南 。
文章看得我似懂非懂,不过有些基础的概念倒是强化了一下。
模板基础使用中需要注意的点
- 类模板普遍不支持分离式编译,参考【C++ 类模板】声明和定义要在同一文件中 .
- 模板参数除了类型外(包括基本类型、结构、类类型等),也可以是一个整型数(Integral Number)。这里的整型数比较宽泛,包括布尔型,不同位数、有无符号的整型,甚至包括指针。
- 宏是基于文本的替换,而模板会在分析模板时以及实例化模板时时候都会进行检查,而且源代码中也能与调试符号一一对应,所以无论是编译时还是运行时,排错都相对简单。
- 模板与宏最大的不同在于它是“可以运算”的。
模板的特化与偏特化
特化
基本用法
1 | // 首先,要写出模板的一般形式(原型) |
解释:
1 | // 我们这个模板的基本形式是什么? |
需要注意:
- 当模板实例化时提供的模板参数不能匹配到任何的特化形式的时候,它就会去匹配类模板的“原型”形式。
- 和继承不同,类模板的“原型”和它的特化类在实现上是没有关系的,比如下面的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template <typename T> class TypeToID
{
public:
static int const NotID = -2;
};
template <> class TypeToID<float>
{
public:
static int const ID = 1;
};
void PrintID()
{
cout << "ID of float: " << TypeToID<float>::ID << endl; // Print "1"
cout << "NotID of float: " << TypeToID<float>::NotID << endl; // Error! TypeToID<float>使用的特化的类,这个类的实现没有NotID这个成员。
cout << "ID of double: " << TypeToID<double>::ID << endl; // Error! TypeToID<double>是由类模板实例化出来的,它只有NotID,没有ID这个成员。
}
模板推导
考虑下面的例子和问题:
1 | template <typename T> struct X {}; |
把这段代码编译一下,类型定义3出错,其它的都没问题。不过到这里你应该会有几个问题:
- 不是
struct X<T>
的定义是空的吗?为什么在struct Y
内的类型定义2使用了X<T>::MemberType
编译器没有报错? - 类型定义2中的
typename
是什么鬼?为什么类型定义1就不需要? - 为什么类型定义3会导致编译错误?
- 为什么
void foo()
在MSVC下什么错误都没报?
先看第四个问题。
按照C++标准的意思,名称查找会在模板定义和实例化时各做一次,分别处理非依赖性名称和依赖性名称的查找。这就是“两阶段名称查找”这一名词的由来。为什么MSVC中,函数模板的定义内不管填什么编译器都不报错?因为MSVC在分析模板中成员函数定义时没有做任何事情。因为C++的语义将会直接干扰到语法,为了偷懒,MSVC将包括所有模板成员函数的语法/语义分析工作都挪到了第二个Phase,于是乎连带着语法分析都送进了第二个阶段。符合标准么?显然不符合。这样做会带来两个问题:
- 如果类模板中有一些与模板参数无关的错误,如果名称查找/语义分析在两个阶段完成,那么这些错误会很早、且唯一的被提示出来;但是如果一切都在实例化时处理,那么可能会导致不同的实例化过程提示同样的错误。而模板在运用过程中,往往会产生很多实例,此时便会大量报告同样的错误。当然,MSVC并不会真的这么做。根据推测,最终他们是合并了相同的错误。
- 另一个缺点也与之类似。因为没有足够的检查,如果你写的模板没有被实例化,那么很可能缺陷会一直存在于代码之中。特别是模板代码多在头文件。虽然不如接口那么重要,但也是属于被公开的部分,别人很可能会踩到坑上。缺陷一旦传播开修复起来就没那么容易了。
但也有一个优点:MSVC的实现要比标准更加易于写和维护。
再看其他问题。
先对Y内部做一下分析:
1 | template <typename T> struct Y |
标准中规定了形如 T::MemberType
这样的 qualified id
在默认情况下不是一个类型,而是解释为 T
的一个成员变量 MemberType
,只有当 typename
修饰之后才能作为类型出现。
比如代码:
1 | a * b |
在没有模板的情况下,这个语句有两种可能的意思:如果a
是一个类型,这就是定义了一个指针b
,它拥有类型a*
;如果a
是一个对象或引用,这就是计算一个表达式a*b
,虽然结果并没有保存下来。可是如果上面的a
是模板参数的成员,会发生什么呢?
1 | template <typename T> void meow() |
编译器对模板进行语法检查的时候,必须要知道上面那一行到底是个什么——这当然可以推迟到实例化的时候进行(比如VC,这也是上面说过VC可以不加typename
的原因),不过那是另一个故事了——显然在模板定义的时候,编译器并不能妄断。因此,C++标准规定,在没有typename
约束的情况下认为这里T::a
不是类型,因此T::a * b;
会被当作表达式语句(例如乘法);而为了告诉编译器这是一个指针的定义,我们必须在T::a
之前加上typename
关键字,告诉编译器T::a
是一个类型,这样整个语句才能符合指针定义的语法。
在这里,我举几个例子帮助大家理解typename
的用法,这几个例子已经足以涵盖日常使用[(预览)][3]:
1 | struct A; |
偏特化
1 | template <typename T, typename U> struct X ; // 0 |
个别几个匹配上会有歧义,所以会报错
不定长的模板参数
1 | template <typename... Ts> class tuple; |
这里的typename... Ts
相当于一个声明,是说Ts
不是一个类型,而是一个不定常的类型列表。同C语言的不定长参数一样,它通常只能放在参数列表的最后。看下面的例子:
1 | template <typename... Ts, typename U> class X {}; // (1) error! |
为什么第(1)条语句会出错呢?(1)是模板原型,模板实例化时,要以它为基础和实例化时的类型实参相匹配。因为C++的模板是自左向右匹配的,所以不定长参数只能结尾。其他形式,无论写作Ts, U
,或者是Ts, V, Us,
,或者是V, Ts, Us
都是不可取的。(4) 也存在同样的问题。
但是,为什么(3)中, 模板参数和(1)相同,都是typename... Ts, typename U
,但是编译器却并没有报错呢?
答案在这一节的早些时候。(3)和(1)不同,它并不是模板的原型,它只是Y
的一个偏特化。回顾我们在之前所提到的,偏特化时,模板参数列表并不代表匹配顺序,它们只是为偏特化的模式提供的声明,也就是说,它们的匹配顺序,只是按照<U, Ts...>
来,而之前的参数只是告诉你Ts
是一个类型列表,而U
是一个类型,排名不分先后。
模板的默认实参
实际上,模板的默认参数不仅仅可以是一个确定的类型,它还能是以其他类型为参数的一个类型表达式。
1 | include <type_traits> |