跳转至

第十六章 模板与泛型编程

模板是C++泛型编程的基础。

模板就是函数或者类的公式,当使用模板时,编译器会把模板转换成特定的类或者函数。


定义模板

函数模板

一个函数模板(function template)就是一个公式,用来生成针对特定类型的函数版本。

一个函数模板可能像下面这样:

template<typename T>
int template_compare(const T &t1, const T &t2)
{
    if (t1 < t2) return -1;

    if (t1 > t2) return 1;

    return 0;
}

template是关键字,之后尖括号内容叫模板参数列表(template parameter list)

Note

在模板定义中,模板参数列表不能为空。

当使用模板时,我们(隐式或显式地)指定模板实参(template argument),将其绑定到模板参数上。

compare函数声明了一个名为T的类型参数。在compare中,我们用名字T表示一个类型。而T表示的实际类型则在编译时根据compare的使用情况来确定。

实例化函数模板

当调用一个函数模板时,编译器会用函数实参推断出模板实参。即,当我们调用compare时,编译器使用实参的类型来确定绑定到模板参数T的类型。例如:

cout << compare(1, 0) << endl;  // T为int

函数实参类型是int。编译器推断出模板实参为int,并将它绑定到模板参数T。

编译器用推断出的模板参数实例化(instantiate)一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板参数来创建出模板的一个新“实例”。

模板类型参数与非模板参数

一般来说,我们可以将类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。

类型参数前必须使用关键字class或typename。在模板参数列表中,他们的含义没什么不同。但看起来typename更加直观。

除了定义类型参数,还可以在模板中定义非类型参数(nontype parameter)。一个非类型参数表示一个值而非一个类型。我们通过一个特定的类型名来指定非类型参数。

当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化模板。

inline和constexpr的函数模板

可以把函数模板声明成inline或constexpr的,inline或constexpr说明符放在模板参数列表之后,返回类型之前:

template<typename T> inline T min(const T&, const T&);

模板编译

编译器遇到模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。当我们使用(而不是定义)模板时,编译器才生成代码。

这一个特性影响到了我们如何组织代码以及错误何时被检测到。

通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。

模板则不同:为了生成一个实例化版本,编译器通常需要掌握函数模板或类模板成员函数的定义。因此,模板的头文件通常既包括声明也包括定义。

Warning

保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。

类模板

类模板(class template)是用来生成类的蓝图的。与函数模板不同之处是,编译器不能为类模板推断模版参数类型。为了使用类模板,我们必须在模板名后的尖括号中提供额外信息——用来代替模板参数的模板实参列表。

类似函数模板,类模板以关键字template开始,后跟模板参数列表。在类模板的定义中,我们将模版参数当作替身,代替使用模板时用户需要提供的类型或值。

实例化类模板

当使用一个类模板时,我们必须提供额外信息。这些额外信息是显式模板实参(explicit template argument)列表,它们被绑定到模版参数。编译器使用这些模板实参来实例化出特定的类。

Note

一个类模板的每个实例都形成一个独立的类。类型Blob<string>与任何其他Blob类型都没有关联。

类模板的成员函数

我们既可以在类模板的内部,也可以在外部定义成员函数,且定义在类模板内的成员函数被隐式声明为内联函数。

类模板的成员函数本身是一个普通函数。但是,类模板的每个实例都有其自己版本的成员函数。因此,类模板的成员函数具有和类模板相同的模板参数。

例如对于Blob的成员应该是这样的:

template<typename T>
ret-type Blob<T>::member_name(parm-list)

模板与友元

当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关的。

  • 如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。

  • 如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。

一对一友好关系

类模板与另一个模板间友好关系的最常见的形式是建立对应实例及其友元间的友好关系。

为了引用(类或函数)模板的一个特定实例,我们必须首先声明模板自身。一个模板声明包括模板参数列表:

// 前置声明,在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>&);
};

Note

注意上述模板参数列表只有一个typename,这样的前置声明是合法的。

通用和特定的模板友好关系

// 前置声明,在将模板的一个特定实例声明为友元时要用到
template<typename T> class Pal;

class C {
    friend class Pal<C>;                    // 用类C实例化的Pal是C的一个友元
    template<typename T> friend class Pal2; // Pal2的所有实例都是C的友元,这种情况无须前置声明
};

模板类型别名

我们可以定义一个typedef来引用实例化的类:

typedef Blob<string> StrBlob;

类模板的static成员

类模板可以声明static成员:

template<typename T> class Foo {
public:
    static std::size_t count() { return ctr; }
private:
    static std::size_t ctr;
};

