C++模板系列01:模板的特化与偏特化

YiQi 管理员

笔记摘抄自C++ Template 进阶指南

文章看得我似懂非懂,不过有些基础的概念倒是强化了一下。

模板基础使用中需要注意的点

  • 类模板普遍不支持分离式编译,参考【C++ 类模板】声明和定义要在同一文件中 .
  • 模板参数除了类型外(包括基本类型、结构、类类型等),也可以是一个整型数(Integral Number)。这里的整型数比较宽泛,包括布尔型,不同位数、有无符号的整型,甚至包括指针。
  • 宏是基于文本的替换,而模板会在分析模板时以及实例化模板时时候都会进行检查,而且源代码中也能与调试符号一一对应,所以无论是编译时还是运行时,排错都相对简单。
  • 模板与宏最大的不同在于它是“可以运算”的。

模板的特化与偏特化

特化

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 首先,要写出模板的一般形式(原型)
template <typename T> class AddFloatOrMulInt
{
static T Do(T a, T b)
{
// 在这个例子里面一般形式里面是什么内容不重要,因为用不上
// 这里就随便给个0吧。
return T(0);
}
};

// 其次,我们要指定T是int时候的代码,这就是特化:
template <> class AddFloatOrMulInt<int>
{
public:
static int Do(int a, int b) //
{
return a * b;
}
};

// 再次,我们要指定T是float时候的代码:
template <> class AddFloatOrMulInt<float>
{
public:
static float Do(float a, float b)
{
return a + b;
}
};

void foo()
{
// 这里面就不写了
}

解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 我们这个模板的基本形式是什么?
template <typename T> class AddFloatOrMulInt;

// 但是这个类,是给T是Int的时候用的,于是我们写作
class AddFloatOrMulInt<int>;
// 当然,这里编译是通不过的。

// 但是它又不是个普通类,而是类模板的一个特化(特例)。
// 所以前面要加模板关键字template,
// 以及模板参数列表
template </* 这里要填什么? */> class AddFloatOrMulInt<int>;

// 最后,模板参数列表里面填什么?因为原型的T已经被int取代了。所以这里就不能也不需要放任何额外的参数了。
// 所以这里放空。
template <> class AddFloatOrMulInt<int>
{
// ... 针对Int的实现 ...
};

// Bingo!

需要注意:

  • 当模板实例化时提供的模板参数不能匹配到任何的特化形式的时候,它就会去匹配类模板的“原型”形式。
  • 和继承不同,类模板的“原型”和它的特化类在实现上是没有关系的,比如下面的代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    template <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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T> struct X {};

template <typename T> struct Y
{
typedef X<T> ReboundType; // 类型定义1
typedef typename X<T>::MemberType MemberType; // 类型定义2
typedef UnknownType MemberType3; // 类型定义3

void foo()
{
X<T> instance0;
typename X<T>::MemberType instance1;
WTF instance2
大王叫我来巡山 - + &
}
};

把这段代码编译一下,类型定义3出错,其它的都没问题。不过到这里你应该会有几个问题:

  1. 不是struct X<T>的定义是空的吗?为什么在struct Y内的类型定义2使用了 X<T>::MemberType 编译器没有报错?
  2. 类型定义2中的typename是什么鬼?为什么类型定义1就不需要?
  3. 为什么类型定义3会导致编译错误?
  4. 为什么void foo()在MSVC下什么错误都没报?

先看第四个问题。

按照C++标准的意思,名称查找会在模板定义和实例化时各做一次,分别处理非依赖性名称和依赖性名称的查找。这就是“两阶段名称查找”这一名词的由来。为什么MSVC中,函数模板的定义内不管填什么编译器都不报错?因为MSVC在分析模板中成员函数定义时没有做任何事情。因为C++的语义将会直接干扰到语法,为了偷懒,MSVC将包括所有模板成员函数的语法/语义分析工作都挪到了第二个Phase,于是乎连带着语法分析都送进了第二个阶段。符合标准么?显然不符合。这样做会带来两个问题:

  1. 如果类模板中有一些与模板参数无关的错误,如果名称查找/语义分析在两个阶段完成,那么这些错误会很早、且唯一的被提示出来;但是如果一切都在实例化时处理,那么可能会导致不同的实例化过程提示同样的错误。而模板在运用过程中,往往会产生很多实例,此时便会大量报告同样的错误。当然,MSVC并不会真的这么做。根据推测,最终他们是合并了相同的错误。
  2. 另一个缺点也与之类似。因为没有足够的检查,如果你写的模板没有被实例化,那么很可能缺陷会一直存在于代码之中。特别是模板代码多在头文件。虽然不如接口那么重要,但也是属于被公开的部分,别人很可能会踩到坑上。缺陷一旦传播开修复起来就没那么容易了。

但也有一个优点:MSVC的实现要比标准更加易于写和维护。

再看其他问题。

先对Y内部做一下分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T> struct Y
{
// X可以查找到原型;
// X<T>是一个依赖性名称,模板定义阶段并不管X<T>是不是正确的。
typedef X<T> ReboundType;

// X可以查找到原型;
// X<T>是一个依赖性名称,X<T>::MemberType也是一个依赖性名称;
// 所以模板声明时也不会管X模板里面有没有MemberType这回事。
typedef typename X<T>::MemberType MemberType2;

// UnknownType 不是一个依赖性名称
// 而且这个名字在当前作用域中不存在,所以直接报错。
typedef UnknownType MemberType3;
};

