面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况。不同之处在于:OOP能处理类型在程序运行之前都未知的情况;而在泛型编程中,在编译时就能获知类型了。模板是C++中泛型编程的基础。一个模板就是一个创建类或函数的蓝图或者说公式。
一、定义模板
1 函数模板
我们可以定义一个通用的函数模板(function template),而不是为每个类型都定义一个新函数。一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。
C++ template <typename T> int compare(const T &v1, const T &v2) { if(v1 < v2) return -1; if(v2 < v1) return 1; return 0; } |
模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。
模板参数表示在类或函数的定义中用到的类型或值。当使用模板时,我们(隐式地或显式地)指定模板实参(template argument),将其绑定到模板参数上。
实例化函数模板
当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。编译器用推断出的模板参数来为我们实例化(instantiate)一个特定版本的函数。
C++ // 实例化出int compare(const int&, const int&) cout << compare(1, 0) << endl; // T为int // 实例化出int compare(const vector<int>&, const vector<int>&) vector<int> vec1{1, 2, 3}, vec2{4, 5, 6}; cout << compare(vec1, vec2) << endl; // T为vector<int> |
这些编译器生成的版本通常称为模板的实例(instantiation)。
模板类型参数
类型参数(type parameter)可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。类型参数前必须使用关键字class或typename。
C++ template <typename T> T foo(T* p) { T tmp = *p; // tmp的类型将是指针p指向的类型 //... return tmp; } // 错误:U之前必须加上class或typename template <typename T, U> calc(const T&, const U&); // 正确:在模板参数列表中,typename和class没有什么不同 template <typename T, class U> calc(const T&, const U&); |
编写类型无关的代码
我们最初的compare函数虽然简单,但它说明了编写泛型代码的两个重要原则:
模板中的函数参数是const的引用。
函数体中的条件判断仅使用<比较运算。
通过将参数设定为const的引用,保证了这些类型可以用我们的compare函数来处理。而且,如果compare用于处理大对象,这种设计策略还能使函数运行得更快。模板程序应该尽量减少对实参类型的要求。
模板编译
当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。
为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。
大多数编译错误在实例化期间报告
模板直到实例化时才会生成代码,这一特性影响了我们何时才会获知模板内代码的编译错误。通常,编译器会在三个阶段报告错误。
第一个阶段是编译模板本身时。编译器可以检查语法错误,例如忘记分号或者变量名拼错等。
第二个阶段是编译器遇到模板使用时。对于函数模板调用,编译器通常会检查实参数目是否正确。还能检查参数类型是否匹配。
第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告。
保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确使用,是调用者的责任。
2 类模板
类模板(class template)是用来生成类的蓝图的。
定义类模板
类似函数模板,类模板以关键字template开始,后跟模板参数列表。在类模板(及其成员)的定义中,我们将模板参数当作替身,代替使用模板时用户需要提供的类型或值:
C++ template <typename T> class Blob { public: typedef T value_type; typedef typename std::vector<T>::size_type size_type; // 构造函数 Blob(); Blob(std::initializer_list<T> il); // Blob中的元素数目 size_type size() const { return data->size(); } bool empty() const { return data->empty(); } // 添加和删除元素 void push_back(const T &t) { data->push_back(t); } // 移动版本 void push_back(T &&t) { data->push_back(std::move(t)); } void pop_back(); // 元素访问 T& back(); T& operator[](size_type i); private: std::shared_ptr<std::vector<T>> data; // 若data[i]无效,则抛出msg void check(size_type i, const std::string &msg) const; }; |
实例化类模板
与函数模板的不同之处是,编译器不能为类模板推断模板参数类型。为了使用类模板,我们必须在模板名后的尖括号中提供显式模板实参(explicit template argument)列表——用来代替模板参数的模板实参列表。
C++ Blob<int> ia; Blob<string> names; Blob<double> prices; |
一个类模板的每个实例都形成一个独立的类。类型Blob<string>与任何其他Blob类型都没有关联,也不会对任何其他Blob类型的成员有特殊访问权限。
类模板的成员函数
定义在类模板之外的成员函数就必须以关键字template开始,后接类模板参数列表。
C++ // 对于StrBlob的一个给定的成员函数 ret-type StrBlob::member-name(parm-list) // 对应的Blob的成员应该是这样的: template <typename T> ret-type StrBlob<T>::member-name(parm-list) // 默认构造函数 template <typename T> Blob<T>::Blob(): data(std::make_shared<std::vector<T>>()) { } // 接受一个initializer_list参数的构造函数 template <typename T> Blob<T>::Blob(std::initializer_list<T> il): data(std::make_shared<std::vector<T>>(il)) { } // check函数 template <typename T> T& Blob<T>::check(size_type i, const std::string &msg) const { if(i >= data->size()) throw std::out_of_range(msg); } |
如果一个成员函数没有被使用,则它不会被实例化。成员函数只有在被用到时才进行实例化,这一特性使得即使某些类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。
在类模板自己的作用域中,我们可以直接使用模板名而不提供实参。在类模板外定义其成员时,我们并不在类的作用域中,直到遇到类名才表示进入类的作用域。
C++ // 由于返回类型位于类的作用域之外,必须提供模板实参 template <typename T> BlobPtr<T> BlobPtr<T>::operator++(int) { BlobPtr ret = *this; // 进入类的作用域,无需重复模板实参 ++*this; return ret; } |
类模板与友元
一对一友好关系
C++ // 前置声明,在Blob中声明友元所需要的 template <typename> class BlobPtr; template <typename> class Blob; template <typename T> bool operator==(const Blob<T>&, const Blob<T>&); template <typename T> class Blob { // 每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符 friend class BlobPtr<T>; friend bool operator==<T>(const Blob<T>&, const Blob<T>&); }; Blob<char> ca; // BlobPtr<char>和operator==<char>都是本对象的友元 Blob<int> ia; // BlobPtr<int>和operator==<int>都是本对象的友元 |
通用和特定的模板友好关系
C++ // 前置声明,在将模板的一个特定实例声明为友元时要用到 template <typename T> class Pal; class C { // C是一个普通的非模板类 friend class Pal<C>; // 用类C实例化的Pal是C的一个友元 // Pal2的所有实例都是C的友元;这种情况无须前置声明 template <typename T> friend class Pal2; }; template <typename T> class C2 { // C2本身是一个类模板 // C2的每个实例将相同实例化的Pal声明为友元 friend class Pal<T>; // Pal的模板声明必须在作用域之内 // Pal2的所有实例都是C2的每个实例的友元,不需要前置声明 template <typename X> friend class Pal2; // Pal3是一个非模板类,它是C2所有实例的友元 friend class Pal3; // 不需要Pal3的前置声明 }; |
令模板自己的类型参数成为友元
C++ template <typename Type> class Bar { friend Type; // 将访问权限授予用来实例化Bar的类型 //... }; |
模板类型别名
类模板的一个实例定义了一个类类型,与任何其他类类型一样,我们可以定义一个typedef来引用实例化的类:
C++ typedef Blob<string> StrBlob; |
由于模板不是一个类型,我们不能定义一个typedef引用一个模板。即,无法定义一个typedef引用Blob<T>。但是,新标准允许我们为类模板定义一个类型别名:
C++ template<typename T> using twin = pair<T, T>; twin<string> authors; // authors是一个pair<string, string> |
当我们定义一个模板类型别名时,可以固定一个或多个模板参数:
C++ template <typename T> using partNo = pair<T, unsigned>; partNo<string> books; // books是一个pair<string, unsigned> |
3 模板参数
我们通常将类型参数命名为T,但实际上我们可以使用任何名字。
模板参数与作用域
一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。与任何其他名字一样,模板参数会隐藏外层作用域中声明的相同名字。但是,与大多数其他上下文不同,在模板内不能重用模板参数名:
C++ typedef double A; template <typename A, typename B> void f(A a, B b) { A tmp = a; // tmp的类型为模板参数A的类型,而非double double B; // 错误:重声明模板参数B } |
模板声明
模板声明必须包含模板参数。与函数参数相同,声明中的模板参数的名字不必与定义中相同:
C++ // 3个calc都指向相同的函数模板 template <typename T> T calc(const T&, const T&); // 声明 template <typename U> U calc(const U&, const U&); // 声明 // 模板的定义 template <typename Type> Type calc(const Type& a, const Type& b) { /* ... */ } |
默认模板实参
就像我们能为函数参数提供默认实参一样,我们也可以提供默认模板实参(default template argument)。
C++ // compare有一个默认模板实参less<T>和一个默认函数实参F() template <typename T, typename F = less<T>> int compare(const T &v1, const T &v2, F f = F()) { if(f(v1, v2)) return -1; if(f(v2, v1)) return 1; retrun 0; } |
在这段代码中,我们为模板添加的第二个类型参数,名为F,表示可调用对象的类型;并定义了一个新的函数参数f,绑定到一个可调用对象上。我们为此模板参数提供了默认实参,并为其对应的函数参数也提供了默认实参。默认模板实参指出compare将使用标准库的less函数对象类,它是使用与compare一样的类型参数实例化的。默认函数实参指出f将是类型F的一个默认初始化的对象。当用户调用这个版本的compare时,可以提供自己的比较操作,但这并不是必需的:
C++ bool i = compare(0, 42); // 使用less;i为-1 // 结果依赖于item1和item2中的isbn Sales_data item1(cin), item2(cin); bool j = compare(item1, item2, compareIsbn); |
无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。
C++ template <class T = int> class Numbers { // T默认为int public: Numbers(T v = 0): val(v) { } private: T val; }; Numbers<long double> lots_of_precision; Numbers<> average_precision; // 空<>表示我们希望使用默认类型 |
4 成员模板
一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板(member template)。成员模板不能是虚函数。
C++ // 函数对象类,对给定指针执行delete class DebugDelete { public: DebugDelete(std::ostream &s = std::cerr): os(s) { } // 与任何函数模板相同,T的类型由编译器推断 template <typename T> void operator()(T *p) const { os << "deleting unique_ptr" << std::endl; delete p; } private: std::ostream &os; }; // 我们可以用这个类代替delete double* p = new double; DebugDelete d; // 可像delete表达式一样使用对象 d(p); //调用DebugDelete::operator()(double*),释放p int* ip = new int; // 在一个临时DebugDelete对象上调用operator()(int*) DebugDelete()(ip); |
5 控制实例化
当模板被使用时才会进行实例化这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。
我们可以通过显式实例化(explicit instantiation)来避免这种开销。
C++ extern template class Blob<string>; // 声明 template int compare(const int&, const int&); // 定义 |
当编译器遇到extern模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为extern就表示承诺在程序其他位置有该实例化的一个非extern声明(定义)。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。
由于编译器在使用一个模板时自动对其实例化,因此extern声明必须出现在任何使用此实例化版本的代码之前。
二、模板实参推断
对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程被称为模板实参推断(template argument deduction)。
1 类型转换与模板类型参数
与非模板函数一样,我们在一次调用中传递给函数模板的实参被用来初始化函数的形参。如果一个函数形参的类型使用了模板类型参数,那么它采用特殊的初始化规则。只有很有限的几种类型转换会自动地应用于这些实参。编译器通常不算对实参进行类型转换,而是生成一个新的模板实例。
与往常一样,顶层const无论是在形参中还是在实参中,都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项。
const转换:可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针)形参。
数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。类似的,一个函数实参可以转换为一个该函数类型的指针。
其他类型转换,如算术转换、派生类向基类的转换以及用户定义的转换,都不能应用于函数模板。
C++ template <typename T> T fobj(T, T); // 实参被拷贝 template <typename T> T fref(const T&, const T&); // 引用 string s1("a value"); const string s2("another value"); fobj(s1, s2); // 调用fobj(string, string);const被忽略 fref(s1, s2); // 调用fref(const string&, const string&) // 将s1转换为const是允许的 int a[10], b[42]; fobj(a, b); // 调用f(int*, int*) fref(a, b); // 错误:数组类型不匹配 |
注:将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。
使用相同模板参数类型的函数形参
一个模板类型参数可以用作多个函数形参的类型。由于只允许有限的几种类型转换,因此传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,则调用就是错误的。
C++ long lng; compare(lng, 1024); // 错误:不能实例化compare(long, int) |
正常类型转换应用于普通函数实参
函数模板可以有用普通类型定义的参数,即,不涉及模板类型参数的类型。这种函数实参不进行特殊处理;他们正常转换为对应形参的类型。
C++ template <typename T> ostream &print(ostream &os, const T &obj) { return os << obj; } print(cout, 42); // 实例化print(ostream&, int) ofstream f("output"); print(f, 10); // 使用print(ostream&, int);将f转换为ostream& |
注:如果函数参数类型不是模板参数,则对实参进行正常的类型转换。
2 函数模板显式实参
在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,我们希望允许用户控制模板实例化。当函数返回类型与参数列表中任何类型都不相同时,这两种情况最常出现。
指定显式模板实参
C++ // 编译器无法推断T1,它未出现在函数参数列表中 template <typename T1, typename T2, typename T3> T1 sum(T2, T3); // T1是显式指定的,T2和T3是从函数实参类型推断而来的 auto val3 = sum<long long>(i, lng); // long long sum(int, long) |
在本例中,没有任何函数实参的类型可用来推断T1的类型。每次调用sum时调用者都必须为T1提供一个显式模板实参(explicit template argument)。
显式模板实参按由左至右的顺序与对应的模板参数匹配;第一个模板实参与第一个模板参数匹配,第二个实参与第二个参数匹配,依此类推。只有尾部(最右)参数的显式模板实参才可以忽略,而且前提是它们可以从函数参数推断出来。
C++ // 糟糕的设计:用户必须指定所有三个模板参数 template <typename T1, typename T2, typename T3> T3 alternative_sum(T2, T1); // 错误:不能推断前几个模板参数 auto val3 = alternative_sum<long long>(i, lng); // 正确:显式指定了所有三个参数 auto val2 = alternative_sum<long long, int, long>(i, lng); |
正常类型转换应用于显式指定的实参
对于用普通类型定义的函数参数,允许进行正常的类型转换,出于同样的原因,对于模板类型参数已经显式指定了的函数参数,也进行正常的类型转换:
C++ long lng; compare(lng, 1024); // 错误:模板参数不匹配 compare<long>(lng, 1024); // 正确:实例化compare(long, long) compare<int>(lng, 1024); // 正确:实例化compare(int, int) |
3 尾置返回类型与类型转换
当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。但在其他情况下,要求显式指定模板实参会给用户增添额外负担,而且不会带来什么好处。例如,我们可能希望编写一个函数,接受表示序列的一对迭代器和返回序列中一个元素的引用:
C++ // 尾置返回允许我们在参数列表之后声明返回类型 template <typename It> auto fcn(It beg, It end) -> decltype(*beg) { // 处理序列 return *beg; // 返回序列中一个元素的引用 } |
此例中我们通知编译器fcn的返回类型与解引用beg参数的结果类型相同。解引用运算符返回一个左值,因此通过decltype推断的类型为beg表示的元素的类型的引用。因此,如果对一个string序列调用fcn,返回类型将是string&。如果是int序列,则返回类型是int&。
进行类型转换的标准库模板类
为了获得元素类型,我们可以使用标准库的类型转换(type transformation)模板。这些模板定义在头文件type_traits中。

C++ // 为了使用模板参数的成员,必须用typename template <typename It> auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type { // 处理序列 return *beg; // 返回序列中一个元素的拷贝 } |
4 函数指针和实参推断
当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
C++ template <typename T> int compare(const T&, const T&); // pf1指向实例 int compare(const int&, const int&) int (*pf1)(const int&, const int&) = compare; |
如果不能从函数指针类型确定模板实参,则产生错误:
C++ // func的重载版本;每个版本接受一个不同的函数指针类型 void func(int(*)(const string&, const string&)); void func(int(*)(const int&, const int&)); func(compare); // 错误:使用compare的哪个实例? |
我们可以通过使用显式模板实参来消除func调用的歧义:
C++ // 正确:显式指出实例化哪个compare版本 func(compare<int>); // 传递compare(const int&, const int&) |
注:当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。
5 模板实参推断和引用
从左值引用函数参数推断类型
当一个函数参数是模板类型参数的一个普通(左值)引用时(即,形如T&),绑定规则告诉我们,只能传递给它一个左值(如,一个变量或一个返回引用类型的表达式)。
C++ template <typename T> void f1(T&); // 实参必须是一个左值 // 对f1的调用使用实参所引用的类型作为模板参数类型 f1(i); // i是一个int;模板参数类型T是int f1(ci); // ci是一个const int;模板参数T是const int f1(5); // 错误:传递给一个&参数的实参必须是一个左值 |
如果一个函数参数的类型是const T&,正常的绑定规则告诉我们可以传递给它任何类型的实参——一个对象(const或非const)、一个临时对象或是一个字面常量值。当函数参数本身是const时,T的类型推断的结果不会是一个const类型。const已经是函数参数类型的一部分;因此,它不会也是模板参数类型的一部分:
C++ template <typename T> void f2(const T&); // 可以接受一个右值 // f2中的参数是const &;实参中的const是无关的 // 在每个调用中,f2的函数参数都是被推断为const int& f2(i); // i是一个int;模板参数T是int f2(ci); // ci是一个const int,但模板参数T是int f2(5); // 一个const &参数可以绑定到一个右值;T是int |
从右值引用函数参数推断类型
当一个函数参数是一个右值引用时,正常绑定规则告诉我们可以传递给它一个右值。当我们这样做时,类型推断过程类似普通左值引用函数参数的推断过程。推断出的T的类型是该右值实参的类型:
C++ template <typename T> void f3(T&&); f3(42); // 实参是一个int类型的右值;模板参数T是int |
引用折叠和右值引用参数
假定i是一个int对象,我们可能认为像f3(i)这样的调用说不合法的。但是,C++语言在正常绑定规则之外定义了两个例外规则,允许这种绑定。
第一个例外规则影响右值引用参数的推断如何进行。当我们将一个左值(如i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型。
第二个例外绑定规则:如果我们间接创建一个引用的引用,则这些引用形成了“折叠”:
X& &、X& &&和X&& &都折叠成类型X&
类型X&& &&折叠成X&&
注:引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。
C++ f3(i); // 实参是一个左值;模板参数T是int& f3(ci); // 实参是一个左值;模板参数T是一个const int& // 无效代码,只是用于演示目的 void f3<int&>(int& &&); // 当T是int&时,函数参数为int& && |
这两个规则导致了两个重要结果:
如果一个函数参数是一个指向模板类型参数的右值引用(如,T&&),则它可以被绑定到一个左值;且
如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&)
另外值得注意的是,这两个规则暗示,我们可以将任意类型的实参传递给T&&类型的函数参数。
编写接受右值引用参数的模板函数
模板参数可以推断为一个引用类型,这一特性对模板内的代码可能有令人惊讶的影响:
C++ template <typename T> void f3(T&& val) { T t = val; // 拷贝还是绑定一个引用? t = fcn(t); // 赋值只改变t还是既改变t又改变val? if(val == t) { /* ... */ } // 若T是引用类型,则一直为true } |
当我们对一个右值调用f3时,例如字面常量42,T为int。在此情况下,局部变量t的类型为int,且通过拷贝参数val的值被初始化。当我们对t赋值时,参数val保持不变。
另一方面,当我们对一个左值i调用f3时,则T为int&。当我们定义并初始化局部变量t时,赋予它类型int&。因此,对t的初始化将其绑定到val。当我们对t赋值时,也同时改变了val的值。在f3的这个实例化版本中,if判断永远得到true。