template<typename T>
size_t Foo<T>::ctr = 0; // 定义并初始化ctr

每个Foo实例都有其自己的static成员实例。

一个static成员函数只有在使用时才会实例化。

模板参数

类似函数参数的名字,一个模板参数的名字没有什么内在的含义。我们可以使用任何名字:

template <typename Foo> Foo calc(const Foo& a, const Foo& b) {}

一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。模板参数会隐藏外层作用域中声明的相同名字。而且,在模版内不能重用模版参数名。

typedef double A;
template <typename A, typename B> void f(A a, B b)
{
    A tmp = a;  // tmp的类型为模版参数A的类型,而非double
    double B;   // 错误:重声明模板参数B 
}

模板声明

模板声明必须包含模板参数:

// 声明但不定义compare和Blob
template <typename T> int compare(const T&, const T&);
template <typename T> class Blob;

使用类的类型成员

当编译器遇到类似T::mem这样的代码时,它不会知道mem是一个类型成员还是一个static数据成员,直到实例化时才会知道。但是,为了处理模板,编译器必须知道名字是否表示一个类型。

默认情况下,C++语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字typename来实现这一点:

template <typename T>
typename T::value top(const T &c) { /* ... */ }

默认模板实参

就像我们能为函数参数提供默认实参一样,我们也可以提供默认模板实参(default template argument)。

例如,可以重写compare,默认使用标准库的less函数对象模板:

// compare有一个默认模板实参,和一个默认函数实参
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;
    return 0;
}

与函数默认实参一样,对于一个模板参数,只有当它右侧的所有参数都有默认实参时,它才可以有默认实参。

模板默认实参与类模板

无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。尖括号指出类必须从一个模板实例化而来。

如果一个类模板为其所有模版参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名之后跟一个尖括号对:

template <class T = int>
class Numbers {
public:
    Numbers(T v = 0) : val(v) {}
private:
    T val;
};

Numbers<long long> ll_n;
Numbers<> int_n;    // 空<>表示我们希望使用默认类型

成员模板

一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板(member template)。成员模板不能是虚函数。

更多内容详见p596。

控制实例化

当模板被使用时才会进行实例化,这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就会有该模板的一个实例。

在大系统中,在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销。

一个显式实例化有如下形式:

extern template declaration;    // 实例化声明
template declaration;           // 实例化定义

declaration是一个类或函数声明,例如:

extern tempalte class Blob<string>; // 声明而不实例化

template class Blob<string>;        // 实例化类模板的所有成员

当编译器遇到extern模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为extern就表示承诺在程序其他位置有该实例化的一个非extern声明(定义)。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。

Warning

对每个实例化声明,在程序中某个位置必须有其显式的实例化定义。

显式的实例化定义会实例化所有成员。

关于效率与灵活性的详细讨论见p599,书中拿shared_ptr和unique_ptr举例说明,对于shared_ptr,它的删除器需要间接保存(比如为一个指针);而对于unique_ptr,它的删除器是类类型的一部分,在创建时就必须指定好,可以作为unique_ptr的成员。

  • 效率:unique_ptr在编译时绑定了删除器,避免了间接调用删除器的运行时开销。

  • 灵活性:shared_ptr在运行时绑定删除器,使用户重载删除器更为方便。

模板实参推断

对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程被称为模板实参推断(template argument deduction)。在模板实参推断过程中,编译器使用函数调用中的实参类型来寻找模板实参,用这些模板实参生成的函数版本与给定的函数调用最为匹配。

类型转换与模板类型参数

我们在一次调用中传递给函数模板的实参被用来初始化函数的形参。

与往常一样,顶层const无论是在形参中还是在实参中,都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项:

  • const转换:可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针)形参。

  • 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。一个函数实参可以转换为一个该函数类型的指针。

其他类型转换都不能应用于函数模板。编译器通常不是对实参进行类型转换,而是生成一个新的模板实例。

使用相同模板参数类型的函数形参

一个模板类型参数可以用作多个函数形参的类型,这时候传递给这些形参的实参必须具有相同的类型。

long lng;
compare(lng, 1024); // 错误,不能实例化compare(long, int)

正常类型转换应用于普通函数实参

函数模板可以有用普通类型定义的参数,这种函数实参不进行特殊处理:它们正常转换为对应形参的类型。

// 传递给os的实参会进行正常的类型转换。
template <typename T> ostream &print(ostream &os, const T &obj)
{
    return os << obj;
}

