模板参数分为类型形参与非类型形参,类型形参即出现在模板参数列表中,跟在 class 或者 typename 关键字之后的参数类型名称,我们前面使用的所有模板参数都是类型形参;而非类型形参则是用一个常量作为类模板/函数模板的一个参数,在类模板/函数模板中可将该参数当成常量来使用。
我们以静态数组为例;在没有非类型模板参数时,我们采用如下方式来定义一个静态数组:
#define N 10
template<class T>
class arr {
public:
//...
private:
T _a[N];
};
void test1() {
arr<int> arr;
}
但是这样有一个缺陷,因为 N 的大小是固定的,所以我们只能定义出 N 个元素大小的 arr对象,那么此时如果我们想要同时定义两个大小为 10 和 100 的数组显然是做不到的。
C++ 中设计了非类型模板参数来解决了这个问题,如下,我们可以通过传递不同的非类型形参来定义不同的类,非类型模板参数在函数模板中的使用也是如此:
template<class T, size_t N>
class arr {
public:
//...
private:
T _a[N];
};
void test1() {
arr<int, 10> arr1;
arr<double, 100> arr2;
}
注意事项:
C++ 11 中引入了一个新类 – array,array 使用非类型形参作为模板参数,其底层其实就相当于静态数组:
由于 array 底层是静态数组,所以 array 的特性和静态数组的特性一样 – 数据存放在栈上、不要求数据连续存储 (使用 [] 给指定位置的数据赋值)、也不允许 push_back (不能扩容):array 使用文档
有的同学可能有疑问 – 既然 array 的底层就是静态数组,那为什么不直接使用C语言中的静态数组,而要将它封装成为一个新类呢?答案是为了更严格的进行越界检查。
C语言静态数组对越界的检查规则是 – 越界读不检查、越界写抽查:
而 array 里面重载了 [] 运算符,在 operator 函数里面对数组边界进行了判断,所以不管是越界读还是越界写都会检查出来:
所以,其实 C++ 11 设计出 array 类是为了让 array 替代掉C语言的静态数组,以此来帮助人们更早的发现并解决程序中可能出现的越界问题,但是由于人们数组已经用习惯了,所以 array 其实被使用的并不多。
注:array 除了具有安全性,还有可读性、抽象性、兼容性等优点。
通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理;比如,实现了一个专门用来进行小于比较的函数模板:
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{
return left < right;
}
void test4()
{
cout << Less(1, 2) << endl; // 可以比较,结果正确
Date d1(2022, 7, 7);
Date d2(2022, 7, 8);
cout << Less(d1, d2) << endl; // 可以比较,结果正确
Date* p1 = &d1;
Date* p2 = &d2;
cout << Less(p1, p2) << endl; // 可以比较,结果错误
}
可以看到,Less 绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果;上述示例中,p1 指向的 d1 显然小于 p2 指向的d2 对象,但是 Less 内部并没有比较 p1 和 p2 指向的对象内容,而比较的是 p1 和 p2 指针的地址,这就无法达到预期而发生错误。
此时,就需要对模板进行特化 – 即在原模板类的基础上,针对特殊类型进行特殊化的处理;模板特化中分为函数模板特化与类模板特化。
函数模板特化的步骤如下:
如下:
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{
return left < right;
}
//对Date*类型进行模板特化
template<>
bool Less<Date*>(Date* left, Date* right)
{
return *left < *right; //特殊处理:比较left和right指向的内容,而不是left和right本身
}
有的同学可能会说,我直接重载一个参数类型为 Date* 的函数即可,为什么要费这么大劲搞成模板的特化呢?-- 确实,由于函数支持重载,所以我们完全可以将重载一个/多个特殊类型的形参;所以,一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出 (函数重载)。
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{
return left < right;
}
//Date*类型的函数重载
bool Less(Date* left, Date* right)
{
return *left < *right; //特殊处理:比较left和right指向的内容,而不是left和right本身
}
如上,对于一些参数类型复杂的函数模板直接给出,即实现为函数重载,这种方法该种实现简单明了,代码的可读性高,容易书写,因此函数模板不建议特化。
类模板特化又分为全特化与偏特化。
全特化即是将模板参数列表中所有的参数都确定化,如下:
template<class T1, class T2>
class Data {
public:
Data() { cout << "Data<T1, T2>" << endl; }
private:
T1 _d1;
T2 _d2;
};
template<>
class Data<int, char> { //类模板全特化
public:
Data() { cout << "Data<int, char>" << endl; }
private:
int _d1;
char _d2;
};
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。偏特化有以下两种表现方式:
我们以如下类模板为例:
template<class T1, class T2>
class Data {
public:
Data() { cout << "Data<T1, T2>" << endl; }
private:
T1 _d1;
T2 _d2;
};
部分特化:
//部分特化--将第二个参数特化为int
template <class T1>
class Data<T1, int> {
public:
Data() { cout << "Data<T1, int>" << endl; }
private:
T1 _d1;
int _d2;
};
void test6() {
Data<int, char> d1; //使用普通模板实例化
Data<int, int> d2; //第二个参数与模板特化中的特化参数相同,优先使用特化模板进行实例化
}
可以看到,我们可以将模板中的部分参数显示指定为某种具体类型,这样模板参数在进行匹配时会优先匹配。
参数更进一步限制:
//参数更进一步限制--两个参数偏特化为指针类型
template <typename T1, typename T2>
class Data <T1*, T2*> {
public:
Data() { cout << "Data<T1*, T2*>" << endl; }
private:
T1 _d1;
T2 _d2;
};
//两个参数偏特化为引用类型
template <typename T1, typename T2>
class Data <T1&, T2&> {
public:
Data(const T1& d1, const T2& d2)
: _d1(d1)
, _d2(d2)
{
cout << "Data<T1&, T2&>" << endl;
}
private:
const T1& _d1;
const T2& _d2;
};
void test7() {
Data<double, int> d1; // 调用特化的int版本
Data<int, double> d2; // 调用基础的模板
Data<int*, int*> d3; // 调用特化的指针版本
Data<int&, int&> d4(1, 2); // 调用特化的指针版本
}
可以看到,我们可以通过偏特化对模板参数进行进一步的限制,比如模板参数定义为 <T*, T*>,这样只要实参为指针类型,不管是 int*、double*、还是 vector<int>*,都会调用此特化模板;又比如将模板参数定义为为 <T&, T&>,这样只要实参是引用类型就会调用此特化模板;
从而实现了在限制参数类型的同时又不会将参数局限为某一种具体类型。
阅读我博客的同学会发现,自从学习了模板以后,凡是要用到模板的类我们成员函数的声明和定义都是放在一起的,或者是直接在类中给出函数的定义,而不提供函数的声明,比如我们模拟实现的 vector、list、stack、queue、priority_queue 等容器;
那为什么我们不像C语言或者非模板类那样将类成员函数的声明和定义进行分离呢?函数声明和定义分离不是即能够方便别人阅读我们的代码,还能够保护源码不被泄露吗?-- 这是因为模板不支持分离编译。
我们以简易版的 stack 为例:
//Stack.h
#include<iostream>
using namespace std;
template<class T>
class Stack
{
public:
Stack(int capacity);
~Stack();
void push(const T& x);
private:
T* _a;
int _top;
int _capacity;
};
//Stack.cpp
#include "stack.h"
template<class T>
Stack<T>::Stack(int capacity)
{
cout << "Stack(int capacity = )" << capacity << endl;
_a = (T*)malloc(sizeof(T) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
template<class T>
Stack<T>::~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
template<class T>
void Stack<T>::push(const T& x)
{
// ....
//
_a[_top++] = x;
}
如上,我们将模板类 stack 进行声明和定义分离,注意:
1、类模板的外部成员定义不得具有默认参数,即类模板声明与定义分离时不能成员函数不能使用缺省参数; 2、类模板的成员函数在分离定义时必须指明该函数是属于那个类的,而 stack 是类名,stack<T> 才是类型,所以我们需要在每个成员函数前面指明类类型 stack<T>。
但是当我们编译运行的时候我们发现分离定义的所有成员函数都出现了链接性错误:
造成这种错误的原因如下:
在C语言 程序环境和预处理 那一节我们学习了一个 .c/.cpp 程序要变成 .exe 可执行程序一共要经历四个步骤,分别是预处理、编译、汇编和链接,这期间它们的工作分别为:
同时,预处理、编译、汇编这几个阶段每个源文件 (.c 文件) 都是独立进行的,只有在链接时才会将这几个目标文件合并到一起形成可执行程序。
在了解了这些知识以后,我们就可以得出程序报错的原因了:
1、预处理时,Stack.h 头文件分别展开到 Stack.cpp 和 Test.cpp 源文件中; 2、经过编译,Stack.cpp 和 Test.cpp 分别转变成汇编代码; 3、在汇编时,由于 Test.cpp 里面只有 Stack、~Stack、push 等函数的声明,而没有其定义,所以 Test.cpp 生成的符号表会给这些函数对应一个无效地址;同时,由于 Stack.cpp 里面并没有对模板实例化的代码,即没有 Stack<int>,也就没有生成具体的代码,所以 Stack.cpp 的符号表里面函数对应的也是无效地址; 4、在链接时,需要将 Test.cpp 和 Stack.cpp 符号表中的内容进行合并与重定位,但是由于它们符号表中的都是无效地址,所以发生链接错误。
在找出错误原因后有的同学可能会说,这简单,在 Stack.cpp 中对模板进行显式实例化即可,如下:
//Stack.cpp 中增加显式实例化的代码
template
class Stack<int>;
这样做确实可以,但是如果我们此时还要定义一个 double 类型的对象呢?那么我们又需要将 Stack.cpp 中的显式实例化类型改为 double,也就是说,在同一份代码中我们只能定义同一种类型的对象,那么这样也就失去了模板原本的意义了。
所以,模板不支持分离编译,我们一般采用其他的解决办法,如下:
1、模板函数不进行声明,直接在类里面给出函数的定义;(如果类很大时这种方法不方便别人阅读我们的代码,不推荐使用;当类较小时可以这样做,比如我们之前模拟实现的 STL 容器) 2、将模板函数的声明和定义放到同一个文件 “xxx.hpp” 中 (hpp:.h + .cpp) 。(这种方式使用于类较大时,方便别人快速了解我们的类) 3、注:这两种方法都有一个缺点 – 会暴露源码,因为函数的声明和定义是在一个文件中的,我们将类提供给别人使用时不得不将源码也暴露给别人,这也是模板的一个缺点。
模板的优点:
模板的缺点: