本篇讲解模板特化
------------------------------------------------------------------------------------------------------------
第12章 特化和重载
------------------------------------------------------------------------------------------------------------
前面几篇博客讲解了C++模板如何使一个泛型定义扩展成一写相关的类家族或者函数家族。但该机制并非适合所有情况,C++通过更多的特化机制具备了许多用特定方式透明替换泛型定义的特性,也即下面介绍的模板特化和函数模板的重载。
12.1 当泛型代码不再使用的时候
书中提供了一个例子说明泛型代码有时使用起来不再方便,类似的例子比较容易找,详见书籍。
12.2 重载函数模板
上面12.1中描述的例子说明两个同名的函数模板可以同时存在,还可以对它们进行实例化,使它们具有相同的参数类型。下面提供另一个例子:
// details/funcoverload.hpp template <typename T> int f(T) { return 1; } template <typename T> int f(T*) { return 2; }
如果我们用int*来替换第1个模板的T,用int来替换第2个模板的T,那么将会获得两个具有相同参数类型(和返回类型)的同名函数。也就是说,不仅是同名模板可以同时存在,同名各自的实例化体也可以同时存在,即使这些实例化体具有相同的参数类型和返回类型。可以如下调用:
// details/funcoverload.cpp #include <iostream> #include "funcoverload.hpp" int main() { std::cout << f<int*>((int*)0) << std::endl; std::cout << f<int>((int*)0) << std::endl; } 程序输入如下: 1 2
为了说明这一点,让我们相信地分析调用f<int*>((int*)0)。语法f<int*>说明我们希望用int*来替换模板f的第1个模板参数,而且这种替换并不依赖于模板实参演绎。在这个例子中,有两个f模板,因此所生成的重载集包含了两个函数:f<int*>(int*)(生成自第1个模板)和f<int*>(int**)(生成自第2个模板)。然而,调用实参(int*)0的类型是int*,因此它将会和第1个模板生成的函数更好地匹配,最后也就调用这个函数。 类似的分析也可以用于第2个调用。
12.2.1 签名
只要具有不同的签名,两个函数就可以在同一个程序中同时存在。我们对函数签名的定义如下:
1. 非受限函数的名称(或者产生自函数模板的这类名称)。
2. 函数名称所属的类作用域或者名字空间作用域:如果函数名称是具有内部链接的,还包括该名称所在的翻译单元。
3. 函数的const、volatile或者const volatile限定符(前提是它是一个具有这类限定符的成员函数)。
4. 函数参数的类型(如果这个函数是产生自函数模板的,那么指的是模板参数被替换之前的类型)。
5. 如果这个函数是产生自函数模板,那么包括它的返回类型。
6. 如果这个函数是产生自函数模板,那么包括模板参数和模板实参。
这就意味着:从原则上讲,下面的模板和它们的实例化可以在同个程序中同时存在:
template<typename T1, typename T2> void f1(T1, T2); template<typename T1, typename T2> void f1(T2, T1); template<typename T> long f2(T); template<typename T> char f2(T);
然而,如果上面这些模板是在同一个作用域中进行声明的话,我们可能不能使用某些模板,因为实例化过程可能会导致重载二义性。如:
#include <iostream> template<typename T1, typename T2> void f1(T1, T2) { std::cout << "f1(T1, T2) \n"; } template<typename T1, typename T2> void f1(T2, T1) { std::cout << "f1(T2, T1) \n"; } // 到这里为止一切都是正确的 int main() { f1<char, char>(‘a‘, ‘b‘); // 错误:二义性 }
在上面的代码中,虽然函数f1<T1 = char, T2 = char>(T1, T2)可以和函数f1<T1 = char, T2 = char>(T2, T1)同时存在,但是重新解析规则将不知道应该选择哪一个函数。因此,只有在这两个模板出现于不同的翻译单元时,它们的两个实例化体才可以在同个程序中同时存在(而且,链接器也不应该抱怨说存在重复定义,因为这两个实例化体的签名是不同的)。
12.2.2 重载的函数模板的局部排序
template <typename T> int f(T) { return 1; } template <typename T> int f(T*) { return 2; } int main() { std::cout << f(0) << std::endl; std::cout << f((int*)0) << std::endl; }
f(0)这个调用中,重载解析并没有发挥作用,直接匹配第一个模板;
第2个调用(f((int*)0))中:对于这两个模板,实参演绎都可以获得成功,于是获得两个函数,即f<int*>(int*)和f<int>(int*)。如果根据原来的重载解析观点,这两个函数和实参类型为iint*的调用的匹配程度是一样的,这也就意味着该调用是二义性的。然而,在这中情况下,还应该考虑重载解析的额外规则:选择“产生自更加特殊的模板的函数”。因此,第2个模板被认为是更加特殊的模板,从而产生下面的输出结果:
1
2
12.2.3 正式的排序原则
下面我们将给出一个精确的过程来判断:在参与重载集的所有函数模板中,某个函数模板是否比另一个函数模板更加特殊。然而,我们应该知道这只是不完整的排序原则:就是说,两个模板也可能会被认为具有相同的特殊程度。如果重载解析必须在这两个特殊程度相同的模板中进行选择,那么将不能做出任何决定,也就是说程序包含了一个二义性错误。
假设我们要比较两个同名的函数模板ft1和ft2,对于给定的函数调用,它们看起来都是可行的。在我们下面的讨论中,对于没有被使用的缺省函数实参和省略号参数,我们将不考虑。接下来,通过如下替换模板参数,我们将为这两个模板虚构两份不同的实参类型(如果是转型函数模板,那么还包括返回类型)列表,其中第1份列表针对第1个模板,第2份列表针对第2个模板。“虚构”的实参列表将这样地替换每个模板参数:
1. 用唯一的“虚构”类型替换每个模板类型参数;
2. 用唯一的“虚构”类模板替换每个模板的模板参数;
3. 用唯一的适当类型的“虚构”值替换每个非类型参数。
“更加特殊”的判断:
如果第2个模板针对第1份列表可以进行成功的实参演绎(能够进行精确的匹配),而第1个模板针对第2份列表的实参演绎以失败告终,那么我们就称第1个模板要比第2个模板更加特殊。反之,如果第1个模板针对第2份列表可以进行成功的实参演绎(能够进行精确的匹配),而第2个模板针对第1份列表的实参演绎失败,那么我们就称第2个模板要比第1个模板更加特殊。否则的话(或者是两个都不能成功演绎,或者是两个都能成功演绎),我们就称这两个模板之间不存在特殊的排序关系。
例子参见书籍。
12.2.4 模板和非模板
函数模板也可以和非模板函数同时重载。当其他的所有条件都是一样的时候,实际的函数调用将会优先选择非模板函数。
12.3 显式特化(全局特化)
备注:
1. 类模板和函数模板都可以被全局特化;
2. 类模板能局部特化,不能被重载;
3. 函数模板能被重载,不能被局部特化。
具有对函数模板进行重载的这种能力,再加上可以利用局部排序规则选择最佳匹配的函数模板,我们就能够给泛型实现添加更加特殊的模板,从而可以透明地获得具有更高效率的代码。然而,类模板不能被重载;我们可以选择另一种替换的机制来实现这种透明自定义类模板的能力,那就是显式特化。C++标准的“显式特化”概念指的是一种语言特性,我们通常也称之为全局特化。它为模板提供了一种使模板参数可以被全局替换的实现,而没有剩下模板参数。事实上,类模板和函数模板都是可以被全局特化的,而且类模板的成员(包括成员函数、嵌入类、静态成员变量等,它们的定义可以位于类定义的外部)也可以被全局特化。 在一下节,我们将讨论局部特化。局部特化和全局特化有些类似,但局部特化并没有替换所有的模板参数,就是说某些参数化实现仍然保留在模板的(另一种)实现中。另外,在我们的源代码中,全局特化和局部特化都是显式的,这也是我们在讨论中避免使用显式特化这个概念的原因。实际上,全局特化和局部特化都没有引入一个全新的模板或者模板实例。它们只是对原来的泛型(或者非特化)模板中已经隐式声明的实例提供另一种定义。在概念上,这是一个相对比较重要的现象,也是特化区别于重载模板的关键之处。
12.3.1 全局的类模板特化 如下:
template<typename T> class S { public: void info() { std::cout << "generic (S<T>::info() \n)"; } }; template<> class S<void> { public: void msg() { std::cout << "fully specialized (S<void>::msg()) \n"; } };
(1) 我们看到,全局特化的实现不需要与(原来的)泛型实现有任何关联,这就允许我们可以包含不同名称的成员函数(info相对msg)。实际上,全局特化只和类模板的名称有关联。
(2)另外,指定的模板实参列表必须和相应的模板参数列表一一对应。例如,我们不能用一个非类型值来替换一个模板类型参数。然而,如果模板参数具有缺省模板实参,那么用来替换的模板实参就是可选的(即不是必须的)。
template<typename T> class Types { public: typedef int I; }; template<typename T, typename U = typename Types<T>::I> class S; // (1) template<> class S<void> // (2) { public: void f(); }; template<> class S<char, char>; // (3) template<> class S<char, 0>; // 错误:不能用0来替换U int main() { S<int>* pi; // 正确:使用(1),这里不需要定义 S<int> e1; // 错误:使用(1),需要定义,但找不到定义 S<void>* pv; // 正确:使用(2) S<void, int> sv; // 正确:使用(2),这里定义是存在的,因为模板特化的第2个参数的缺省类型为int类型 S<void, char> e2; // 错误:使用(1),需要定义,但找不到定义 S<char, char> e3; // 错误:使用(3),需要定义,但找不到定义 } template<> class S<char, char> // (3)处的定义 { };
如例子中所示,(模板)全局特化的声明并不一定是定义。另外,当一个全局特化声明之后,针对该(特化的)模板实参列表的调用,将不再使用模板的泛型定义,而是使用这个全局特化的定义。因此,如果在调用处需要该特化的定义,而在这之前并没有提供这个定义,那么程序将会出现错误。对于类模板特化而言,“前置声明”类型有时候是很有用的,因为这样就可以构造相互依赖的类型。另外,以这种方式获得的全局特化声明(应该记住它并不是模板声明)和普通的类声明是类似的,唯一的区别在于语法以及该特化的声明必须匹配前面的模板声明。对于特化声明而言,因为它并不是模板声明,所以应该使用(位于类外部)的普通成员定义语法,来定义全局类模板特化的成员(也就是说,不能指定template<>前缀):
template<typename T> class S; template<> class S<char**> { public: void print() const; }; // 下面的定义不能使用template<>前缀 void S<char**>::print() const { std::cout << "pointer to pointer to char \n"; }
我们知道,可以用全局模板特化来代替对应泛型模板的某个实例化体。然而,全局模板特化和由模板生成的实例化版本是不能共存于同一个程序中的,否则会导致编译期错误。遗憾的是,如果在不同的翻译单元,将很难捕捉到这种错误。如下:
// 翻译单元1 template <typename T> class Danger { public: enum { max = 10 }; }; char buffer[Danger<void>::max]; // 使用了泛型值 extern void clear(char const*); int main() { clear(buffer); } // 翻译单元2 template<typename T> class Danger; template<> class Danger<void> { public: enum { max = 100 }; }; void clear(char const* buf) { // 可能与原先定义的数组大小不匹配 for(intk = 0; k < Danger<void>::max; ++k) { buf[k] = ‘\0‘; } }
显然,这个例子是我们经过裁减的。但它也告诉我们:在使用特化的时候,我们需要特别小心,并且确认特化的声明对泛型模板的所有用户都是可见的。在实际的应用中,这意味着:在模板声明所在的头文件中,特化的声明通常都应该位于模板声明的后面。然而,泛型实现也可能来自于外部资源(诸如不能被修改的头文件);尽管实际很少采用这种方式,但我们可以创建一个包含泛型模板的头文件,并让特化声明位于泛型模板之后,来避免这种“难以发现”的错误;实际上,这种做法有时候很有必要的。另外,如果不具有特殊目的的话,我们通常都避免让模板特化来自于外部资源。
12.3.2 全局的函数模板特化
就语法及其后所蕴涵的原则而言,(显式的)全局函数模板特化和类模板特化大体上是一致的,唯一的区别在于:函数模板特化引入了重载和实参演绎这两个概念。借助实参演绎(用实参类型来演绎声明中给出的参数类型)来确定模板的特殊化版本,那么全局特化就可以不声明显式的模板实参。
注意:全局函数模板特化不能包含缺省的实参值。然而,对于基本(即要被特化的)模板所指定的任何缺省实参,显式特化版本都可以应用这些缺省实参值。如:
template<typename T> int f(T, T x = 42) { return x; } template<> int f(int, int = 35) // 错误,不能包含缺省实参值,但如果没有指定第2个实参,则会使用基本模板的缺省参数值 { return 0; } template<typename T> int g(T, T x = 42) { return x; } template<> int g(int, int y) { return y/2; } int main() { std::cout << g(0) << std::endl; // 正确,输出21 }
全局特化声明和普通声明在许多方面都是很相似的(或者进一步说,可以把它看成一个普通的再次声明)。尤其是,全局特化声明的声明对象并不是一个模板,因此对于非内联的全局函数模板特化而言,在同个程序中它的定义只能出现一次,然而,我们仍然必须确保:全局函数模板特化的声明必须紧跟在模板定义的后面,以避免试图使用一个由模板直接生产的函数。因此,在前面的例子中,通常应该把模板g的声明放在两个文件中。接口文件如下所示:
#ifndef TEMPLATE_G_HPP #define TEMPLATE_G_HPP // 模板定义应该放在头文件中: template<typename T> int g(T, T x = 42) { return x; } // 特化声明禁止模板进行实例化;但为了避免出现重复定义,就不能把定义放在这里 template<> int g(int, int y); #endif // TEMPLATE_G_HPP // ------------------------------------------ #include "template_g.hpp" template<> int g(int, int y) { return y/2; }
另一种解决方案是把这个特化声明为内联函数:在这种情况下,该函数的定义就可以(也应该)放在头文件中。
12.3.3 全局成员特化
除了成员模板之外,类模板的成员函数和普通的静态成员变量也可以被全局特化;实现特化的语法会要求给每个外围类模板加上template<>前缀。如果要对一个成员模板进行特化,也必须加上另一个template<>前缀,来说明该声明表示的是一个特化。为了说明这些含义,让我们假设具有下面的声明:
template<typename T> class Outer // (1) { public: template<typename U> class Inner // (2) { private: static int count; // (3) }; static int code; // (4) void print() const // (5) { std::cout << "generic"; } }; template<typename T> int Outer<T>::code = 6; // (6) template<typename T> template<typename U> int Outer<T>::Inner<U>::count = 7; // (7) template<> class Outer<bool> // (8) { public: template<typename U> class Inner // (9) { private: static int count; // (10) }; void print() const {} // (11) };
在(1)处的泛型模板Outer中,(4)处的code和(5)处print(),这两个普通成员都具有一个外围类模板。因此,需要使用一个template<>前缀说明:后面将用一个模板实参集来对它进行全局特化:
template<> int Outer<void>::code = 12; template<> void Outer<void>::print() const { std::cout << "Outer<void>"; }
这些定义将会用于替代类Outer<void>在(4)处和(5)处的泛型定义;但是,类Outer<void>的其他成员仍然默认地产生自(1)处的模板。另外,在提供了上面的声明之后,就不能再次提供Outer<void>的显式特化。
类似于全局函数模板特化,我们需要一种可以在不指定定义的前提下(为了避免多处定义),可以声明类模板普通成员特化的。尽管对于普通类的成员函数和静态成员变量而言,非定义的类外声明在C++中是不允许的;但如果是针对类模板的特化成员,该声明则是合法的。也就是说,前面的定义可以具有如下声明:
template<> int Outer<void>::code; template<> void Outer<void>::print() const;
细心的读者可能会发现,全局特化Outer<void>::code的非定义声明的语法,看起来等同于下面的语法:提供一个能够用缺省构造函数进行初始化的定义。事实上也正是如此,但这些声明仍然被解释为非定义声明。
因此,如果静态成员变量的类型是一个只能使用缺省构造函数进行初始化的类型,那么就不能为该静态成员变量的全局特化提供一个定义:
class DefaultInitOnly { public: DefaultInitOnly(){ } private: DefaultInitOnly(DefaultInitOnly const&); // 不存在拷贝操作,也即“只能使用缺省构造函数进行初始化的类型” }; template<typename T> class Statics { private: static T sm; }; // 下面只是一个声明 // 不存在可以用来提供一个定义的语法 template<> DefaultInitOnly Statics<DefaultInitOnly>::sm; // 无法使用"sm = xxx"的定义语句,因为拷贝构造函数被声明为私有的
对于成员模板Outer<T>::Inner,也可以用一个特定的模板实参对它进行特化,而且对于该特化所在的外围Outer<T>而言,这个特化操作并不会影响Outer<T>相应实例化体的其他成员。另外,由于存在一个外围模板(也就是Outer<T>),所以我们需要添加一个template<>前缀。最后获得的代码大致如下:
template<> template<typename X> class Outer<wchar_t>::Inner { public: static long count; // 成员类型发生了改变 }; template<> template<typename X> long Outer<wchar_t>::Inner<X>::count; // 模板Outer<T>::Inner也可以被全局特化,但只能针对Outer<T>的某个给定实例。而且,我们需要添加两个template<>前缀:因为外围类需要一个template<>前缀,我们所要全局特化的内围模板也需要一个template<>前缀: template<> template<> class Outer<char>::Inner<wchar_t> { public: enum { count = 1 }; }; // 下面的C++程序是不合法的: // template<> 不能位于模板实参列表的后面 template<typename X> template<> class Outer<X>::Inner<void>; // 错误
我们可以将上面这个特化于Outer<bool>的成员模板的特化比较一下。由于Outer<bool>已经在前面全局特化了,所有它的成员模板也就不存在外围模板,因此我们就只需要一个tempate<>前缀:
template<> class Outer<bool>::Inner<wchar_t> { public: enum { count = 2 }; };
12.4 局部的类模板特化
全局模板特化通常都是很有用的,但有时候我们更希望把类模板特化成一个“针对模板实参”的类家族,而不是针对“一个具体实参列表”的全局特化。如下面的例子:
template<typename T> class List // (1) { public: ... void append(T const&); inline size_t length() const; ... };
对于某个使用这个模板的大项目,它可能会基于多种类型来实例化该模板成员。于是,对于那些没有进行内联扩展的成员函数(譬如List<T>::append()),这就可能会明显增加目标代码的大小。然而,如果我们从一个更低层次的实现来看,List<int*>::append()的代码和List<void*>::append()的代码是完全相同的。也就是说,我们希望可以让所有的指针List共享同一个实现。尽管我们不能直接用C++来表达这种实现,但我们可以指定所有的指针List都实例化自一个不同的模板定义,从而近似地获得这种实现:
template<typename T> class List<T*> // (2) { private: List<void*> impl; ... public: ... void append(T* P) { impl.append(p); } size_t length() const { return impl.length(); } ... };
在这种情况下,我们把原来的模板(即(1)处的模板)称为基本模板,而后一个定义则被称为局部特化(因为该模板定义所使用的模板实参只是被局部指定)。表示一个局部特化的语法包括:一个模板参数列表声明(template<...>)和在类模板名称后面显式指定的模板实参列表(在我们的例子中是<T*>)。
我们前面的代码还存在一个问题,因为List<void*>会递归地包含一个相同类型的List<void*>成员。为了打破这种无限递归,我们可以在这个局部特化前面先提供一个全局特化:
template<> class List<void*> // (3) 解决模板无限递归问题 { ... void append(void* p); inline size_t length() const; ... };
这样,一切才是正确的。因为当进行匹配的时候,全局特化会由于局部特化。于是,指针List的所有成员函数都被委托给List<void*>的实现(通过容易内联的函数)。针对C++模板备受指责的代码膨胀的缺点,这也是克服该缺点的有效方法之一。
对于局部特化声明的参数列表和实参列表,存在一些约束。下面就是一些重要的约束:
1. 局部特化的实参必须和基本模板的相应参数在种类上(可以是类型、非类型或者模板)是匹配的。
2. 全局/局部特化的参数列表不能具有缺省实参;但局部特化仍然可以使用基本类模板的缺省实参;
3. 局部特化的非类型实参只能是非类型值,或者是普通的非类型模板参数;而不能是更复杂的依赖型表达式(诸如2*N,其中N是模板参数)。
4. 局部特化的模板实参列表不能和基本模板的参数列表完全等同(不考虑重新命名)。
下面的例子详细说明了这些约束:
template<typename T, int I = 3> class S; // 基本模板 template<typename T> class S<int, T>; // 错误:参数类型不匹配 template<typename T = int> class S<T, 10>; // 错误:不能具有缺省实参 template<int I> class S<int, I*2>; // 错误:不能有非类型的表达式 template<typename U, int k> class S<U, K>; // 错误:局部特化和基本模板之间没有本质的区别
每个局部特化(和每个全局特化一样)都会和基本模板发生关联。但使用一个模板的时候,编译器肯定会对基本模板进行查找,但接下来会匹配调用实参和相关特化的实参,然后确定应该选择哪一个模板实现。如果能够找到多个匹配的特化,那么将会选择“最特殊”的特化(和重载函数模板所定义的原则一样);如果有未能找到“最特殊”的一个特化,即存在几个特殊程度一样的特化,那么程序将会包含一个二义性错误。
最后,我们应该指出:类模板局部特化的参数个数是可以和基本模板不一样的;既可以比基本模板多,也可以比基本模板少。
下面提供针对(特定的)成员指针类型的模板特化:
template<typename C> class List<void* C::*> // (4) { public: // 针对指向void*的成员指针的特化 // 除了void*类型之外,每个指向成员指针的指针类型都会使用这个特化 typedef void* C::*ElementType; ... void append(ElementType pm); inline size_t length() const; ... }; template<typename T, typename C> class List<T* C::*> // (5) { private: List<void* C::*> impl; ... public: // 针对任何指向成员指针的指针类型的局部特化 // 除了指向void*的成员指针类型,它在前面已经处理了 // 我们看到这个局部特化具有两个模板参数 // 然而基本模板却只有一个参数 typedef T* C::*ElementType; ... void append(ElementType pm) { impl.append((void* C::*)pm); } inline size_t length() const { return impl.length(); } ... };
除了模板参数数量不同之外,我们看到在(4)处定义的公共实现本身也是一个局部特化(对于简单的指针例子,这里应该是一个全局特化),而所有其他的局部特化((5)处的声明)都是把实现委托给这个公共实现。显然,在(4)处的公共实现要比(5)处的实现更加特殊化,因此也就不会出现二义性问题。