c++概念——模板的进阶讲解
文章目录
- 模板的进阶讲解
- 非类型模板参数
- 非类型模板参数的应用
- 模板的特化
- 何为特化
- 特化的参数注意事项
- 例1
- 例2
- 全特化
- 偏特化
- 偏特化的另一种形式
- 模板分离编译
- 编译流程
- 解释分离编译失败原因
- 解决方案
- 模板总结
模板的进阶讲解
在学习STL的容器前,我们是已经简单的接触过模板的这个概念的。模板是代码复用的一种高效的方式。这也是我们学习STL库必备的知识。其本质就是将类型推演的事情交给编译器来做。我们只需要控制好复用的逻辑确保复用的时候不出错即可。
但是之前只是简单的提及了一下模板的基础使用,其实模板还有很多很重的内容没有讲解,本篇文章将重点对这些未涉及的内容进行深入的讲解。
非类型模板参数
如果我们想自行实现一个类模板的栈(非适配器模式),我们会这么写:
template<class T>
class stack {//...一系列接口
private:T* _a;size_t _top;size_t _capacity;
};
当然这个是支持扩容的栈。但是当一个栈很明确最多存储的数据个数的时候,我们其实使用静态的栈也是可以的。即成员变量内放一个静态的、确定大小的数组。
但是可能针对于不同情况的场景,数组的空间个数其实也是不太确定的,为了尽可能避免空间浪费,是否能做到传入一个参数就能设置好数组空间个数呢?
答案是有的,就是用非类型模板参数:
template<class T, size_t N = 10>
class stack {//...一系列接口
private:T _a[N];size_t _size;
};
如上所示,我们在声名模板的时候多加入了一个非模板类型的参数,加入了一个整形变量来控制静态栈的数据个数。这里也是可以给缺省值的(这一点前一篇文章将适配器方式的时候已经提及过)。
但是我们需要注意的一个事情是,在c++20标准以前,这里的非类型模板参数只能是整形变量,即char、short、int、 long、long long 和它们对应的无符号形式。当然bool也是属于整形变量的范畴的。
我们可以验证一下:
模板参数可以只有非类型模板参数。
我们可以在编译器属性选项中选择一下标准c++20,就可以成功编译了。但是我在这里就不做演示了。感兴趣的读者可自行去调整试一下。
非类型模板参数的应用
我们可以看一下它的一个应用:
在STL库中有一个容器叫array,看名字就知道是一个数组。这个容器的诞生是为了替代静态数组的,但是没有什么太好的效果。
我们可以发现其类模板声名的时候加入了一个变量N,就是用来控制其内部空间个数的。这个和vector是有区别的。我们可以看到array的定义是fixed-size,也就是确定个数的数组。是开辟在栈区上的。而vector是开辟在堆区上的,这是本质的区别。
对于array不作过多介绍,只需要知道它也是个容器,支持迭代器,且支持随机访问读取即可。最需要知道的就是它对越界访问的处理:
静态数组对于越界的访问是会警告但不会报错,但是越界写就会报错:
因为编译器对静态数组的处理时抽查方式,会在数组末尾设置一些东西建立检查机制。但是对于读取这个操作是很难操作的。
但是容器array就不一样了,无论是越界读取还是写都会断言警告。这是因为容器的[]操作读写是通过符号重载实现的。本质是函数,调用函数的时候直接在函数内部增加断言报错机制就可以了,这就是二者的区别。
不过二者在其他方面的使用差别不大,酌情使用即可。
模板的特化
在这里我们讲一下模板特化的知识。
何为特化
我们来看一下这个场景:
我们写一个模板函数比较大小。正常传数据比较是不会出现问题的。但是当我们传入指针变量的时候会发现除了问题。这是因为比较的变成了地址变量本身,而不是指向的内容。
1.如果是类类型的比较,我们在其内部重载一个operator<函数即可,如果是指针变量,我们就需要专门针对于各种指针变量来重载一个Less函数,只不过是比较指向的内容:
bool Less(const int* p1, const int* p2){return *p1 < *p2;
}
2.但是这样貌似不够好,因为不同类型的指针还是要重载,所以可以使用模板特化:
我们专门特化了一个版本出来,专门针对于int*指针操作。
在这里我们来介绍一下特化过程:
函数模板的特化步骤:
1. 必须要先有一个基础的函数模板
2. 关键字template后面接一对空的尖括号<>
3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型
4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇
怪的错误。
前面三点都很简单,但是最后一点是需要仔细讲述的。
特化的参数注意事项
在上一个部分特化步骤中讲述到,特化的模板函数中的参数的基础类型必须完全相同,这个完全相同是物理意义上的。
例1
我们发现这个很奇怪,为什么会报错呢?当传入指针的时候未int*类型啊,这有什么错误呢?答案就在于指针于const搭配的时候。
//const放在*的前面,修饰的是指针指向的内容
//const放在*的后面,修饰的是指针本身
再来看模板参数声名的是什么,是const T,我们想一下对于除了指针之外的类型,前面加const都是修饰变量本身的。这很好理解。只有指针会出现const放置位置不同导致不同含义的情况。
所以我们得明白,模板参数中的const修饰的变量本身,所以当特化为指针版本的时候,需要修改一下特化模板函数的参数为:(int* const Left, int* const Right)
,这样子就能够成功的使用了:
我们再做修改:
怎么又比较地址了?这是因为我们外界传入的指针变为const int *了,我们的特化版本的参数和这个不一样,很多人会说,那把尖括号中的int * 改为const int *不就可以了嘛?
还是报错,因为输入的指针变成const int *了,此时T就是const int *,而模板参数列表中的const修饰的是变量本身。所以应该这么写参数const int* const Left
,这样才符合。所以特化其实是很复杂的。
例2
对于引用的例子,我们发现这样写也是会报错的。编译器也是无法识别这个特化的模板函数。
修改一下:
注意参数中必须是引用。且模板参数部分的const修饰的虽然是引用,但我们知道,引用就是别名,其实就是原来那个变量。变量前面加const还是对变量本身进行修饰。所以我们的const仍然是放在 * 的后面。
但是这样写又是报错的:
为什么呢?因为引用的类型变量要往前看,此时是int *,前一张图是int * const,二者不一样的。
所以为了区分类型,我们可以认为const int*、int* const、const int* const其实是不同类型的。这样子有助于我们理解这个部分的知识。
全特化
全特化即是将模板参数列表中所有的参数都确定化。
特化不只是针对于函数模板的,也可以适用于类模板:
来看看这个例子,很明显前data1 ~ data3这三个变量走的是类模板,而data4走的是模板的全特化。这是为什么呢?
在刚介绍模板的时候就已经讲过,如果有现成的函数,编译器是不会去走模板那一套的,会直接用现成的。因为模板需要进行类型推演,这个过程其实是比较复杂的,也会降低效率。所以编译器会按需实例化,如果有现成的就使用现成的。
很明显,data4这个类对象是全特化的,传入的两个参数类型正好和全特化的照应的上,所以自然而然地就是用下面那个。
我们可以这么认为:全特化的也是现成的。
偏特化
有全特化,就一定会有偏特化。(其实也叫半特化)。
这个其实和我们学的缺省参数性质很像:
即我们选择给部分参数为设定好的参数。比如这张图中,只要第二个类型为char的都会走偏特化进行构造。这也是一样的道理。半现成也是现成。编译器为了最大限度地提高效率和节省资源,会选择走偏特化。
当全特化和偏特化在一块出现的时候,优先选择全特化。
可见,编译器是很“懒”的,能用现成的就用现成的。
偏特化的另一种形式
偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一
个特化版本。我们来看看是什么意思:
我们先来看类模板是怎么操作的:
//主模板
template<class T1, class T2>
class Data
{
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};//两个参数偏特化为指针类型
template <class T1, class T2>
class Data <T1*, T2*>{
public:Data() { cout << "Data<T1*, T2*>" << endl; }
private:T1 _d1;T2 _d2;
};//两个参数偏特化为引用类型
template <class T1, class 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;
};
这段代码是如何执行的呢?
这是成功的。
但是需要注意一些问题:
当我们这样写的时候,还是报错了,为什么?难道加入const的指针类型不能走主模板嘛?
答案是不行,我们偏特化了模板参数的类型。只要两个传入的都是指针就得走指针偏特化版本。但是我们写的指针偏特化版本是不带const的指针,用这个接收带有const的指针会导致权限放大,是不行的。
这是需要特别注意的。
我们再来看对于函数模板的操作:
模板函数函数对单一类型的偏特化就是控制参数部分的类型。如图,我们偏特化了一个只要传入指针就能够自动走指针比较的逻辑的版本。
但现在有一个问题,很明显,我们传入给T1,T2的都是指针类型,那么在参数列表中的T1 *和T2 *不应该都是二级指针嘛,解引用后应该还是地址,但是运行结果却是数据本身呢?
我们需要记住:在这种情况对类型进行偏特化的时候,传入的参数不是和类模板参数声名中的参数列表对照,而是和偏特化的参数列表对照。
也就是说,传入的int *应该和T1 *和T2 *对照,所以T1和T2类型为int。同理对于double也是同理。
类模板中也是一样的,对照的是特化模板旁边尖括号里面的那两个参数。
这是我们需要记住的一点。
模板分离编译
最后,我们来解决一个问题,那就是为什么类模板不能将声名与定义分离。
这个知识点需要先复习一下以往讲的编译过程。虽然是在c语言部分讲的,但是c++作为c的升级,其实编译过程大差不差。
我们先来简单复习一下:
编译流程
编译分为预处理、编译、汇编、链接。
预处理:就是先完成一些比较简单的工作如:展开头文件,宏的替换,条件编译,去注释等。生成.i为后缀的预处理文件。头文件其实是要讲全部内容展开在以.cpp为后缀的源文件中的。这里的展开就是将里面所有内容复制到源文件包含它的位置。
编译:编译就是将经预处理后的文件转化为汇编代码,生成以.s为后缀的中间文件。内部都是汇编代码。这部分简单回忆一下就好。
汇编:汇编就是将汇编代码转化为0和1组成的二进制文件。因为CPU只能看得懂0和1,生成.obj / .o为后缀的目标文件。
链接:最后链接,链接就是将多个目标文件合并成一个生成可执行的二进制文ji++件.exe(Windows环境下)。
解释分离编译失败原因
原因就出现在了链接上。我们现在给出一个例子:
//Add.h
int add(const int& x, const int& y);template<class T>
T add(const T& x, const T& y);//Add.cpp
#include"Add.h"int add(const int& x, const int& y) {return x + y;
}template<class T>
T add(const T& left, const T& right)
{return left + right;
}//Test.c
#include"Add.h"
int main() {cout << add(10, 20) << endl;cout << add(20.5, 10.5) << endl; return 0;
}
编译一下会发现,报错了:
看到这样的报错就是发生链接错误了。
我们分析一下:
我们发现,在链接前,所有的.cpp文件都是没有交互的(c++规定)。也就是说,在链接以前,Test.cpp中其实只有函数的声名,但没有函数的地址。
编译器在调用函数的时候会用到一个汇编指令叫做call,是需要函数的地址的。但是一开始编译的时候没有地址怎么办呢?
之前就说过,这种情况会先随便给个地址,先默认已经在哪个地方已经有了函数的定义了,认为通过链接就能拿到地址。所以没有地址的先随便给一个。
然后在链接的时候,会将函数的符号表进行合并(详细参考c语言编译链接及预处理那篇文章)。如果拿得到地址就可以正常使用,反之会报错(也就是链接错误)。
对于刚刚我们写的代码,非函数模板的那个add是已经确定了参数的类型的。所以链接的时候符号表合并是可以正常使用。但是对于模板来讲,它是按需实例化的,是需要进行类型推演的。但是将其定义以及声名分离,我们只能在Test.cpp中将类型给那个模板函数。但是对于Add.cpp中的那个,由于接收不到参数,就不进行实例化,也就没有函数的地址。所以链接二段时候就会报错了。
解决方案
1.模板定义的位置处进行显示实例化:
像这样子即可。可以针对不同的类型实例化出不同的。注意显示实例化的时候上面只写template就可以了,其余什么也不加。
但是这个方法不太推荐,因为完全没必要,完全是浪费了模板的优势。
2.直接在声名处定义,这个方式意思就是,尽可能地不要将模板的定义与声名分离。不分离是有好处的。是不会报链接错误的。
因为在头文件处定义了,那么将头文件展开后,Test.c中的就具有了函数的定义,那只需要编译器做一下类型推导就可以将两个.cpp文件中的函数实例化出来,就能得到函数地址了,那么合并函数符号表的时候就不会出现连接错误了。
所以我们尽可能地采取第二种方式!
模板总结
【优点】
1. 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
2. 增强了代码的灵活性
【缺陷】
1. 模板会导致代码膨胀问题,也会导致编译时间变长
2. 出现模板编译错误时,错误信息非常凌乱,不易定位错误