题记:本系列学习笔记(C++ Primer学习笔记)主要目的是讨论一些容易被大家忽略或者容易形成错误认识的内容。只适合于有了一定的C++基础的读者(至少学完一本C++教程)。
作者: tyc611, 2007-02-01
本文主要讨论C++中模板与泛型编程,侧重于介绍模板实现相关技术。
如果文中有错误或遗漏之处,敬请指出,谢谢!
所谓泛型编程就是以独立于任何特定类型的方式编写程序。使用泛型程序时,我们需要提供具体程序实例所操作的类型或值。而模板是泛型编程的基础。标准库的输入输出流、容器、迭代器和算法都是用模板实现的,是泛型编程的例子。我们把C++程序中的多态度性分为两种:一是面向对象编程所依赖的动态多态性;二是泛型编程所依赖的编译时多态性或参数式多态性。
模板
模板定义以关键字template开始,后接模板形参表(template parameter list),模板形参表是用尖括号括起来的一个或多个模板形参(template parameter)的列表,形参之间以逗号分隔。注意:模板形参表不能为空。模板形参可以是表示类型的类型形参(type parameter),也可以是表示常量表达式的非类型形参(nontype parameter)。类型形参必须带上关键字class或typename(推荐使用),而每个非类型形参前面必须带上类型名字。注意:非类型模板形参的实参必须是编译时常量表达式。
在定义模板时,若要使用模板类型形参内部的类型名,必须显式利用typename关键字表明这是一个类型名,因为编译器不能通过检查得知这是一个类型还是值。默认情况下,编译器假定这样的名字指定的是数据成员,而不是类型。注:通常用typename声明是一个类型是没有坏处的。例如:
template<typename T>
void func(T a) {
typename T::size_type *p;
}
在上面的代码中,如果在T::size_type之前不用typename声明它是一个类型,则编译器有两种解释:一是把size_type看成一个类型,则上面的那条语句是一个变量定义;二是把size_type看成一个数据成员,则上面的那条语句被解释成两数据相乘。编译器在这种情况下,默认选择第二个解释,此时就会发生错误。
模板形参的名字可以在声明为模板参数之后直到模板声明或定义的末尾处使用。同样,模板形参遵循常规名字屏蔽规则。但用作模板形参的名字不能在模板内部重用,也意味着模板形参的名字只能在同一模板形参表中使用一次(不可重用)。
函数模板也可以声明为inline,此时必须把inline关键字放在模板形参表之后、返回类型之前。
编写泛型程序
编写模板代码时,应当做到对实参类型的要求尽可能少。一般有两个重要原则:
> 模板的形参是const引用;
> 函数体中的测试只用<比较。
使用模板的错误检查
一般而言,编译模板时,编译器可能会在三个阶段中标识错误:
> 第一阶段是编译模板定义本身时。在这个阶段中编译器一般不能发现许多错误,可以检测到诸如漏掉分号或变量名拼写错误一类的语法错误。
> 第二个错误检测时间是在编译器见到模板的使用时。在这个阶段,编译器仍没有很多检查可做。对于函数模板的调用,许多编译器只检查实参的数目和类型是否恰当,编译器可以检测到实参太多或太少,也可以检测到假定类型相同的两个实参是否真地类型相同。对于类模板,编译器可以检测提供的模板实参的正确数目。
> 第三个阶段是在实例化的时候,只有在这个时候可以发现类型相关的错误。根据编译器管理实例化的方式,有可能在链接时报告这些错误。
模板函数的实例化
编译器用模板产生指定的类或函数的特定类型版本的过程称为模板的实例化。
在使用类模板时,类模板实参是必需的。而在函数模板实例化时,编译器可以自动推断模板实参。
类型形参的实参的受限转换
一般而言,不会转换实参以匹配已有的实例化,相反,会产生新的实例。除了产生新的实例化之外,编译器只会执行两种转换:
> const转换:接受const引用或const指针的函数可以分别用非const对象的引用或指针来调用,而无须产生新的实例化。如果函数接受非引用类型,形参类型和实参都忽略const,即无论传递const或非const对象给接受非引用类型的函数,都使用相同的实例化。
> 数组或函数到指针的转换:如果模板形参不是引用类型,则对数组或函数类型的实参应用常规指针转换。数组实参将当作指向其第一个元素的指针,函数实参当作指向函数类型的指针。
例如:
template<typename T>
T fobj(T, T);
template<typename T>
T fref(const T&, const T&);
string s1("str1");
const string s2("str2");
fobj(s1, s2); // ok: calls fobj(string, string), const is ignored
fref(s1, s2); // ok: non const oject s1 converted t const reference
int a[10], b[42];
fobj(a, b); // ok: calls fobj(int*, int*)
fref(a, b); // error: array types don't match,; arguments aren't converted t pointers
注意:上面的类型转换的限制只适用于类型形参的那些实参,而用普通类型定义的形参的实参仍然可以使用常规转换。
函数模板的显式实参
当编译器无法确定模板形参的类型或值时,可以显式指定模板形参所用的类型或值。如果有多个模板形参,可以省略后面的形参(假如编译器能够推断出来的话)。
为调用提供显式模板实参与定义类模板的实例类似,在以逗号分隔、用尖括号括起来的列表中指定显式模板实参。显式模板类型的列表出现在函数名之后、实参表之前。
模板编译模型
标准C++为编译模板代码定义了两种模型。这两种模板中,构造程序的方式很大程度上是相同的:类定义和函数声明放在头文件中,而函数定义和成员定义放在源文件中。两种模型的不同在于,编译器怎样使用来自源文件的定义。所有编译器都支持第一种模型,称为“包含”模型;只有一些编译器支持第二种模型,称为“分别编译”模型。
1)包含编译模型
在包含编译模型(inclusion compilation model)中,编译器必须看到用到的所有模板的定义。一般而言,可以通过在声明函数模板或类模板的头文件中添加一条#include预处理指令使定义可用,该#include引入了包含相关定义的源文件:
// header file: utilities.h #ifndef UTILITIES_H #define UTILITIES_H template <typename T> int compare (const T&, const T&); // other declarations #include "utilities.cc" // get the definitions for compare etc. #endif |
// implementation file: utilities.cc template <typename T> int compare(const T &v1, const T &v2) { if (v1 < v2) return -1; if (v2 < v1) return 1; return 0; } // other definitions |
这一策略使我们能够保持头文件和实现文件的分离,但是需要保证编译器在编译使用模板的时候能看到两种文件。
某些使用包含模型的编译器,特别是较老的编译器,可能产生多个实例。如果两个或多个单独编译的源文件使用同一模板,这些编译器将为每个文件中的模板产生一个实例。在链接的时候,或者在预链接阶段,编译器会选择一个实例化而丢弃其他的。在这种情况下,如果有许多实例化同一模板的文件,编译时性能会显著下降。对许多应用程序而言,这种编译时性能降低不大可能在现代计算机上成为问题,但是,在大系统环境中,编译时选择问题可能变得非常重要。
有些编译器通常支持某些机制,避免同一模板的多个实例化中隐含的编译时开销。若迫切需要避免这种开销,请参考你的编译器用户指南,查看编译器是否支持以及以什么方式支持避免多余的实例化。
2)分别编译模型
在分别编译模型(separate compilation model)中,编译器会为我们跟踪相关的模板定义。但是,我们必须让编译器知道要记住给定的模板定义,可以使用export关键字来做这件事情。
export关键字能够指明给定的定义可能会需要在其他文件中产生实例化。在一个程序中,一个模板只能定义为导出一次。编译器在需要产生这些实例化时计算出怎样定位模板定义。export关键字不必在模板声明中出现。
一般我们在函数模板的定义中指明函数模板为导出的,这是通过在关键字template之前包含export关键字而实现的:
// the template definition goes in a separately-compiled source file
export template <typename Type>
Type sum(Type t1, Type T2) { ... }
这个函数模板的声明像通常一样应放在头文件中,声明不必指定export。
对类模板使用export更复杂一些。通常,类声明必须放在头文件中,头文件中的类定义体不应该使用关键字export,如果在头文件中使用export,则该头文件只能被程序中的一个源文件使用。相反,应该在类的实现文件中使用export:
// Queue.h: class template header goes in shared header file
template <typename Type>
class Queue { ... };
// Queue.cc: implementation file declares Queue as exported
export template <typename Type> class Queue;
#include "Queue.h"
// Queue member definitions
导出类的成员将自动声明为导出的。也可以将类模板的个别成员声明为导出的,在这种情况下,关键字export不在类模板本身指定,而是只在被导出的特定成员定义上指定。
导出成员函数的定义不必在使用成员时可见。任意非导出成员的定义必须像在包含模板中一样对待:定义应放在定义类模板的头文件中。
类模板成员
在类模板的作用域内部,可以用它的非限定名引用该类名。
在类外定义类模板的成员时,其形式形如:
template <typename T> ret_type class-name<T>::member-name
类模板的成员函数本身也是函数模板。像任何其他函数模板一样,需要使用类模板的成员函数产生该成员的实例化。与其他函数模板不同的是,在实例化类模板成员函数时,编译器不执行模板实参推断,相反,类模板成员函数的模板形参由调用该函数的对象的类型确定。这一事实意味着,调用类模板成员函数比调用普通函数模板更灵活,因为模板形参定义的函数形参的实参允许进行常规转换。
实例化
类模板成员函数只有为程序所用才进行实例化。如果某函数从未使用,则不会实例化该成员函数。这一行为意味着,用于实例化模板的类型只需要满足实际使用的操作的要求。定义类模板的对象时,该定义导致实例化模板。定义对象也会实例化用于初始化该对象的任一构造函数,以及该构造函数调用的任意成员。而类模板的指针定义不会对类进行实例化,只有用到这样的指针时才会对类进行实例化。
友元声明
在类模板中可以出现三种友元声明,每一种都声明了一个或多个实体的友元关系:
1)普通非模板类或函数的友元声明,将友元关系授予明确指定的类或函数;
2)类模板或函数模板的友元声明,授予对友元所有实例的访问权;
3)只授予对类模板或函数模板的特定实例的访问权的友元声明。
例如:
case 1):
template <typename Type> class Bar { friend class FooBar; friend void fcn(); // ... }; |
case 2):
template <typename Type> class Bar { template <typename T> friend class Foo1; template <typename T> friend void templ_fcn1(const T&); // ... }; |
case 3):
template <typename T> class Foo2; template <typename T> void templ_fcn2(const T&); template <typename Type> class Bar { friend class Foo2<cha*>; friend void templ_fcn2<char*>(char* const&); // ... };
// More common are friend declarations of the following form template <typename T> class Foo3; template <typename T> void templ_fcn3(const T&); template <typename Type> class Bar { friend class Foo2<Type>; friend void templ_fcn2<Type>(const Type&); // ... }; |
当授予对给定模板的所有实例的访问权的时候,在作用域中不需要存在该类模板或函数模板的声明(如case 2)。实质上,编译器将友元声明也当作类或函数的声明对待。当只对特定实例声明为友元关系时,必须在可以用于友元声明之前声明类或函数模板(如case 3)。
成员模板
任意类(模板或非模板)可以拥有本身为类模板或函数模板的成员,这种成员称为成员模板(member template),成员模板不能为虚。
当需要函数成员的形参类型变化的时候,就需要定义函数成员模板。当在类外定义成员模板时,需要包含两个模板形参表。首先是类模板形参表,然后是成员自己的模板形参表。例如:
template <typename Type> template <typename Iter>
void Bar<Type>::sort(Iter beg, Iter end) { ... }
static成员
类模板可以像任意其他类一样声明static成员。因为每个模板类的实例化表示截然不同的类型,所以他们都有自己的static成员。然而,给定实例化的所有对象都共享同一个static成员。与任意其他成员函数一样,static成员函数只有在程序中使用时才进行实例化。
模板特化
某些情况下,模板定义对某个类型可能是完全错误的,并且有时需要利用关于类型的一些特殊知识,编写比从模板实例化的函数更有效率的函数。这时,我们需要使用模板特化。类或函数模板的特化版本能与模板共同存在,并为用户工作。而对用户而言,他并不需要知道关于特化的细节,他甚至不需要是否存在特化。
模板特化(template specialization)是把模板中的一个或多个模板形参的实际类型或实际值给予指定。
当有特化版本存在时,模板的使用将优先与特化版本相匹配。由于类模板存在部分特化,所以,如果有多个不同程度的特化版本,那么优先与最特化的版本匹配。
模版显式特化(explicit specialization)只能声明或定义在 namespace 作用域中(如果是类成员模版的特化,则是类所在的 namespace),不允许在其它域(比如类域)中进行显式特化,C++ 标准的规定就是如此(可参见 C++03 14.7.3 Explicit specialization 一节)。
函数模板的特化
函数模板的特化形式如下:
> 关键字template后面接一对空的尖括号(<>);
> 再接模板名和一对尖括号,尖括号中指定这个特化定义的模板实参;如果可以从函数形参表推断模板实参,则可以省略模板实参。
> 函数形参表;
> 函数体。
例如,下面是一个函数模板:
template <typename T>
int compare (const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
显然上面的函数对const char*类型无法正常工作,这时我们需要对const char*类型进行模板的特化:
template <>
int compare <const char*> (const char* const &v1, const char* const &v2) {
return strcmp(v1, v2);
}
特化的声明必须与对应的模板相匹配。在上面的例子中,模板有一个类型形参和两个函数形参,函数形参是类型形参的const引用。而特化版本中类型形参固定为const char*,因此,函数形参是const char*的const引用(请注意其书写形式)。由于这里的模板实参const char*可以从后面的函数形参表中推断出来,所以可以省略上面特化定义中的<const char*>:
template <>
int compare (const char* const &v1, const char* const &v2) {
return strcmp(v1, v2);
}
函数模板特化的声明就是把定义的函数体省略掉。
类模板的特化
类模板的特化形式与函数模板的特化形式相似:
template<> class
class-name<
type-list> {...};
特化类模板时可以定义与模板本身完全不同的成员。虽然如此,还是应当尽量保证类模板特化版本与类模板定义相同的接口,否则,当用户试图使用未定义的成员时会感到奇怪。
当在类特化外定义成员时,成员之前不能加template<>标记,但类名后应当还是带上模板实参表。例如:
void
class-name<
type-list>::
func-name{...}
特化成员而不特化类
如果只是特化成员,只需要在类外重定义该成员即可,并且定义该特化成员时,可以省略特化标志:template<>。如下面代码所示:
#include <iostream> using namespace std; template<typename T> class Bar { public: void func() { cout<<"template"<<endl; } }; template <> // "template<>" is not necessary void Bar<const char*>::func() { cout<<"specialization"<<endl; } int main() { Bar<int> b1; b1.func(); Bar<const char*> b2; b2.func(); return 0; } |
上面的程序运行结果为:
虽然在定义特化成员时可以省略template<>,但在声明特化成员时不可以省略template<>,声明形式为定义去掉函数体部分(前面一定有template<>)。
类模板的部分特化
类模板的部分特化(partial specialization)本身也是模板。部分特化的定义看来像模板定义,这种定义以关键字template开头,接着是由尖括号(<>)括住的模板形参表。部分特化的模板形参表是对应的类模板定义形参表的子集,只列出未知模板实参的那些形参。然后是部分特化的类名(与对应模板类的类名相同),类名后面必须接着模板实参列表,对于部分特化版本形参表中的类型直接使用形参类型占位即可,其它为特化类型。例如:
template <typename T1, typename T2>
class Bar { ... };
// partial specialization: fixes T2 as int and allows T2 to vary
template <typename T1>
class Bar<T1, int> {...};
与类的特化一样,部分特化的定义与通用模板的定义完全不会冲突。部分特化可以具有与通用类模板完全不同的成员集合。类模板成员的通用定义永远不会用来实例化类模板部分特化的成员。
重载与函数模板
函数模板可以重载:可以定义有相同名字但形参数目或类型不同的多个函数模板,也可以定义与函数模板有相同名字的普通非模板函数。
如果重载函数中既有普通函数又有函数模板,确定函数调用的步骤如下:
1)为这个函数名建立候选函数集合集合,包括:
> 与被调用函数名字相同的任意普通函数;
> 任意函数模板实例化,在其中,模板实参推断发现了与调用中所用函数实参相匹配的模板实参。
2)确定哪些普通函数是可行的;候选集中的每个模板实例都是可行的,因为模板实参推断保证函数可以被调用。
3)如果需要转换来进行调用,根据转换的种类排列可行函数,记住,调用模板函数的实例所允许的转换是有限的:
> 如果只有一个函数可选,就调用这个函数;
> 如果调用有二义性,从可行函数集合中去掉所有函数模板实例。
4)重新排列去掉函数模板实例的可行函数:
> 如果只有一个函数可选,就调用这个函数;
> 否则,调用有二义性。
设计既包含函数模板又包含非模板函数的重载函数集合是困难的,因为可能会使函数的用户感到奇怪。定义函数模板特化几乎总是比使用非模板版本更好。
如果文中有错误或遗漏之处,敬请指出,谢谢!
参考文献:
[1] C++ Primer(Edition 4)
[2] Thinking in C++(Volume Two, Edition 2)
[3] International Standard:ISO/IEC 14882:1998