第 16 章 特化和重载
到目前为止,我们已经研究了C++模板如何允许将泛型定义扩展为一系列相关的类,函数或变量。尽管这是一种强大的机制,但是在许多情况下,对于模板参数的特定替换,操作的泛化形式远非最佳。
C++与其他流行的编程语言相比,对于泛型编程来说有着一独到之处,这是因为它有着一个丰富的特性集,使得某一个更加特化的设施对泛型定义进行无形替代。本章中,我们会学习两种C++语言机制,它们允许从纯粹的泛化产生实际的偏差:模板特化和函数模板重载。
16.1 当“泛型代码”不完全契合时
考虑下例:
1 |
|
对简单类型来说,exchange()
的泛型实现表现良好。然而,对于有着昂贵的拷贝操作符的类型来说,相比于为特定的给定结构体量身定制的实现来说,泛型实现体更为昂贵(从机器周期和内存使用两方面来说)。在我们的例子中,泛型实现体需要调用一次Array<T>
的拷贝构造器和两次Array<T>
的拷贝操作符(译者注:作者这里应该是想用Array<T>
代入exchange
的模板参数T
)。对于大尺寸的数据结构来说,这些拷贝动作通常会涉及复制相对大量的内存。然而,exchange()
的功能可以通过仅仅交换内部的data
指针来取而代之,就好像在其成员函数exchangeWith()
中所作的那样。
16.1.1 透明客制化
在前例中,成员函数exchangeWith()
提供了一个对泛型exchange()
函数的一个高效替换体,但是这样一来,就需要使用一个不同的函数,而这会在以下几个方面给我们带来不便:
Array
类的使用者不得不记住其额外接口,并且必须在可以使用时万分小心。- 泛型算法通常无法区分不同的可能性。例如:
1 |
|
基于这些考虑,C++模板提供了透明地客制化函数模板和类模板的方法。对函数模板来说,是通过重载机制来达成。例如,我们可以编写一个重载的quickExchange()
函数模板集合,如下所示:
1 |
|
第一个quickExchange()
的调用有两个类型为int*
的实参,因此只有第一个模板才能推导成功,T
由int
替换。因此对于哪个函数应该被调用毫无疑义。相对地,第二个调用就可以同时匹配上面的两个模板:第一个模板使用Array<int>
替换T
,第二个模板使用int
替换T
。另一方面,在两个函数替换的结果中,参数类型都是严格匹配调用实参的。通常来说,这应该得出一个调用有歧义的结论,但是相对于第一个模板来说,C++语言认为第二个模板“更加特化”。在其他项都相同的情况下,重载解析会倾向于更加特化的模板,因此这里会选择第二个函数模板。
16.1.2 语义透明性
在上一节中使用的重载对达成实例化过程的透明客制化来说非常有用,但是有一点非常重要:该“透明性”非常非常依赖于实现体的细节。为了阐明这一点,考虑一下我们的quickExchange()
解决方案。尽管泛型算法和为Array<T>
类型客制化的算法最后都可以交换指针所指向的值,但是二者各自所带来的边缘效应却是截然不同的。
下面的代码对比了交换结构对象和交换Array<T>
对象的值,这可以形象地阐释上面两者的不同之处:
1 |
|
如示例所展示,在调用quick_exchange()
后,指向第1个Array
的指针p
变成了指向第2个Array
的指针(即使值没有改变);然而,指向非Array
(即struct S
)s1
的指针在交换操作执行之后,仍然指向s1
,只是指针所指向的值发生了交换。这一区别非常显著,足以让客户端混淆模板实现。前缀quick_有助于吸引人们注意以下事实:可以采取捷径来实现所需的操作。然而,原始的泛型exchange()
模板也可以对Array<T>
进行一个有效的优化:
1 |
|
对泛型代码来说,这一版本的优势在于不再需要额外的大尺寸临时Array<T>
对象。exchange()
模板会被递归地调用,因此对于诸如Array<Array<char>>
这样的类型来说,可以获得更好的性能。同时也注意到模板的更加特化的版本并没有声明inline
,这是因为它本身会做很多的递归操作,相对而言,原始的泛型实现体声明了inline
,因为它仅仅执行了少数的几个操作(每一个操作可能都很昂贵)。
16.2 重载函数模板
在前面的章节中我们已经看到了两个同名函数模板可以共存,尽管它们可能会实例化出相同的参数类型。这里有另一个简单的例子:
1 |
|
当第一个模板使用int*
替换T
、第二个模板使用int
替换T
时,二者就会得到一个参数类型(以及返回类型)完全相同的函数。不仅是这些模板可以共存,就连它们各自的实例化体也可以共存(即使它们有相同的参数和返回类型)。
下例展示了像这样生成的两个函数要如何使用显式模板实参语法来调用:
1 |
|
该程序输出如下:12
为了说明这一结果,我们来详细分析一下f<int*>((int*)nullptr)
调用。f<int*>()
表示我们想要用int*
来替换f()
模板的第一个参数,此时无需依赖模板实参推导。本例中有多个模板f()
,因此得以创造一个包含两个函数的重载集合,这两个函数通过模板f<int*>(int*)
(由第一个模板生成)和f<int*>(int**)
(由第二个模板生成)生成。调用实参(int*)nullptr
的类型为int*
。这仅仅与第一个模板生成的函数匹配,因此最终调用的就是该函数。
相对而言,第二个调用所创造的重载集合中包含了f<int>(int)
(由第一个模板生成)和f<int>(int*)
(由第二个模板生成),其中第二个模板是匹配的。
16.2.1 签名
两个函数如果拥有不同的签名,那么就可以在一个程序中共存。我们将函数的签名定义为如下信息:
- 非受限函数的名称(或者生成该函数的函数模板名称)。
- 函数名称所属的类或命名空间作用域;如果函数名称拥有内部链接,还包括该名称声明所在的编译单元。
- 函数的
const
、volatile
或const volatile
限定(前提是拥有这样一个限定符的成员函数) - 函数的
&
或&&
限定(如果是拥有这样一个限定符的成员函数) - 函数参数的类型(如果函数是从函数模板中生成的,那么指的是替换前的模板参数)
- 如果函数是从函数模板中生成,则包括它的函数返回类型
- 如果函数是从函数模板中生成,则包括模板参数和模板实参
这意味着下面的模板和它们的实例化体可以在同一个程序中共存:
1 |
|
然而,当它们定义在相同的作用域中时,它们并不能总被使用,这是因为实例化会产生重载歧义。例如,调用f2(42)
对于上面声明的模板来说显然会产生歧义。另一个例子在下面演示:
1 |
|
这里,函数f1<T1 = char, T2 = char>(T1, T2)
可以与函数f1<T1 = char, T2 = char>(T2, T1)
共存,但是重载解析永远无法抉择出哪一个更合适。如果模板在不同的编译单元中出现,这两个实例化体实际上可以在同一个程序中共存(并且,链接器不应该抱怨重复的定义,这是因为实例化体的签名是有所区别的):
1 |
|
该程序是有效的,它的输出如下:
f1(T2, T1)
f1(T1, T2)
16.2.2 重载函数模板的偏序
再次考虑一下我们早先的例子:我们发现在替换了给定的模板实参列表后(<int*>
和<int>
),重载解析最终会选择最合适的函数并进行调用:
1 |
|
然而,即使显式模板实参没有提供,函数依然会有做这样的选择。本例中,模板实参推导发挥了作用。让我们稍微修改一下main()
函数来讨论这一机制:
1 |
|
考虑一下第一调用f(0)
:实参的类型是int
,如果我们用int
替换T
,那么它与第一个模板的参数类型匹配。然而,第二个模板的参数类型始终都是一个指针,因此,在推导之后,对于该调用来说只会从第一个模板生成一个唯一的实例作为候选。对这一情景来说,重载解析是多余的。
对于第二个调用f(nullptr)
来说也类似:实参类型是std::nullptr_t
,它再一次仅与第一个模板匹配。
第三个调用f((int*)nullptr)
比较有意思:实参推导对于两个模板来说都会成功,产生函数f<int*>(int*)
和f<int>(int*)
。从传统的重载解析视角来看,这两个使用int*
实参的函数同等优秀,如此理应指出调用存在歧义(参考附录C)。然而,这一案例中,额外的重载解析发挥了作用:更加特化的模板所生成的函数会被选择。在这里,第二个模板被认为是更加特化的,因此该代码示例的输出就是112
。
16.2.3 正式的排序规则
在上例中,我们可以很直观地看出第二个模板比第一个模板更加特化,这是因为第一个模板可以适配各种类型的实参,而第二个则只能容纳指针类型。然而,其他的例子可能没有那么直观。在下面的内容中,我们描述了确定一个函数模板是否比另一个重载模板更特化的确切过程。请注意这些是偏序规则:可以给定两个模板,彼此之间没有更加特化之分。如果重载解析必须从这样的两个模板中选择一个,那么将无法做出决定,且程序会产生一个歧义错误。
假设我们正在比较两个名称相同的函数模板,这些模板对于给定的函数调用似乎可行。重载解析按如下决定:
- 函数调用参数中没有被使用的默认实参和省略号参数在后续将不被纳入考虑。
- 然后我们通过以下方式替换每一个模板实参,为两个模板各自构造出两份实参类型列表(或者对转型函数模板来说,还包括返回类型):
- 使用唯一的虚构类型替换每一个模板类型参数。
- 使用唯一的虚构类模板替换每一个模板模板参数。
- 使用唯一的适当类型的虚构值替换每一个非类型模板参数。(虚构出的类型、模板和值在这一上下文中都与任何其他的类型、模板或值不同,这些其他的类型、模板或值要么是开发者使用的,要么是编译器在其他上下文中合成的。)
- 如果第二个模板对于第一份合成出来的实参类型列表可以进行成功的实参推导(能够进行精确的匹配),而反过来却不行(即第一个模板对第二份实参类型列表无法推导成功),那么我们就认为第一个模板要比第二个模板更加特化。相反地,如果第一个模板对于第二份实参类型列表可以精确匹配而推导成功,反过来则不行,那么我们就认为第二个模板比第一个模板更加特化。否则(要么无法推导成功,要么两个都成功),两个模板之间就没有顺序可言。让我们将此应用于前例的两个模板之上,使得这一概念更加具体。我们从这两个模板构造出两个实参类型列表,按此前描述的那样替换其模板参数:(
A1
)和(A2*
)(A1
和A2
是不同的构造出的类型)。显然,第一个模板对于第二个实参类型列表可以成功推导(将A2*
替换T
)。然而,对于第二个模板来说,没有办法让T*
匹配第一个实参类型列表中的非指针类型A1
。因此,我们得出第二个模板比第一个模板更加特化。
让我们来看一个更加错综复杂的例子,它涉及了多个函数参数:
1 |
|
首先,由于真实的调用没有使用第一个模板的省略号参数和第二个模板的最后一个参数(由默认实参填充),故这些参数会在排序中被忽略。此外,注意到第一个模板的默认实参没有被用到,因此参与到排序中的是其对应的参数(即与之匹配的调用实参)。
合成的两份实参列表分别是(A1*, A1 const*
)和(A2 const*, A2*
)。对于第二个模板来说,实参列表(A1*, A1 const*
)可以成功推导(A1 const
替换T
),但是得到的结果并不能严格匹配,因为当用(A1*, A1 const*
)类型的实参来调用t<A1 const>(A1 const*, A1 const*, A1 const* = 0)
的时候,需要进行限定符的调整(即const
)。类似地,第一个模板对于实参类型列表(A2 const*, A2*
)也不能获得精确的匹配。因此,这两个模板之间并没有顺序关系,同时该调用存在歧义。
这种正式的排序规则通常都能产生符合直观的函数模板选择。然而,该原则偶尔也会产生不符合直觉选择的例子。因此,将来可能会修改某些规则,从而适用于所有例子。
16.2.4 模板和非模板
函数模板可以被非模板函数所重载。在选择实际调用的函数时,非模板函数将更为优先,除此之外没有什么其他区别。下面的例子说明了这一事实:
1 |
|
程序会输出Nontemplate
。
然而,当const
和引用限定符不同时,重载解析的优先级会有所变更。例如:
1 |
|
程序会输出:
Template
Nontemplate
现在,当我们传递非常量int
参数时,函数模板f<>(T&)
是一个更合适的选择。原因在于对于int
来说,f<>(int&)
实例化体要比f(int const&)
更合适。因此,这一差异不仅仅在于以下事实:一个函数是模板,而另一个函数不是模板。在这种情况下,实际应用到的是通用的重载解析规则(参考P682节C.2)。只有当使用int const
调用f()
时,两个函数的签名才会有相同的类型——int const&
,而此时才会优先选择非模板函数。
出于这一原因,按下面的方式声明成员函数模板是个不错的主意:
1 |
|
只不过,当定义成员函数接受与拷贝或移动构造函数相同的实参时,这种效果很容易发生意外并引起令人惊讶的行为。例如:
1 |
|
程序输出如下:
1 |
|
因此,成员函数模板要比C
的拷贝构造函数更合适。而对于std::move(c)
来说,它会产生C const&&
类型(这是一种可行的类型,但是在语法上通常没有什么意义),成员函数模板此时也比移动构造函数更合适。
因此,通常当这些成员函数模板可能隐藏拷贝或移动构造函数时,必须部分禁用它们。这在P99节6.4中解释过。
16.2.5 可变函数模板
可变函数模板(参考P200节12.4)在进行排序时需要被特殊对待,这是因为对参数包的推导(见P275节15.5)过程是将多个实参匹配到单一参数。这一行为对函数模板排序来说引入了各种有趣的场景,我们通过下例来展示:
1 |
|
上例输出的结果是231
,我们随后会进行讨论。
对第一个调用f(0, 0.0)
来说,每个名称为f
的函数模板都会被考虑:第一个函数模板f(T*)
推导会失败,这一方面是因为模板参数T
无法被成功推导,另一方面是因为实参的个数多于该非可变模板参数的个数;第二个函数模板f(Ts...)
是可变模板,推导过程会针对两个实参的类型(分别是int
和double
)与函数参数包(Ts)
的样式进行比较,将Ts
推导为序列(int
, double
);对于第三个函数模板——f(Ts*...)
,推导过程会将每个实参类型与函数参数包Ts*
的样式进行比较,该推导会失败(Ts
无法被推导出来)。因此,最终只有第二个函数模板是可行的,也就不需要函数模板的顺序。
第二个调用——f((int*)nullptr, (double*)nullptr)
更加有趣:对第一个函数模板的推导会失败,因为实参个数多于模板参数个数;对第二个和第三个模板来说推导都会成功,我们显式地写出推导结果如下:
1 |
|
排序规则会考虑第二个和第三个模板,它们都是这样的可变模板:当对可变模板应用P331节16.2.3中描述的正式的排序规则时,每个模板参数包都会由一个单一构造的类型、类模板或是指来替代。举例来说,第二个和第三个函数模板所合成的实参类型分别为A1
和A2*
,其中A1
和A2
都是唯一的构造出的类型。第二个模板对于第三个模板的实参类型列表可以推导成功(通过替换参数包Ts
为单一元素序列(A2*
))。然而,无论如何构造Ts*
的样式,第三个模板参数包始终无法匹配非指针类型A1
,因此第三个函数模板(接受指针类型实参)要比第二个函数模板(接受任意实参)更加特化。
第三个调用——f((int*)nullptr)
,又激起了一层涟漪:三个函数模板的推导都是成功的,因此就需要给非可变参数模板和可变参数模板排排顺序。为了说明,我们比较第一个和第三个函数模板。这里,合成的实参类型分别是A1*
和A2*
,其中A1*
和A2*
都是唯一的构造出的类型。第一个模板对于第三个合成的实参列表可以推导成功(通过替换T
为A2
)。反过来,第三个模板对于第一个合成的实参列表也可以推导成功(通过替换参数包Ts
为单一元素序列(A1
))。第一个和第三个模板之间的顺序可能会产生有歧义的结果。然而,有这样一条特殊的规则:它禁止了那些源于函数参数包(例如,第三个模板参数包Ts*...
)的实参去匹配一个非参数包(第一个模板参数T*
)的参数。因此,第一个模板使用第三个合成的实参列表时推导会失败,于是我们认为第一个模板相比第三个模板更加特化。这一特殊的规则让非可变模板(拥有固定数量的参数)比可变模板(拥有可变数量的参数)更加特化。
前面描述的规则对发生在函数签名的类型中的包展开时有着同等用法。例如,在前面的示例中,我们可以将函数模板的每一个参数和实参包裹成一个可变类模板Tuple
,来实现一个类似的示例而不用引入函数参数包:
1 |
|
函数模板排序时,对模板实参到Tuple
的包展开与我们前面示例中函数包展开有着类似的考虑,其结果输出为:231
。
16.3 显式特化
重载函数模板并根据偏序规则来选择“最”匹配的函数模板这一能力,使得我们可以透明地对泛型实现增加特化模板来调整代码以获得更高的效率。然而,类模板和变量模板无法被重载。取而代之的是,类模板的透明客制化采用了另一种机制:显式特化。标准术语”显式特化“是指一种我们称之为“全特化”的语言特性。它使用完全替代后的模板参数来提供一个模板实现体:没有保留任何模板参数。类模板、函数模板和变量模板都可以进行全特化。
类模板的成员可以被定义在类定义体的外部(即,成员函数,嵌套类,静态数据成员和成员枚举类型)。
在后面的一节中,我们会描述“偏特化”。它与全特化相似,只不过并没有完全替换模板参数而是在模板的替代实现中保留了一些参数化。全特化和偏特化在我们的代码中都是同等“显式的”,这也是为什么我们在讨论中避开用术语”显式特化“的原因。全特化和偏特化都没有引入一个全新的模板或是模板实例。相反,这些结构为泛型模板中已经隐式声明的实例提供了替代的定义。这是一个相对重要的概念,它是与重载模板的主要区别。
16.3.1 类模板全特化
全特化由连续的template
,<
和>
语法块引导,且类名称的后面跟随着特化所声明的模板实参。下面的例子对此进行了说明:
1 |
|
请注意,完全特化的实现无需以任何方式与泛型定义相关联:这意味着我们可以使用不同名称的成员函数(info
对msg
)。二者的关联仅由类模板的名称所决定。
特化模板实参列表必须与模板参数列表一致。举例来说,为模板类型参数指定一个非类型值是不合法的。然而,对于有着默认模板实参的模板参数来说,对应的模板实参也是可选的:
1 |
|
如上例所展示,全特化的声明可以无需定义体。然而,当声明了全特化时,泛型定义就永远不会使用这一组既定的模板实参来实例化。因此,如果程序需要某个定义但是却找不到对应的实现体时就会出错。对类模板特化来说,有时“前置声明”类型会很有用,因为这样就可以构造相互依赖的类型。全特化声明与普通的类声明在这一方面是等同的(记住它不是模板声明)。唯一的区别在于语法以及特化声明必须匹配前面的模板声明。因为这不是模板声明,全特化类模板的成员可以使用普通的类外成员定义语法来定义(换句话说,不能指定模板template<>
前缀):
1 |
|
一个更复杂的例子来加强理解这一概念:
1 |
|
全特化是泛型模板的特定实例化体的替代体,并且在同一个程序中无法同时存在显式全特化体和模板生成的实例化体这两个版本。试图在同一个文件中使用两者通常会被编译器所捕获:
1 |
|
不幸的是,如果在不同的编译单元中使用,问题可能不会被轻易捕获。下面的C++代码由两个文件组成,在多个平台上编译和链接这个例子都表示它是非法的,甚至是危险的:
1 |
|
显然,为了使该示例简短我们做了裁剪,但是它说明了:在使用特化时,必须非常小心地确认特化的声明对泛型模板的所有用户都是可见的。实际应用中,这意味着:在模板声明所在的头文件中,特化的声明通常应该在模板的声明之后。然而,泛型实现也可能来自外部源码(诸如不能被修改的头文件),尽管实际中很少采用这种方式,但还是值得我们去创建一个包含泛型模板的头文件,并让特化声明位于泛型模板之后,以避免这种“难以排查”的错误。此外,通常来说,最好避免从外部源码引入特化模板,除非明确表示设计的目的就是如此。
16.3.2 函数模板全特化
函数模板全特化背后的语法和原则与类模板全特化大体相同,只是加入了重载和实参推导。
如果可以借助实参推导(用实参类型来推导声明中给出的参数类型)和偏序来确定模板的特化版本,那么全局特化就可以不声明显式的模板实参。举个例子:
1 |
|
函数模板全特化不能包含默认实参值。然而,对于被特化的模板所指定的任何默认实参,显式特化版本都可以应用这些默认实参值。例如:
1 |
|
(这是因为全特化提供的是一个替换的定义,而不是一个替换的声明。在调用函数模板的时点,该调用已经完全基于函数模板而完成解析了。)
全特化声明和普通声明(或者是一个普通的再次声明)在很多方面都很类似。特别地,它不会声明一个模板,因此对于非内联全特化函数模板特化来说,在程序中它只能有一个定义。然而,我们必须确保:函数模板的全特化声明是跟随在模板定义之后,以避免试图使用一个由模板生成的函数。因此,模板g()
的声明和全特化声明应该被组织成两个文件,如下所示:
- 接口文件包含了主模板的定义和偏特化的定义,但是仅包含全特化的声明:
1 |
|
- 相应的,实现文件包含了全特化的定义:
1 |
|
或者全特化也可以做成内联,此时它的定义就可以放在一个头文件中。
16.3.3 变量模板全特化
变量模板也可以被全特化。现如今,语法非常直观:
1 |
|
显然,特化可以提供一个不同于模板产生结果的初始化器。有趣的是,变量模板特化不需要与模板的类型匹配:
1 |
|
16.3.4 成员全特化
类模板的成员模板、普通静态数据成员、普通成员函数都可以进行全特化。每个类模板作用域都需要一个template<>
前缀。如果要对一个成员模板进行特化,则必须加上另一个template<>
前缀,来说明该声明表示的是一个特化。为了说明这些含义,我们给出下列声明:
1 |
|
泛型模板Outer
(#1)的普通成员code
(#4)和print()
(#5)具有单一的类模板作用域,因此全特化时需要一个template<>
前缀以及一组模板实参:
1 |
|
这些定义将会用于替代类Outer<void>
(在#4和#5处替代泛型定义),但是Outer<void>
的其他成员仍然会通过#1处的模板来生成。请注意,在进行了这些声明之后,将不能再次提供Outer<void>
的显式特化。
正如函数模板全特化那般,我们也需要一种方式来声明类模板普通成员的特化而不用去定义它(防止出现多个定义体)。尽管对于普通类的成员函数和静态数据成员而言,非定义的类外声明在C++中不被允许,但如果是针对类模板的特化成员,该声明则是合法的。也就是说,前面的定义可以具有如下声明:
1 |
|
细心的读者可能会发现Outer<void>::code
的全特化非定义声明看上去就是一个使用默认构造器的初始化定义。实际上也确实如此,只不过这样的声明永远会被解析成非定义申明。因此,如果静态数据成员的类型是一个只能使用默认构造函数进行初始化的类型,我们就必须采用初始化列表语法。如下示例:
1 |
|
下面的语句是一个声明:
1 |
|
如果想要一个定义并调用默认构造器:
1 |
|
在C++11之前,这无法办到。对于这种特化也无法实现默认初始化。以前的经典办法是使用拷贝初始化:
1 |
|
遗憾的是,对我们的例子来说这是行不通的,因为拷贝构造器被删除了。然而,C++17引入了强制复制省略(mandatory copy-elision)法则,这一法则使得该实现合法化,因为这里实际上不会真正调用拷贝构造器。
成员模板Outer<T>::Inner
也可以使用特定的模板实参进行特化,对于该特化所在的外围Outer<T>
而言,它不会影响Outer<T>
相应实例化体的其他成员。同样地,由于存在一个外围模板,所以我们需要添加一个template<>
前缀。代码应该写成下面这样:
1 |
|
模板Outer<T>::Inner
也可以被全特化,但只能针对某个给定的Outer<T>
实例。我们现在需要两个template<>
前缀:第一个是因为外围类的存在,第二个是因为我们全特化了内层模板:
1 |
|
我们可以将此与Outer<bool>
的成员模板特化进行比较。由于后者已经进行过全特化了,也就没有外部模板了,此时我们只需要一个template<>
前缀:
1 |
|
16.4 类模板偏特化
模板全特化通常很有用,但有些时候我们更希望对类模板或变量模板对”一整个家族的模板实参“进行特化,而不是针对“一个具体实参列表”进行全特化。例如,假设下面是一个类模板实现的链表:
1 |
|
使用该类模板的大型项目会为多种类型实例化出它的成员。对于非内联展开的成员函数来说(即List<T>::append()
),这回导致对象代码的显著膨胀。然而,如果我们从一个底层视角来看,List<int*>::append()
和List<void*>::append()
是等同的。换句话说,我们可以指定所有的指针型List
共享同一个实现体。尽管这无法在C++中直接表达,但我们可以指定所有的指针型List
都从不同的模板定义中实例化,从而达成近似的目标:
1 |
|
在该上下文中,#1处的原始模板被称作主模板,后面的定义被称为偏特化(因为模板定义所使用的模板实参只指定了一部分)。模板参数列表声明(template<...>
),再加上显式指定的模板实参集合(在类模板名称后,本例中是<T*>
),两者组合在一起就是偏特化语法的表征。
我们的代码中有一个问题,List<void*>
会递归地包含相同的List<void*>
类型。为了打破这一循环,我们可以在该偏特化之前先提供一个全特化:
1 |
|
这样之所以行得通,是因为全特化的优先级高于偏特化。因此,指针型List
的所有的成员函数都会通过内联函数转发到List<void*>
的实现体。这是一种对抗代码膨胀(C++模板经常会遇到)的有效方法。
偏特化声明的参数和实参列表存在着一些约束。下面是这些约束的一部分内容:
- 偏特化的实参必须与主模板对应的参数相匹配。
- 偏特化的参数列表不能具有默认实参;作为替代,主类模板的默认实参会被使用。
- 偏特化的非类型实参要么是一个非依赖型值,要么是一个普通的非类型模板参数。它们不能是更加复杂的表达式,诸如
2*N
(N
是一个模板参数)。 - 偏特化的模板实参列表不应该与主模板的参数列表完全相同(忽略重命名)。
- 如果模板实参的某一个是包展开,那么它必须位于模板实参列表的最后。
用一个例子来说明这些约束:
1 |
|
每个偏特化和每个全特化一样,都和主模板相关联。模板被使用时,编译器总是会对主模板进行查找,但接下来还会匹配调用实参和相关联特化的实参(使用模板实参推导,如15章所描述),然后确定应该选择哪一个模板实现体。与函数模板实参推导一样,SFINAE原则会在这里应用:如果在试图匹配一个偏特化时产生了无效的结构,那么特化就会被默默丢弃,然后继续对下一个候选进行试验(如果可行的话)。如果找不到匹配的特化,主模板就会被选择;如果能够找到多个匹配的特化,那么就会选择“最特殊”的特化(与重载函数模板所定义的规则一样),而这其中如果无法确定“最特殊”的那一个(即存在几个特殊程度相同的特化),那么程序就会抛出有歧义错误。
最后,我们要指出:类模板偏特化的参数个数是可以和主模板不一样的,它既可以多于主模板,也可以少于主模板。让我们再次考虑泛型模板List
(在#1处声明)。我们已经讨论了如何优化指针型List
的情景,但我们希望可以针对特定的成员指针类型实现这种优化。下面的代码就是针对指向成员指针的指针,来实现这种优化:
1 |
|
除了模板参数数量不同之外,我们看到在#4处定义的公共实现本身也是一个偏特化(对于简单的指针例子,这里应该是一个全特化),而所有其他的偏特化(#5处的声明)都是把实现委托给这个公共实现。显然,在#4处的公共实现要比#5处的实现更加特化,因此也就不会造成歧义。
此外,显式书写的模板实参数量与主模板的模板参数数量甚至也可能不同。这会在拥有默认模板实参以及拥有可变模板时发生:
1 |
|
16.5 变量模板偏特化
变量模板在C++11标准的草稿中引入时,其许多方面的规范都被忽视了,其中的一些问题依然没有给出官方定论。然而,实际当中,各种编译器在实现时通常对这些问题的处理都颇为一致。
这些问题中可能最令人惊讶的是:标准会更倾向于偏特化变量模板,但是却并没有描述它们要如何声明或者它们意味着什么。因此,下面的内容基于实践中的C++实现(确实允许这种偏特化),而不是基于C ++标准。
如你所愿,语法与变量模板全特化是类似的,除了template<>
要被替换成实际的模板声明头,并且变量模板名称后跟随着模板实参列表必须依赖于模板参数。例如:
1 |
|
与变量模板全特化一样,偏特化的类型也不需要匹配主模板的类型:
1 |
|
变量模板偏特化可以指定的模板参数种类这一规则与类模板偏特化是相同的。类似地,为给定的具体模板实参列表选择某一个特化的规则也是相同的。
16.6 后记
模板全特化是C++模板机制中一开始就有的一部分。然而,函数模板重载和类模板偏特化则出现得晚一些。第一个实现了函数模板重载的是HP的C++编译器,而第一个实现了类模板偏特化的是EDG的C++前端(C++ front end)编译器。本章中描述的偏序规则最早由Steve Adamczyk和John Spicer发明(这两位都是EDG的成员)。
模板特化可以终止模板定义的无限递归(诸如P348节16.4中出现的List<T*>
)这一项能力长久以来可谓广为人知。然而,Erwin Unruh可能是提出模板元编程(使用模板实例化机制在编译器执行非琐碎的计算。我们会在第23章中致力于这一话题)这一有趣概念的第一人。
你可能想知道为什么只有类模板和变量模板可以被偏特化。实际上大都是历史成因。为函数模板定义这一相同的机制是可行的(参考第17章)。在某些方面,函数模板的重载效果与之相似,但是也存在一些细微的差异。这些差异主要与以下事实有关:在用到的时候仅需要查找主模板,随后才考虑特化,以确定哪一个实现体会被使用。相反,在进行查找时,所有的重载函数模板都必须放入一个重载集合中,它们可能源于不同的命名空间或是类。这增加了模板名称被无意中重载的可能性。
相反,也可以想象允许某种形式的类模板和变量模板重载。这里有一个例子:
1 |
|
然而,似乎并没有迫切需要这一机制。