模板名本身并不是一个类型,不能用于声明变量或调用函数。要创建一个具体的类型或函数,必须对模板进行实例化。大多数情况下,模板在使用时会隐式地实例化。我们仍然从函数模板开始说起。
要使用函数模板生成一个具体函数,必须指定所有模板类型参数应使用的具体类型。可以直接指定类型:
template <typename T> T half(T x) { return x/2; }
int i = half<int>(5);
这将使用 int 类型来实例化 half 函数模板。类型是显式指定的;可以传入其他类型的参数调用该函数,只要该参数能够转换为所指定的类型即可:
double x = half<double>(5);
尽管参数是 int 类型,但此处实例化的是 half<double>,返回类型也是 double。整数值 5 隐式转换为 double 类型。
尽管可以通过指定所有类型参数来实例化每个函数模板,但这种情况很少发生。大多数函数模板的使用都涉及类型的自动推导。考虑以下情况:
auto x = half(8); // int
auto y = half(1.5); // double
模板类型只能从模板函数的参数中推导出来 —— 编译器会尝试为模板参数 T 选择一个类型,使其与以该类型声明的函数参数的类型相匹配。例子中,函数模板有一个类型为 T 的参数 x。对该函数的每次调用都必须为此参数提供一个值,而该值必然具有某种类型。编译器将推导出 T 必须是该类型。在前面代码块的第一个调用中,参数是 5,其类型为 int。此时最合理的选择就是将此次模板实例化中的 T 设为 int。同样地,在第二个调用中,可以推导出 T 必须是 double。
完成类型推导后,编译器会进行类型替换:所有其他对 T 类型的引用都会替换为推导出的类型。例子中,只有一个其他位置使用了 T,即返回类型。
模板参数推导广泛用于捕获那些难以直接确定的类型:
long x = ...;
unsigned int y = ...;
auto x = half(y + z);
这里,将类型 T 推导为表达式 y + z 的结果类型(在这个例子中是 long),但通过模板类型推导,无需显式指定,即使将来修改了 y 和 z 的类型,推导出的类型也会随之自动更新。
考虑以下示例:
template <typename U> auto f(U);
half(f(5));
将 T 推导为 f() 模板函数在传入 int 类型参数时的返回类型(当然,f() 模板函数的定义必须在调用前提供,但无需深入查找 f() 所在的头文件,因为编译器会推导出正确的类型)。
只有用于声明函数参数的类型才能推导。虽然并没有规则要求,所有模板类型参数都必须出现在函数参数列表中,但对于那些无法通过参数推导出的模板参数,必须显式指定:
template <typename U, typename V> U half(V x) {
return x/2;
}
auto y = half<double>(8);
第一个模板类型参数显式指定,因此 U 是 double,而 V 从参数中推导为 int。
有时,即使模板类型参数用于声明函数参数,编译器也无法对其进行推导:
template <typename T> T Max(T x, T y) {
return (x > y) ? x : y;
}
auto x = Max(7L, 11); // 错误
从第一个参数可以推导出 T 必须是 long,但从第二个参数推导出 T 必须是 int。对于刚开始学习模板的开发者来说,常常会感到惊讶:此时并没有推导出 long 类型 —— 毕竟,如果把 T 替换为 long,第二个参数可以通过隐式转换匹配,函数也能顺利编译。那么,为什么没有选择更大的类型呢?
原因在于:编译器不会尝试寻找一个能让所有参数都完成合法转换的类型。毕竟,通常会存在多个这样的类型。在我们的例子中,T 也可以是 double 或 unsigned long,函数依然有效。如果某个模板参数可以从多个参数中进行推导,那么所有这些推导的结果必须完全一致。
否则,该模板实例化将由歧义,导致编译错误。
类型推导并不总是简单地将参数的类型直接用于模板参数,参数的声明类型可能比模板参数本身更复杂:
template <typename T> T decrement(T* p) {
return --(*p);
}
int i = 7;
decrement(&i); // i == 6
参数的类型是指向 int 的指针,但为 T 推导出的类型是 int。只要推导无歧义,类型推导可以任意:
template <typename T> T first(const std::vector<T>& v) {
return v[0];
}
std::vector<int> v{11, 25, 67};
first(v); // T 是 int, 返回 11
参数是另一个模板 std::vector 的一个实例化,必须从用于创建该 vector 实例化的类型中推导出模板参数类型。
如果一个模板类型参数能从多个函数参数中进行推导,则所有这些推导的结果必须一致。否则,将产生歧义并导致编译错误。另一方面,单个函数参数可以用于推导多个不同的模板类型参数:
template <typename U, typename V>
std::pair<V, U> swap12(const std::pair<U, V>& x) {
return std::pair<V, U>(x.second, x.first);
}
swap12(std::make_pair(7, 4.2)); // 一组值 4.2, 7
从一个参数中推导出两个类型 U 和 V,然后使用这两个类型构成一个新类型 std::pair<V, U>。这个例子显得冗长了些,可以利用一些更现代的 C++ 特性,使其更简洁且更易于维护。首先,标准库已经提供了一个函数,能够推导参数类型并用来声明一个 pair,甚至已经使用过这个函数 —— std::make_pair()。
其次,函数的返回类型可以从 return 语句中的表达式推导出来(C++14 特性)。这种推导的规则与模板参数类型推导的规则类似。通过这些简化,示例变为如下形式:
template <typename U, typename V>
auto swap12(const std::pair<U, V>& x) {
return std::make_pair(x.second, x.first);
}
这里不再显式使用类型 U 和 V,仍然需要这个函数是模板,因为它操作的是泛型类型,即两个在实例化函数之前未知的类型组成的 pair。可以只使用一个模板参数,代表参数的类型:
template <typename T> auto swap12(const T& x) {
return std::make_pair(x.second, x.first);
}
这两种变体之间存在显著差异 —— 最后一个函数模板在使用一个参数的调用都能成功完成类型推导,无论该参数的类型是什么。如果该参数不是 std::pair,或者更一般地说,如果该参数不是类或结构体,或者没有 first 和 second 数据成员,推导仍然会成功,但后续的类型替换将失败。另一方面,之前的版本对于非某种类型 pair 的参数甚至不会考虑。对于 std::pair 参数,pair 的类型都会推导出来,并且替换应该能顺利进行。我们能否使用最后的声明,同时仍将类型 T 限制为 pair 或具有类似接口的其他类?可以,我们将在本书后面介绍几种实现此目的的方法。
成员函数模板与非成员函数模板非常相似,其参数的推导方式也类似。成员函数模板可用于类或类模板中,我们接下来将回顾。
类模板的实例化与函数模板的实例化类似 —— 使用模板创建类型会隐式地实例化该模板。要使用类模板,需要为模板参数指定类型实参:
template <typename N, typename D> class Ratio {
public:
Ratio() : num_(), denom_() {}
Ratio(const N& num, const D& denom) :
num_(num), denom_(denom) {}
explicit operator double() const {
return double(num_)/double(denom_);
}
private:
N num_;
D denom_;
};
Ratio<int, double> r;
变量 r 的定义隐式地为 int 和 double 类型实例化了 Ratio 类模板,还实例化了该类的默认构造函数。第二个构造函数在此代码中未使用,因此不会实例化。类模板的这一特性 —— 实例化模板会实例化所有数据成员,但方法直到使用时才会实例化 —— 可以编写仅对某些类型能编译部分方法的类模板。如果使用第二个构造函数来初始化 Ratio 的值,则该构造函数将实例化,并且必须对给定类型有效:
Ratio<int, double> r(5, 0.1);
C++17 中,这些构造函数可用于从构造函数参数中推导类模板的类型:
Ratio r(5, 0.1);
这只有在构造函数参数足够推导出类型时才有效。例如,默认构造的 Ratio 对象必须显式指定类型来实例化;没有其他方式可以进行推导。在 C++17 之前,通常使用辅助函数模板来构造一个其类型可以从参数中推导出的对象。类似于之前看过的 std::make_pair(),可以实现一个 make_ratio 函数,实现与 C++17 构造函数参数推导相同的功能:
template <typename N, typename D>
Ratio<N, D> make_ratio(const N& num, const D& denom) {
return { num, denom };
}
auto r(make_ratio(5, 0.1));
应优先使用 C++17 的模板参数推导方式(如果可用):不需要编写另一个本质上复制类构造函数的函数,也不会为了初始化对象而调用复制或移动构造函数(尽管实际上大多数编译器会执行返回值优化,从而消除对复制或移动构造函数的调用)。
当使用模板生成类型时,会隐式地实例化该模板。类模板和函数模板也都可以显式实例化。这样做会在不使用模板的情况下进行实例化:
template class Ratio<long, long>;
template Ratio<long, long> make_ratio(const long&,
const long&);
显式实例化很少需要,在本书的其他地方不会使用。
虽然带有特定模板参数的类模板实例在行为上(大多数情况下)类似于普通类,但类模板的静态数据成员需要特别说明。首先,回顾一下静态类数据成员的常见挑战:必须在某处定义,并且只能定义一次:
// 头文件中:
class A {
static int n;
};
// C++ 源文件中:
int A::n = 0;
std::cout << A::n;
如果没有这样的定义,程序将无法链接:名称 A::n 未定义。但如果将定义移到头文件中,并且该头文件包含在多个编译单元中,程序也将无法链接,名称 A::n 重复定义。
对于类模板来说,要求静态数据成员恰好定义一次是不可行的:需要为模板实例化的每一组模板参数都定义这些静态成员,而无法在一个编译单元中完成所有定义(其他编译单元可能会用不同的类型实例化同一模板)。幸运的是,这并非必要。类模板的静态成员可以(也应该)与模板本身一起定义:
// 头文件中:
template <typename T> class A {
static T n;
};
template <typename T> T A<T>::n {};
虽然这在技术上会导致多个定义,但链接器的任务是将它们合并,从而得到一个单一的定义(同一类型的所有对象的静态成员变量只有一个值)。
在C++17中,内联变量提供了一个更简单的解决方案:
// 头文件中:
template <typename T> class A {
static inline T n {};
};
这也适用于非模板类:
// 头文件中:
class A {
static inline int n = 0;
}
如果类模板的静态数据成员具有非简单的构造函数,则对该模板的每次实例化都会调用此构造函数一次(不是对每个对象调用 —— 同一类型的所有对象的静态成员变量只有一个实例)。
目前为止,所使用的类模板允许我们声明泛型类,即可以用许多不同类型实例化的类。所有这些类除了类型之外看起来完全相同,并生成相同的代码。这并不总是可取 —— 不同的类型可能需要以某种不同的方式处理。
假设不仅希望表示存储在ratio对象中的两个数的比率,还希望表示存储在其他位置的两个数的比率,而Ratio对象包含指向这些数的指针。如果对象存储了指向分子和分母的指针,则Ratio对象的某些方法(例如转换为double的操作符)需要以不同的方式实现。在C++中,这通过模板特化来实现,接下来将介绍模板的特化。