【C++】模板:了解泛型编程
本篇是C++模板学习的一些笔记
[TOC]
1.了解泛型编程
泛型编程,故如其名,是一个泛化的编程方式。其实现原理为程序员编写一个函数/类的代码示例,让编译器去填补出不同的函数实现
就好比活字印刷术,可以灵活调整印刷的板块和内容,比只能固定印刷某一个内容的雕版印刷术效率更高,也让印刷术由此得到了更广泛的应用。
在C++中,函数重载和模板的出现,让泛型编程得到了实际的应用。其中模板,就是类似活字印刷术一样的存在。
我们写一个模板,编译器帮我们按照我们写的模板的方式,实例化成不同的函数。相当于替代了一部分操作,节省了代码量。
2.函数模板
八八了那么多没用的,让我们来看看函数模板的语法实现吧
2.1 简单示例
下面是一个最简单的交换函数的例子,通过标明模板参数T
,让编译器自动识别函数传参,推测并实例化出不同的函数,供程序来调用。
1 | template<typename T> |
其中,typename
是定义模板的关键字,我们可以使用class
来替代,但不能使用struct
据我查询到的资料,在模板的定义中使用
typename/class
是没有区别的
可以看到,编译器成功调用了Swap
函数,成功交换了int类型和double类型;
2.2 多个模板参数
如果我们尝试把int和double同时传参给这个函数,会发生什么呢?
编译器会报错,表示模板参数T不明确
,因为我们在函数模板里面对两个参数的说明都是T,编译器就认为这两个参数必须是相同的类型,而传入的int和double并不相同,于是就报错了。
这时候我们有几种解决方法:
- 首先是将double强转为int(反过来亦可)
你会发现还是不行,那是因为强转并不支持用double引用int。所以我们把函数传参中的引用去掉,即可正常调用这个函数(暂且不提传引用和传值的区别)
- 使用多个模板参数
和函数传参类似,我们也可以设置多个模板参数
在下图中,我使用typeid
关键字来打印模板参数T1和T2的类型。
使用
typeid
需要包含头文件#include <typeinfo>
可以看到,实际上函数在调用这个模板的时候,已经实例化了这个函数(即替换模板参数为正确参数类型)这时候在后台处理的时候,其实Show函数已经实例化为了下面这个样子
1 | void Show(int left, double right) |
2.3 模板实例化
上面的方式,是编译器自动帮我们实例化模板参数。在实际使用中,我们还可以自己指定实例化为什么类型
- 利用强制类型转换
- 使用
<int>
直接指定实例化为int类型(显式实例化)
使用第二种方式的时候,编译器会对另外一个不匹配的参数进行隐式类型转换。如果转换不成功,或者该参数不支持转换,则会报错。
另外注意的是,函数模板参数T同样可以用来作为返回值,但是不能通过返回值来推断参数T的类型。比如下面这个函数,我们在使用的时候就需要直接<int>指定
模板参数T,而不能写一个 int* ptr=test(10)
,让编译器通过 “返回值是int*
接收的,所以函数模板参数T是int”来推断。
1 | template<typename T> |
函数模板支持给予类型缺省值
当一个参数不确定的时候,函数模板的参数是支持给予缺省值的
1 | // 传递一个类型做为缺省值 |
比如这样,当我们没有直接指定的时候,编译器就会将T作为char类型,返回一个num大小的char(一个字节)的空间
注意:当有多个模板参数时,缺省值需要从右往左给,否则会出现类型匹配不上而无法调用的问题;这点和函数参数缺省值相同。
函数模板中函数的传参也支持缺省值
1 | template<typename T1> |
在这种情况下,编译器会正确调用该函数模板
2.4 模板和普通函数同时存在(重载)
以Add函数为例,在函数模板存在的同时,我们还可以单独写一个int类型的add函数。这都归功于函数重载的存在。
同时,我们还可以使用<int>
来指定函数模板重载为已存在的Add函数。因为本质上这两个函数是不同的,并不会冲突。
函数在调用的时候,首先会去调用已经存在的函数。当参数和已存在的函数不匹配时,才会调用函数模板;
而有<int>
直接指定了模板参数类型的函数调用,自然也是会调用函数模板实例化出来的函数。
2.5 函数模板不支持定义和声明分离
一般情况下,我们都会在头文件中声明函数,在另外一个源文件中定义函数。
但是模板是不支持这么做的!严谨来说,是不支持在不同文件中声明和定义分离,同一个文件是可以的。
分了两个文件的时候,编译器会报错 链接错误
,比如如下VS2019的报错
1 | error LNK2019:无法解析的外部符号…… |
所以我们需要将函数模板的声明和定义放在一个头文件中。在部分使用场景,会使用.hpp
后缀,来表示这个头文件是包含了函数定义的(即.h 和 .cpp
的集合体)。
c/cpp 中对头文件的包含并没有硬性的后缀要求,我们设置为
.h/.hpp
只是一个约定。
需要注意,这并不是一个硬性要求,你也可以直接使用.h
,并将声明和定义放入其中。
- 模板为什么不支持定义和声明分离?
因为单独的.h/.hpp
声明会在源文件顶部被编译器展开,而此时函数模板就会开始正常推演参数,并call 函数名
;
但编译器并没有找到这个函数名所对应函数的具体实现,而在另外一个文件中(实现在另外一个函数里面),编译器又不知道你这个函数模板的声明需要实例化成什么类型的函数,那也没有办法成功进行实例化。
最终就导致call
了一个没有地址的函数,链接的时候肯定就找不到函数的地址,产生了符号表的链接错误
;
- 有无解决办法?
其实是有的,我们可以在模板函数定义的.cpp
中对我们需要使用的函数进行显式实例化指定.
1 | //头文件 |
模板显式实例化需要对我们要用的所有类型的函数模板进行实例化,比如你需要用double类型,只显示实例化了int类型是不行的,依旧会报错。
这就好比你只告诉厨子做10个人的饭,结果来了20个人。那剩下的10个人肯定是吃不上饭的。因为你没有告诉厨子(编译器)到底要来多少个人干饭。
这样感觉非常多余……对吧?写多了还容易眼花。所以还是老老实实把声明和定义放在同一个文件里面吧!
3.类模板
类模板的基本形式如下,这里作为一个小区分,我用class
来当作模板参数名。实际上typename
也是可以的
1 | template<class T1, class T2, ...> |
3.1 简单示例
下面用一个非常简单的顺序表代码来演示一下类模板
1 | template<class T> |
可以看到,通过显式实例化类模板的方式,我们成功让这个类模板变成了两个不同类型的顺序表
3.2 成员函数声明和定义分离
其中需要注意的是析构函数,声明和定义分离的时候(同一文件),在定义的时候也需要加上模板参数
1 | //类模板中函数放在类外进行定义时,需要加模板参数列表 |
个人觉得这样也非常麻烦,既然模板最好是声明和定义放在同一个文件,那还不如直接将类的成员函数直接定义到类内部。多省事!
- 如果是类的声明和定义放在不同文件中,显式实例化方式如下
1 | template |
需要什么类型的类,就得实例化这个类型。
以下是模板进阶的内容,源码仓库 GITEE
4.非类型模板参数
上面我们接触到的,都是类型模板参数。还有另外一种模板参数是非类型模板参数。
- 类型模板参数:给的模板参数是一个类型
- 非类型模板参数:给的模板参数是一个常量
比如看看stl中的array容器,它的声明是下面这样的
1 | template < class T, size_t N > class array; |
其中第二个参数 size_t N
就是一个非类型模板参数
4.1 什么场景需要非类型模板参数?
用一个栈来作为栗子,不考虑动态内存管理。我们可以通过define
一个栈的空间大小,来实例化一个int类型的栈
1 |
|
为了让这个栈能自适应的实例化出不同成员类型的栈,C++提供了类型模板参数
1 |
|
这两个栈实例化的方式是下面这样,其中第一个栈是已经确定了是int类型,而第二个栈可以被实例化出任何我们想要的类型。
1 | int main() |
不过,即便已经有了模板参数,我们还是没有办法做到,让其中一个栈是100个成员的空间,另外一个栈是30的空间;这是因为空间在初始化的时候就已经被define
替换了,并开辟了定长的空间。
动态内存管理的时候,是可以通过构造函数来解决到底实例化多少空间的问题。但现在假设我们需要的就是一个定长的栈,那么动态内存管理就无能为力了。
这时候,非类型模板参数就出场了,在这种场景下,我们可以通过模板l传递参数,来确定栈内空间的大小;
1 | // 动态栈 |
这就给了我们在使用模板实例化对象的时候,将一些参数像函数传参一样提供给这个类,作为其实例化的地基的能力
1 | Stack3<int, 30> st4; |
编译通过无报错
1 | g++ test.cpp -o test -std=c++11 |
将成员变量设置成公有,用sizeof
能观察到这两个数组的大小和预期相符
1 | cout << sizeof(st4._st) << endl; |
输出结果
1 | 120 |
非类型模板参数不可被修改
1 | template <class T, size_t N> |
当我们添加了一个修改N的函数,并尝试调用它的时候,编译会报错
1 | g++ test.cpp -o test -std=c++11 |
报错的意思是 =
操作符需要一个可被修改的左值。这便告诉我们,非类型模板参数是一个const常量,是不能被修改的。
4.2 STL的Array
回过头来看stl的array,此时就能理解这个非类型模板参数的作用了;
在声明中,第二个模板参数已经指定了参数类型为size_t
,此时需要给N传入的值就是一个确定的无符号整数,作为array容器的空间大小;
1 | template < class T, size_t N > class array; |
因为array是一个固定大小的数组容器,并不像vector那样可以实现扩容操作。
而且array
开辟的空间在栈上,vector
在堆上(堆的空间大小远大于栈)
1 | Arrays are fixed-size sequence containers |
那么array
这个容器的作用是什么呢?
- 我们确定知道需要使用多少长度的场景
array
对标的应该是原生数组,和vector
相比,其优势基本没有。array
相比直接使用定长数组,其封装了迭代器,能使用迭代器来进行参数的访问。能保证array和其他STL容器访问的代码的统一性。array
因为有封装operator[]
,所以有预先写好的越界检查;而直接使用a[100]
原生数组,越界检查是不确定的。
不过,这类封装和vector
相比就尽显颓势了,所以在日常编码中array
使用的频率很低
4.3 非类型模板参数类型限制
1 | // 非类型模板参数只能用整数 |
如果你想使用除了整数以外的类型作为非类型模板参数,包括浮点数和字符串,都是不可以的;
但是char
类型都是可以的(字符底层是1个字节的整数)
5.模板的特化
在绝大多数场景中,模板提供的这两个特性已经可以帮助我们完成很多工作。但在一些特殊情形下,模板的特化就没有我们想象中的那么美好了。
5.1 问题引出
1 | template <class T> |
比如上面的代码中,我们使用一个模板参数来进行两个相同类型之间的比较,输出的结果与预期相符
1 | 0 |
引入我们自己写的日期类(类中封装了对大小比较的重载)也是可以使用的
1 | Date d1(2023,3,1); |
但如果用指针呢?问题就出现了!
1 | int main() |
编译运行后,发现指针处输出的结果是1,即 pd1<pd2
成立!
1 | $ ./test |
这是因为,在内部的比较是直接比较的这两个date*
的指针,其比较的是这两个指针的地址大小!
1 | Date* pd2 = new Date(2023,3,1); |
如果让pd2
这个更小的日期在pd1
之前被new,那输出的结果又变成0了;我们也能确认这个输出的结果,并非预期的日期比较结果
1 | $ ./test |
5.2 模板特化
5.3.1 函数模板特化
针对这种情况,我们需要进行一些特殊化的处理;
1 | template <class T> |
处理之后的函数再进行比较,输出的结果就是正确的了
1 | Date* pd1 = new Date(2023,3,2); |
可以将这样的特化当作一种特殊的函数重载;
那么函数模板是否支持后文中类模板一样的半特化呢?
1 | template <class T> |
并不支持!vscode就会报错告知我们,编译也无法通过
1 | g++ test.cpp date/date.cpp -o test -std=c++11 |
要像实现对指针的操作,反而得单独写一个像下面一样的模板;
需要注意,这个并不是模板特化,而是一个和原本的模板完全独立的函数模板,二者构成函数重载关系了(将原本的函数模板删除后,依旧可以编译通过并正常运行,就可以证明这个函数模板和原有函数模板无关)
1 | template <class T> |
此时date和int的指针都会走这个模板,而不会关注原本对Date*
特化后的模板
1 |
|
输出结果
1 | T* |
在遇到特殊情况的时候,函数模板直接实现一个特定类型的特化,或者实现一个完全不同的函数模板就可以了。函数模板是不支持使用T* 和 T&
的偏特化的。
调用时候指定模板参数类型的行为叫
显式实例化
,并非特化
5.3.2 类模板特化
1 | template<class T1, class T2> |
没有特化之前,两个函数都是调用的模板本身
1 | $ ./test |
全特化
添加了一个针对<int, double>
特化之后(这种特化被称为全特化)
1 | template<> |
调用的就是我们特化后的类模板了
1 | $ ./test |
半特化/偏特化
偏特化的第一种形式,将模板参数中的一部分特化。从左往右/从右往左都可以。
比如下面的代码中,我们将模板参数的T2
修改为char
1 | // 1、将模板参数类表中的一部分参数特化。 |
那么修改了之后,只要第二个参数是char的实例化类,都会走偏特化后的模板
1 | Data<int,char> d3; |
测试结果,走的都是偏特化后的版本
1 | Data<T1, char> |
除了这种偏特化某个特定类型的,我们还可以针对指针和引用进行偏特化
1 | // 2、偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。 |
测试代码
1 | Data<char*,char*> d5; |
输出如下
1 | Data<T1*, T2*> |
而只有一个指针的情况,会匹配原生的
1 | // 只有一个指针,会匹配原生的 |
输出结果
1 | Data<T1, T2> |
引用的结果也是一样的,两个引用就会调用我们对引用特化了的类模板
1 | Data<int&,char&> d9; |
输出
1 | Data<T1&, T2&> |
但是引用如果只有一个(不管是在前面还是后面),那就会报错了!
1 | Data<char&,double> d11; |
这是因为它会去调用没有特化后的基础版本,但在默认版本中,我们使用了模板参数来定义了一个变量,这时候就出错了(因为引用必须要在定义的时候赋值!)
1 | g++ test.cpp date/date.cpp -o test -std=c++11 |
如果将基础模板中的这两个变量定义删除,那就不会报错了
1 | template <class T1, class T2> |
运行,输出的结果也是默认的模板
1 | Data<T1, T2> |
小结
偏特化的适用范围一般高于全特化。
偏特化和全特化同时存在时,优先调用全特化!
5.3 使用场景
当我们使用CPP库函数sort
的时候, 需要传入一个仿函数来进行排序的操作。
1 | template <class T> |
在栈上创建的Date
是可以直接进行仿函数排序,并获取到正确结果
1 | 2023-3-2 |
但如果是new出来的Date
,就会因为是比较的指针而无法正常进行排序
1 | Date *pd1 = new Date(2023, 3, 2); |
输出结果无序
1 | 2023-7-1 |
这时候就需要对Less这个仿函数的类进行特化了,我们可以使用全特化,也可以使用偏特化,二者效果相同;而针对指针的偏特化显然适用范围更广
1 | // 仿函数类特化 |
再次测试,成功打印出有序结果
1 | 2023-3-2 |
5.4 迭代器萃取
5.4.1 iterator_traits
这个问题在STL-LIST的博客中已经涉及到了一部分;但那时候还没还有了解模板中的特化,没有办法详细地去观察底层的实现机制。
在STL源码中的stl_iterator.h
文件中,可以看到反向迭代器中的如下实现。其使用了iterator_traits
模板类,来获取正向迭代器中的成员类型(暂时只关注pointer/reference
)
1 | template <class Iterator> |
再看看这个类的实现,能看到如下的内容,其针对正向迭代器,同时刻画了关于T*
指针和const T*
引用的两个偏特化版本。
因为在list中,我们的正向迭代器是自主封装实现的。但是在vector中,正向迭代器直接用的就是指针。提供了这两个特化版本,就能保证即便是用指针这一内置类型实现的特化版本,能成功地获取到一个正确的数据类型(同时也使用了typename
告诉编译器这是数据类型)的typedef
1 | template <class Iterator> |
5.4.2 distance
除了反向迭代器这里需要用到特化,在STL提供的distance函数中也会用到(用于计算两个迭代器之间的距离)
1 | template <class InputIterator, class Distance> |
如果是一个单项迭代器,其就需要用过++
来计算出两个迭代器之间的长度。如果是一个随机迭代器,那就可以直接通过二者相减计算出距离。
这时候就需要通过萃取来获取迭代器的类型
(在迭代器类构造的时候,迭代器类型就已经通过萃取确认了),并通过这个迭代器的类型,确定使用的算法;
6.模板的显示实例化
6.1 说明
请注意,模板的显示实例化和模板的特化不是一个东西,需要注意区分开来。
所谓模板显示实例化,就是让编译器给你指定的模板类型生成对应的代码,这样在后续的编译过程中不再需要多次生成。
在上文中其实已经出现过函数模板实例化的示例 👉 点我跳转到上文. 我们在函数模板定义的源文件中,显示实例化我们需要的类型的函数模板,这样后续编译器在生成对应函数的时候,就可以正常从模板生成出我们需要的函数,而避免编译时找不到目标函数的问题。
6.2 语法
模板显示实例化的方式是template+函数模板的原本声明。
比如显示实例化一个函数,就需要用template带上这个函数原本的定义和具体的模板参数类型。
1 | template<typename T1> |
如果是显示实例化一个class,那么操作如下
1 | template <typename T> |
6.3 显示实例化的作用域
请注意,如果使用显示实例化,那么它的实例化后的模板类/函数的声明都只会存在于当前源文件中,其他源文件中无法调用该函数!
下面给出4个文件中的内容,作为测试。文件名参考开头注释。
1 | // head.h |
上为head.h
文件,下为a.cpp
文件。
1 | // a.cpp |
下为b.cpp
文件和test.cpp
文件.
1 | // b.cpp |
尝试在main函数中调用分别在a和b中定义的两个相加函数.
1 | // test.cpp |
执行编译后的报错如下,在b.cpp中找不到对应的Add<int>
函数实例化之后的函数体实现,无法调用该函数模板!
1 | ╰─ g++ test.cpp a.cpp b.cpp -o test |
这是因为函数模板的实现只存在于a.cpp
中,所以只有a.cpp
中的其他函数能正常调用到这个函数模板.
所以,如果想在b.cpp
中也能调用到这个函数模板,解决办法只有在其中也带上函数模板的定义!不然是不行的! (只在b.cpp
中显示实例化是无效的,编译会报错.)
小结: 不管是什么时候,遇到函数模板和类模板,还是老老实实声明定义不分离最好!
模板总结
优点:模板复用了代码,节省了资源和开发效率,C++的模板标准库也因此产生。增强了代码的灵活性;
缺点:模板会导致代码膨胀问题,也会增加编译时间(模板在编译过程中实例化出具体的函数和类)。而且出现模板编译失败的错误时,错误信息凌乱不方便定位问题。
The end
关于模板的基本知识和进阶关于特化的知识就基本结束辣!
其实模板还有模板元编程
这种更深层的东西,但那些使用的频率并不高,具体在工作中如果用到了,可能就需要更深入的学习了。