前言
在之前的文章里,我们进行了模板初阶的学习( 【C++】泛型编程——模板初阶),了解了什么是泛型编程,学习了函数模板和类模板。 那这篇文章,我们继续学习模板进阶的内容的学习。
1. 模板参数的分类
首先我们来回顾一下:
我们在模板初阶的学习中,定义模板参数是怎么定义的? 是不是使用class或者typename关键字啊, template<class T1, class T2,...,class Tn>
对于函数模板来说,我们调用函数时,传的参数是什么类型,T就会被替换成对应的类型,然后实例化出对应的模板函数,我们实际调用的就是函数模板根据具体传入的实参类型实例化出来的模板函数。
那对于跟在class或者typename之后的这种模板参数,我们把它叫做类型模板参数:
即它定义的是一个类型,对应的模板实例化的时候该参数会被替换成一个具体的类型,供其对应的模板类或模板函数使用。
但是呢,这其实只是模板参数的一种:
模板参数分为类型模板参数和非类型模板参数。
类型模板参数我们已经了解了:
类型形参即:出现在模板参数列表中,跟在class或者typename关键字之后的参数类型名称。
那非类型模板参数又是什么呢?
2. 非类型模板参数
2.1 非类型模板参数的概念
非类型模板参数的概念:
非类型模板参数,就是用一个常量(且必须是整型常量)作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
什么意思呢?下面我们通过一个栗子细细的给大家介绍一下:
2.2 铺垫
假设我们现在要写一个静态的顺序表,那我们就可以这样搞
首先这里我们定义了一个标识符(宏)常量N,用N作为当前静态数组的大小,就使得我们后续想要改变数组大小的时候很方便。 其次,我们把它实现成了一个类模板,该类模板有一个模板参数T,那通过上面的了解我们知道这里的T其实就是一个类型模板参数,它定义的是一个类型,这样我们在使用该类模板的时候,指定什么类型,实例化出来的数组(模板类)就存放什么类型的数据。
这样看起来好像也挺方便的。
但是对于有些情况却不能很好的处理:
如果我们现在想达到这样一种效果,我们想让a1的大小为10 ,而a2的大小为20,目前的实现可以做到吗? 不行啊,虽然这里我们用来#define定义的宏,改变数组的大小是很方便的,但是,这里我们实例化出两个对象,一个大小为10,一个20,是不是做不到啊。
那大家来思考一下:
首先对于类型模板参数来说,他解决了类型的问题。 我们没有学模板之前,写一个数据结构,比如有一个栈,我们一般会有一个typedef
,这样想要改变栈里存储的数据的类型很方便,但是如果我们在main函数里定义了2个或者多个栈,想让它们分别存储不同类型的数据,能不能做到呢? 如果typedef的话是不能的,但是在模板初阶的学习之后,借助模板,用类型模板参数是不是就可以解决这个问题啊!
这样在一个main函数中,我们定义两个栈,就可以让他们分别存储不同类型的数据。
2.2 非类型模板参数的使用
那再回到我们上面的问题,其实这里有点类似:
类型模板参数呢?解决了类型的问题。 那这里我们想让a1大小为10,a2大小为20
这与类型无关啊,那这种情况又该如何解决呢? 🆗,那非类型模板参数的引入,其实就很好的解决了这种问题。
类型模板参数定义了一个类型,那非类型模板参数定义的是什么呢?
我们再来回顾一下非类型模板参数的概念: 非类型模板参数,就是用一个常量(且必须是整型常量)作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。 是的,非类型模板参数其实定义的是一个整型常量。 注意⚠:这里的整型不单指int,而是整个整型家族(包括C99引入的bool类型)。
具体怎么用呢?这里我们就可以这样写:
和类型模板参数一样,直接放在模板参数列表里面就行了。 我们看到这里是这样写的——size_t N
,虽然没加const,但是规定它在这里就是常量。 后面我们会验证它是不能修改的。
那这样就可以实现a1的大小是10,a2的大小是20了。
当然:
非类型模板参数也是可以给缺省值的。
2.4 注意
再次提醒大家:
非类型模板参数必须是常量且要是整型。
我们可以来验证一下:
这次我们举个函数模板的例子: 首先常量就意味着它不能被修改:
其次必须是整型
所以说: 浮点数、类对象以及字符串等其它非整形的类型是不允许作为非类型模板参数的。 不过呢,这里提一下,就是C++20允许使用float或double作为非类型模板参数。
另外要知道: 非类型的模板参数必须在编译期间就能确认结果。
2.5 array的了解
然后我们再来了解一个东西就是:
C++11搞出来了一个新容器——array
。 其实可以认为就是静态数组,我们看到文档给的解释是固定大小的序列容器
我们看到array这个类模板其实就用了一个非类型模板参数来作为这个数组的大小。 可以看一下它的成员函数
🆗,那C++11搞出来这个东西
其实是对标C语言里的静态数组: 我们包一下<array>
这个头文件就可以使用它
那array的底层其实也是一个静态数组,只不过用类进行了封装。
那大家想一下,本来就已经有静态数组了,为什么还要搞出来一个这个,或者说,它于C语言的静态数组相比,有什么进步吗?
嗯~,array可以用迭代器,而数组不能。 是,虽然数组不能用迭代器,但是它也可以用范围for啊,范围for用起来就很方便啊。 那这样看来好像没什么特别之处啊,难道array可以自动初始化?
并没有。 没啥用啊,与原生的数组相比好像没啥进步啊。 🆗,其实它的优势是对越界的一个检查比数组更严格一点。 传统的数组对于越界的检查是一个抽查,有时候越界会报错,而有时候可能就不会
越界读,不会报错。 如果越界写:
就不一定了,所以我们说是抽查。
那对于array来说:
它对于读写的检查就比较严格,比较全面。 读
写
因为它是一个类嘛,它里面可以拿非类型模板参数这个N去比较,判断你是否越界。
所以要说它比传统数组的进步的话,可能就这一个点了吧。
所以C++11搞出来这个其实是想让我们以后用数组的时候都用array。 但是大家想一下如果给我们选择的话我们会用这个嘛? 是不是又vector啊,我vector如果越界也能很好的检查啊,并且我vector还能直接初始化,array有哪一点比vector强。 所以,这个设计就感觉有点没必要了😂。 如果要论array和vector的区别的话,那就是array的空间是在栈上开辟的,而vector的空间是在堆上申请的。
所以这个大家了解一下就行,没什么用。
3. 模板的特化
接下来我们再来学一个东西叫做模板的特化。 首先我们来认识一下它的概念。
3.1 概念
通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理。
举个栗子: 我们这里现在有一个专门用来进行小于比较的函数模板
代码语言:javascript
复制
template<class T>
bool Less(T left, T right)
{
return left < right;
}
那我们现在就可以使用它去比较不同类型数据的大小:
代码语言:javascript
复制
int main()
{
cout << Less(1, 5) << endl; // 可以比较,结果正确
return 0;
}
我们来看一下结果:
没有问题,结果正确。
现在我们拿过来一个日期类:
代码语言:javascript
复制
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
bool operator<(const Date& d)const
{
return (_year < d._year) ||
(_year == d._year && _month < d._month) ||
(_year == d._year && _month == d._month && _day < d._day);
}
bool operator>(const Date& d)const
{
return (_year > d._year) ||
(_year == d._year && _month > d._month) ||
(_year == d._year && _month == d._month && _day > d._day);
}
private:
int _year;
int _month;
int _day;
};
他已经重载了大于小于操作符。
那我们现在相比较日期类的大小:
看一下结果:
没有问题,结果也是正确的。 但是,这样呢?
我们再来运行看结果:
欸,p1指向的日期小于p2指向的日期啊,那结果怎么是0呢? 🆗,因为这里比较的并不是它们指向的日期,而是这两个指针本身,那指针进行比较的话实际比较的是它们存储的地址的大小,所以这里的结果可能并不是我们想要的。 可以看到,Less绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。 上述示例中,p1指向的d1显然小于p2指向的d2对象,但是Less内部并没有比较p1和p2指向的对象内容,而比较的是p1和p2指针的地址,这就无法达到预期而错误。
那如果我们就想比较日期,即使我们拿到的是date*的指针,我们也想按日期去比较,怎么办?
🆗,我们在优先级队列那篇文章是不是解决过这个问题啊,可以写一个仿函数去搞定这个问题。 那除了仿函数,还有没有其它方法来解决呢? 有的,我们还可以使用模板特化去解决这个问题。
那模板特化到底是什么呢?
模板特化即在原模板的基础上,针对特殊类型所进行特殊化的实现。 模板特化中分为函数模板特化与类模板特化。
3.2 函数模板特化
首先我们来看函数模板特化,我们说上面那种情况可以用模板特化去解决。
那现在问题来了,对于一个像上面那样的函数模板,我们想对其进行特化,要怎么做呢?或者说步骤是什么?
🆗,来看,函数模板特化的步骤:
- 必须要先有一个基础的函数模板
那这个我们已经有了啊
代码语言:javascript
复制
template<class T>
bool Less(T left, T right)
{
return left < right;
}
接着要怎么做呢? 🆗,特化出来的版本是这样的:
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
那我们现在要对Date*
这种类型进行特化,所以函数名后面的尖括号里面放的就是Date*
。
那我们想要的是比较它们指向的日期的大小,所以现在函数体应该这样实现:
至此我们的特化就完成了。
那我们来试一下这次的结果是否正确:
🆗,这下结果就正确了。
我们实现了特化的版本之后:
对于那些普通的类型,就还是走普通版本的逻辑
而一旦遇到我们特化的类型,他就会走特化版本的逻辑。
所以模板特化就是针对某些特殊类型进行特殊化的处理。
当然,其实针对上面这种情况,我们不使用模板特化也能很轻松解决:
我们可以直接针对Date*
这种类型,写一个函数出来啊:
这样也可以啊。 该种实现简单明了,代码的可读性高,容易书写。
是的,所以说:
函数模板不建议特化,一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将处理该情况的函数直接给出,而不是对其进行特化。 所以对于函数模板特化我们这里也不再继续介绍更多的内容了。
3.3 类模板特化
接下来我们再来学习一下类模板的特化:
首先我们要知道模板的特化其实分为两种——全特化和偏特化。
3.3.1 全特化
全特化即将模板参数列表中所有的参数都确定化。
举个栗子,现在有这样一个类模板:
代码语言:javascript
复制
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
那我们现在想对这个类模板进行全特化,怎么做呢?
那就是把该类模板模板参数列表的所有参数都确定化嘛。
那此时如果我们实例化该类模板的时候:
如果我们指定的类型是<int, char>
,那就匹配全特化的版本,因为我们的全特化就是把它确定成了<int, char>
,那其它类型就匹配的是原始的版本。
3.3.2 偏特化
那什么是偏特化呢? 偏特化有以下两种表现方式:
部分特化
- 部分特化 将模板参数类表中的一部分参数特化(确定化)
什么意思?举个栗子:
还是上面那个Data类模板
那我们想对它进行部分特化,比如把第二个参数特化成int,就是这样搞:
参数更进一步的限制
- 参数更进一步的限制 针对模板参数更进一步的条件限制所设计出来的一个特化版 本。
什么意思呢?举个栗子: 现在这里有一个用来进行小于比较的仿函数
代码语言:javascript
复制
template<class T>
struct Less
{
bool operator()(const T& l, const T& r) const
{
return l < r;
}
};
我们来用它比较一些内置类型数据的大小,包括我们的日期类(重载了><
),都是没什么问题的。 当然如果我们比Date*
的话,还是比较的是地址的大小,如果我们想让他比较指针指向的数据的大小,我们可以对Date*
这个类型进行一个特化(那在这里其实就是一个全特化)。
但是,如果我们不只针对Date*
的指针,对于其它类型的指针,比较时我们也想去比较它们指向的内容,而不是地址。 那我们可以怎么做呢?难道对不同的指针类型都进行一个特化吗?
这显然是很麻烦的。 那我们此时就可以用偏特化的第二种形式——参数更进一步的限制来解决这个问题。
怎么做呢?
进行一个偏特化,将模板参数限制成T*,这样只要调用仿函数时传的数据是指针类型,都会去匹配偏特化的这个版本,对指针指向的内容进行比较,而不是存储的地址。
我们来运行看一下:
这下就可以了。
4. 模板分离编译
然后我们再来学习一个东西叫做模板分离编译。
大家还记不记得我们在模板初阶的学习中,文章最后我们提到一个东西,就是我们定义一个类可能习惯头文件和源文件分开来,那普通类这样搞是没问题的,就像我们之前实现的日期类就是多文件管理的。 但是呢,类模板不行,类模板如果这样搞,会链接错误的。
那这篇文章在这里,我们就会来解释一下其中的原因。
4.1 什么是分离编译
我们先来了解一下,什么是分离编译:
分离编译模式源于C语言,在C++语言中继续沿用。 简单地说,分离编译模式是指:一个程序(项目)由若干个源文件(或在加上若干头文件)共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件连接起来形成单一的可执行文件的过程。
4.2 模板的分离编译
假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:
除了模板函数之外,我还加了一个普通函数,也是声明定义分开。
然后我们在test.cpp
的main函数中去调用模板函数和普通函数,我们会发现:
普通函数func分离编译时没问题的,可以正常调用,没有报错。 但是:
我们发现模板是不行的,它报了一个链接错误。
那为什么呢?为什么模板分离编译不行呢?
🆗,那我们接下来就来分析一个当前这个程序编译链接的一个过程(复习相关知识可看之前的这篇文章:【C进阶】——我们写的代码是如何一步步变成可执行程序(.EXE)的?),看能不能发现其中的原因。 那大致的过程呢差不多是这个样子的:
在里面这个编译的过程中,会把预处理之后的C++代码转换为汇编代码(由一系列汇编指令组成),而函数的地址信息其实就包含在这些汇编指令中。 但是呢,对于当前这个程序来说,编译(func.i——>func.s
)的过程中,只会生成func函数的汇编指令,而并没有函数模板Add的。 因为Add没有被实例化,为什么没实例化呢,函数模板实例化不是在编译期间就会进行吗? 因为func.cpp和test.cpp是分开的,链接之前它们都是单独进行的,test.cpp里面指定了具体类型对Add进行实例化,但是func.cpp编译的时候没法确定类型,因为在链接之前它们不交互,所以func.cpp里面的函数模板没法确定T要替换成什么类型,所以没法实例化。 那然后我们再来分析一下,main函数中在调用它们的时候这个过程是怎么样的?
首先在test.c编译的过程中这几句代码肯定也会被变成汇编代码
那函数调用转换成对应的汇编代码其实就是去call
这个函数的地址。 但是呢,test.c包含了头文件"func.h"
,而"func.h"
里面只有Add和func的声明,并没有具体的函数定义和对应的实例化生成的具体函数,所以这里生成的符号表里面,它们的地址可能知识标识一下,无实际意义。 但是有声明的话这里编译也可以通过。 可是呢?后面链接的时候就有问题了: 我们知道连接的时候会做一件事情叫做符号表的合并和重定位,然后就可以拿到这些函数重定位之后有效的地址,就可以成功调用它们了。 那func函数在func.cpp中是有具体的定义的,所以最终的符号表中会有它有效的地址。 但是,由于Add并没有成功进行实例化,没有生成具体的函数定义,所以就找不到有效的地址,所以符号表合并和重定位之后,里面并没有有效的函数地址,可能还是声明产生的那个无效的,没有意义的地址。
所以这里两个Add就无法调用,由于是在链接阶段出现的错误,所以报的错是链接错误error LNK
。 大家也可以看一下这张图:
那通过上面的分析我们可以得出:
其实模板分离编译导致出错的原因关键就在于这些多个文件是分离编译的,在链接之前它们是不会进行交互的,所以没法实例化,没法产生具体的函数或类,因为不交互的话,类型都没法确定。 所以最后链接的时候就没法找的有效的地址,就出现了链接错误。
4.3 解决方法
那针对上面的问题,有没有什么解决方法呢? 有的,这里有两种解决方法:
- 在模板定义的位置显式实例化
举个栗子:
对于Add这个模板,你在main函数里面不是实例化了两份嘛,一份是int,一份是double。 那我们就针对这两个在模板定义的位置显示实例化一下,怎么做呢?
写法是这样的。 然后我们再来运行:
就可以了。 但是我们发现,这种方法的,增加一个新类型,我们就要增加一个显式实例化,很麻烦。 所以不推荐这种方法。
那有没有好一点的方法呢?有的
- 声明和定义可以分离,但放到一个文件中
什么意思呢?
就是你可以把模板的声明和定义分开来写,但是不要分成一个头文件,一个源文件,而是把它们放到一个.h或者一个.hpp文件里面。(有的头文件为了暗示是模板会将后缀改成.hpp,但还是头文件)
这样就不会出错了。推荐使用这种。
所以如果遇到需要分离的场景建议大家使用第二种方式。
5. 模板总结
最后我们来对模板进行一个简单的总结:
5.1 优点
- 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
- 增强了代码的灵活性
5.2 缺点
- 模板会导致代码膨胀问题,也会导致编译时间变长
- 出现模板编译错误时,错误信息非常凌乱,不易定位错误
关于模板的讲解就先到这里。