标准中规定了形如 T::MemberType 这样的 qualified id 在默认情况下不是一个类型,而是解释为 T 的一个成员变量 MemberType,只有当 typename 修饰之后才能作为类型出现。
比如代码:

1
a * b

在没有模板的情况下,这个语句有两种可能的意思:如果a是一个类型,这就是定义了一个指针b,它拥有类型a*;如果a是一个对象或引用,这就是计算一个表达式a*b,虽然结果并没有保存下来。可是如果上面的a是模板参数的成员,会发生什么呢?

1
2
3
4
template <typename T> void meow()
{
   T::a * b; // 这是指针定义还是表达式语句?
}

编译器对模板进行语法检查的时候,必须要知道上面那一行到底是个什么——这当然可以推迟到实例化的时候进行(比如VC,这也是上面说过VC可以不加typename的原因),不过那是另一个故事了——显然在模板定义的时候,编译器并不能妄断。因此,C++标准规定,在没有typename约束的情况下认为这里T::a不是类型,因此T::a * b; 会被当作表达式语句(例如乘法);而为了告诉编译器这是一个指针的定义,我们必须在T::a之前加上typename关键字,告诉编译器T::a是一个类型,这样整个语句才能符合指针定义的语法。

在这里,我举几个例子帮助大家理解typename的用法,这几个例子已经足以涵盖日常使用[(预览)][3]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct A;
template <typename T> struct B;
template <typename T> struct X {
typedef X<T> TA; // 编译器当然知道 X<T> 是一个类型。
typedef X TB; // X 等价于 X<T> 的缩写
typedef T TC; // T 不是一个类型还玩毛

// !!!注意我要变形了!!!
class Y {
typedef X<T> TD; // X 的内部,既然外部高枕无忧,内部更不用说了
typedef X<T>::Y TE; // 嗯,这里也没问题,编译器知道Y就是当前的类型,
// 这里在VS2015上会有错,需要添加 typename,
// Clang 上顺利通过。
typedef typename X<T*>::Y TF; // 这个居然要加 typename!
// 因为,X<T*>和X<T>不一样哦,
// 它可能会在实例化的时候被别的偏特化给抢过去实现了。
};

typedef A TG; // 嗯,没问题,A在外面声明啦
typedef B<T> TH; // B<T>也是一个类型
typedef typename B<T>::type TI; // 嗯,因为不知道B<T>::type的信息,
// 所以需要typename
typedef B<int>::type TJ; // B<int> 不依赖模板参数,
// 所以编译器直接就实例化(instantiate)了
// 但是这个时候,B并没有被实现,所以就出错了
};

偏特化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <typename T, typename U> struct X            ;    // 0 
// 原型有两个类型参数
// 所以下面的这些偏特化的实参列表
// 也需要两个类型参数对应
template <typename T> struct X<T, T > {}; // 1
template <typename T> struct X<T*, T > {}; // 2
template <typename T> struct X<T, T* > {}; // 3
template <typename U> struct X<U, int> {}; // 4
template <typename U> struct X<U*, int> {}; // 5
template <typename U, typename T> struct X<U*, T* > {}; // 6
template <typename U, typename T> struct X<U, T* > {}; // 7

template <typename T> struct X<unique_ptr<T>, shared_ptr<T>>; // 8

// 以下特化,分别对应哪个偏特化的实例?
// 此时偏特化中的T或U分别是什么类型?

X<float*, int> v0;
X<double*, int> v1;
X<double, double> v2;
X<float*, double*> v3;
X<float*, float*> v4;
X<double, float*> v5;
X<int, double*> v6;
X<int*, int> v7;
X<double*, double> v8;

个别几个匹配上会有歧义,所以会报错

不定长的模板参数

1
template <typename... Ts> class tuple;

这里的typename... Ts相当于一个声明,是说Ts不是一个类型,而是一个不定常的类型列表。同C语言的不定长参数一样,它通常只能放在参数列表的最后。看下面的例子:

1
2
3
4
template <typename... Ts, typename U> class X {};              // (1) error!
template <typename... Ts> class Y {}; // (2)
template <typename... Ts, typename U> class Y<U, Ts...> {}; // (3)
template <typename... Ts, typename U> class Y<Ts..., U> {}; // (4) 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
// Custom Div的实现
}

template <typename T, bool IsFloat = std::is_floating_point<T>::value> struct SafeDivide {
static T Do(T lhs, T rhs) {
return CustomDiv(lhs, rhs);
}
};

template <typename T> struct SafeDivide<T, true>{ // 偏特化A
static T Do(T lhs, T rhs){
return lhs/rhs;
}
};

template <typename T> struct SafeDivide<T, false>{ // 偏特化B
static T Do(T lhs, T rhs){
return lhs;
}
};

void foo(){
SafeDivide<float>::Do(1.0f, 2.0f); // 调用偏特化A
SafeDivide<int>::Do(1, 2); // 调用偏特化B
}
此页目录
C++模板系列01:模板的特化与偏特化