函数模板显式实参

在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,我们希望用户控制模板实例化。当函数返回类型与参数列表中任何类型都不同时,这两种情况最常出现。

指定显式模板实参

有这样的例子:

// 编译器无法推断T1,它未出现在函数参数列表中
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);

没有任何函数实参的类型可用来推断T1的类型,每次调用sum时调用者都必须为T1提供一个显式模板实参(explicit template argument)。

显示模板实参在尖括号中给出,位于函数名之后,实参列表之前:

// T1是显式指定的,T2和T3是从函数实参类型推断而来的
auto val3 = sum<long long>(i, lng); // long long sum(int, long)

显式模板实参按由左至右的顺序与对应的模板参数匹配;第一个模板实参与第一个模板参数匹配,第二个实参与第二个参数匹配,以此类推。

正常类型转换应用于显式指定的实参

对于模板类型参数已经显式指定了的函数实参,也进行正常的类型转换:

long lng;
compare(lng, 1024);         // 错误,模板参数不匹配
compare<long>(lng, 1024);   // 正确,实例化compare(long, long)

尾置返回类型与类型转换

在一些情况下,要求显式指定模板实参会给用户增添额外负担,而且不会带来什么好处。例如:

template <typename It>
??? &fcn(It beg, It end)
{
    // 处理序列
    return *beg;    // 返回序列中一个元素的引用
}

我们知道,返回类型就是decltype(*beg)。但是,在编译器遇到函数的参数列表之前,beg都是不存在的,这时候必须使用尾置返回类型:

// 尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
    // 处理序列
    return *beg;    // 返回序列中一个元素的引用
}

进行类型转换的标准库模板类

有时我们无法直接获得所需要的类型,比如,让上面的fcn函数返回一个元素的值而非引用:所有迭代器操作都不会生成元素(的拷贝),只能生成元素的引用。

为了获得元素类型,我们可以使用标准库的类型转换(type transformation)模板。这些模板定义在头文件type_traits中。这个头文件中的类通常用于所谓的模板元程序设计,这一主题已经超出本书的范围。但,类型转换模板在普通编程中也很有用。

书本p606列出了所有类型转换模板。

可以使用remove_reference来获得元素的类型:

// 为了使用模板参数的成员,必须用typename
template <typename It>
auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type
{
    // 处理序列
    return *beg;    // 返回序列中一个元素的拷贝
}

函数指针和实参推断

当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。

例如:

template <typename T> int compare(const T&, const T&);
// pf1指向实例int compare(const int&, const int&);
int (*pf1)(const int&, const int&) = compare;

如果不能从函数指针类型确定模板实参,则产生错误。

模板实参推断和引用

有例子:

template <typename T> void f(T &p);

非常重要的有两点:

  • 编译器会应用正常的引用绑定规则;

  • const是底层的,不是顶层的。

从左值引用函数参数推断类型

当一个函数参数是模板类型参数的一个普通(左值)引用时,只能传递给它一个左值(一个变量,或者一个返回引用的表达式)。实参可以是const类型,也可以不是。如果实参是const的,则T将被推断为const类型。

template <typename T> void f1(T &p);        // 实参必须是一个左值

// 对f1的调用使用实参所引用的类型作为模板参数类型
f1(i);      // i是一个int,模板参数类型T是int
f1(ci);     // ci是一个const int,模板参数T是const int
f1(5);      // 错误,传递给一个&参数的实参必须是一个左值

如果一个函数参数的类型是const T&,可以传递给它任何类型的实参。当函数参数本身是const时,T的类型推断的结果不会是一个const类型。const已经是函数参数类型的一部分。

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&&),我们可以传递给它一个右值,当我们传递一个右值时,类型推断过程类似普通的推断过程。

template <typename T> void f3(T&&);
f3(42);     // 实参是一个int类型的右值,版本参数T是int

引用折叠和右值引用参数

通常我们不能将一个右值引用绑定到一个左值上。但是,C++语言定义了两个例外:

  • 一种是move这种标准库设施

  • 另一种是,当我们将一个左值(如i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(T&&)时,编译器推断模板类型参数为实参左值引用类型。因此,假如i是一个int对象,调用f3(i)时,编译器推断T的类型为int&,而非int。

这样,就间接创建了一个引用的引用,形成了“折叠”:

  • x& &, x& &&, x&& &折叠成x&

  • x&& &&折叠成x&&

如果将引用折叠规则和右值引用的特殊类型推断规则组合在一起,则意味着对一个左值调用f3,编译器推断T为一个左值引用类型,从而函数参数间接实例化成一个引用的引用,又通过折叠,变成一个左值引用:

f3(i);      // 实参是一个左值,模版参数T是int&

// 实例化代码,无效的代码,用于演示
void f3<int&>(int& &&); // 当T是int&时,函数参数为int& &&

// 实例化代码,最终结果
void f3<int&>(int&);    // 当T是int&时,函数参数折叠为int&

Note

如果一个函数参数是指向模版参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)。

如果不想让一个左值传递给一个参数是右值引用的函数,就需要重载函数模板:

template <typename T> void f(T&&);      // 绑定到非const右值
template <typename T> void f(const T&); // 绑定到左值和const右值

理解std::move

标准库move函数是使用右值引用的模板的一个很好的例子。虽然不能直接将一个右值引用绑定到一个左值上,但可以用move获得一个绑定到左值上的右值引用。

std::move是如何定义的

// 在返回类型和类型转换中也要用到typename
template <typename T>
typename remove_reference<T>::type&& move(T &&t)
{
    return static_cast<typename remove_reference<T>::type&&>(t);
}

move的函数参数T&&是一个指向模板类型参数的右值引用。通过引用折叠,此参数可以与任何类型的实参匹配。

string s("hi!"), s2;
s2 = std::move(string("bye!"));     // 正确:从一个右值移动数据
s2 = std::move(s1);                 // 正确:但在赋值之后,s1的值是不确定的

从一个左值static_cast到一个右值引用是允许的

虽然不能隐式地将一个左值转换为右值引用,但我们可以用static_cast显式地将一个左值转换为一个右值引用。

对于操作右值引用的代码来说,将一个右值引用绑定到一个左值的特性允许它们截断左值。

转发

某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。

书本上给出一个翻转函数的例子:

// 接受一个可调用对象和另外两个参数的模板
// 对“翻转”的参数调用给定的可调用对象
// flip1是一个不完整的实现:顶层const和引用丢失了
template <typename F, typename T1, typename T2>
void flip1(F f, T1 t1, T2 t2)
{
    f(t2, t1);
}

如果f定义成这样,就会出现问题:

void f(int a, int &b)
{
    cout << a << " " << ++b << endl;
}

如果我们通过flip1调用f,f所做的改变就不会影响实参:

f(42, i);           // f改变了实参i
flip1(f, j, 42);    // 通过flip1调用f不会改变j

my note: 这是因为有这样的传递路径: j -> flip1的形参t1(int) -> f的形参b(int&),因此,f只是改变了flip1的形参,flip1的形参不会改变它的实参j。

定义能保持类型信息的函数参数

通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论左值还是右值)使得我们可以保持const属性,因为在引用类型中的const是底层的。

template <typename F, typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2)
{
    f(t2, t1);
}

Note

如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的const属性和左值/右值属性将得到保持。

这个版本的flip2虽然能接受一个左值引用了,但却不能接受右值引用参数的函数。例如:

void g(int &&i, int &j) {}

即使给flip2传递一个右值:

flip2(g, i, 42);    // 错误:不能从一个左值实例化int&&

函数参数与其他任何变量一样,都是左值表达式。因此,flip2中对g的调用将传递给g的右值引用参数一个左值。

my note: 虽然由42推断出来的参数类型是int&&,但它作为一个函数参数传递给g,则它就是一个左值。见p471(变量是左值)。

在调用中使用std::forward保持类型信息

我们可以使用一个名为forward的新标准库设施来传递flip2的参数,它能保持原始实参的类型。forward定义在头文件utility中。forward必须通过显式模板实参来调用。forward返回该显式实参类型的右值引用。即,forward的返回类型是T&&。

通过返回类型上的引用折叠,forward可以保持给定实参是左值/右值属性:

template <typename Type> intermediary(Type &&arg)
{
    finalFcn(std::forward<Type>(arg));
}
  • 如果实参是一个右值,则Type是一个普通类型,forward返回Type&&。

  • 如果实参是一个左值,则通过引用折叠,Type本身是一个左值引用类型。在此情况下,返回类型是一个指向左值引用类型的右值引用。再次对forward的返回类型进行引用折叠,将返回一个左值引用类型。

Note

当用一个指向模版参数类型的右值引用函数参数时(T&&),forward会保持实参类型的所有细节。

使用forward,我们可以重写翻转函数:

template <typename F, typename T1, typename T2>
void flip(F f, T1 &&t1, T2 &&t2)
{
    f(std::forward<T2>(t2), std::forward<T1>(t1));
}

重载与模板

函数模板可以被另一个模板或一个普通非模板函数重载。并在以下几方面影响函数匹配规则:

  • 对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。

  • 候选函数总是可行的,因为模板实参推断会排除任何不可行的模板。

  • 可行函数按类型转换来排序,当然,可以用于函数模板调用的类型转换是非常有限的。

  • 如果一个函数有最好的匹配,则选择此函数,如果有多个函数提供同样好的匹配,则:

    • 如果其中只有一个是非模板函数,则选择此函数。

    • 如果只有多个函数模板,且其中一个模板最特例化,则选择此模板。

    • 否则,此调用有歧义。

详细讨论见p615。

可变参数模板

一个可变参数模板(variadic template)就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包(parameter packet)。存在两种参数包:

  • 模板参数包,表示零个或多个模板参数;

  • 函数参数包,表示零个或多个函数参数。

我们用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中,class...或typename...指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。

// Args是一个模板参数包;rest是一个函数参数包
// Args表示零个或多个模板类型参数
// rest表示零个或多个函数参数
template <typename T, typename... Args>
void foo(const T &t, const Args& ... rest);

与往常一样,编译器从函数的实参推断模板参数类型,对于一个可变参数模板,编译器还会推断包中参数的数目。

foo(i, s, 42, d);       // 包中有三个参数
foo("hi");              // 空包

编译器将实例化出这样的foo版本:

void foo(const int&, const string&, const int&, const double&);
void foo(const char(&)[3]);

sizeof运算符

如果需要知道包中有多少个元素时,可以使用sizeof...运算符,类似sizeof,sizeof...返回一个常量表达式,而且不会对其实参求值。

template <typename ... Args> void g(Args ... args)
{
    cout << sizeof...(Args) << endl;    // 类型参数的数目
    cout << sizeof...(args) << endl;    // 函数参数的数目
}

编写可变参数函数模板

我们可以使用一个initializer_list来定义一个可接受可变数目实参的函数。但是,所有实参必须具有相容的类型。当我们既不知道想要处理的实参的数目也不知道它们的类型时,可变参数函数是很有用的。

这里给出一个案例print函数来说明如何编写一个可变参数函数。

可变参数函数通常是递归的。第一步调用处理包中的第一个实参,然后用剩余实参调用自身。为了终止递归,我们还需要定义一个非可变参数的版本。

// 用来终止递归并打印最后一个元素的函数
// 此函数必须在可变参数版本的print定义之前声明
template <typename T>
ostream &print(ostream &os, const T &t)
{
    return os << t;
}

// 包中除了最后一个元素之外的其他元素都会调用这个版本的print
template <typename T, typename... Args>
ostream &print(ostream &os, const T &t, const Args&... rest)
{
    os << t << ", ";
    return print(os, rest...); // 递归调用,打印其他实参
}

Warning

当定义可变参数版本的print时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。

包扩展

对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展(expand)它。当扩展一个包时,我们还要提供用于每个扩展元素的模式(pattern)。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。我们通过在模式右边放一个省略号(...)来触发扩展操作。

template <typename T, typename... Args>
ostream &
print(ostream &os, const T &t, const Args&... rest) // 扩展Args
{
    os << t << ", ";
    return print(os, rest...);                      // 扩展rest
}

第一个扩展操作扩展模板参数包,为print生成函数参数列表。第二个扩展操作出现在对print的调用中,此模式为print调用生成实参列表。

对Args的扩展中,模式是const Args&,编译器将它应用到模板参数包Args中的每个元素。

第二个扩展发生在对print的调用中,模式是函数参数包的名字(rest)。

关于包扩展和其理解的更多详细的讨论见p621。

模板特例化

在某些情况下,通用模板的定义对特定类型是不合适的:通用定义可能编译失败或做的不正确。当我们不能(或不希望)使用模板版本时,可以定义类或函数模板的一个特例化版本。

// 可以比较任意两个类型
template <typename T> int compare(const T&, const T&);

上面的函数模板可能无法很好的处理字符指针,因此可以为它定义一个模板特例化(template specialization)版本。一个特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。

定义函数模板特例化

当我们特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参。为了指出我们正在实例化一个模板,应使用关键字template后跟一个空尖括号对。

// compare的特殊版本,处理字符数组的指针
template <>
int compare(const char* const &p1, const char* const &p2)
{
    return strcmp(p1, p2);
}

当我们定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配。

Note

特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。

我们还可以特例化类模板,这一部分的讨论见p626。