前言
在 C++ 中,我们经常会遇到这样的代码:
voidSwap(int&left,int&right);voidSwap(double&left,double&right);voidSwap(char&left,char&right);这些函数处理的数据类型不同,但完成的事情完全相同:交换两个变量的值。
函数重载虽然能够解决问题,但如果以后还需要支持float、long等更多类型,就要继续编写新的重载。代码逻辑没有变化,却因为类型不同被重复实现了很多次。
于是就产生了一个很自然的问题:
能不能只写一份代码,把具体的数据类型也抽象出来,让同一套逻辑适用于不同类型?
这就是 C++模板(Template)要解决的问题。
模板是 C++ 实现泛型编程的重要基础。它允许我们先编写一份与具体类型无关的通用代码,再由编译器根据实际使用的类型生成对应的具体代码。
本文主要围绕模板最基础的两个部分展开:
- 函数模板:让同一套函数逻辑适用于不同类型;
- 类模板:让同一套类结构适用于不同类型。
在这个过程中,还需要理解模板参数、模板实例化、类型推导以及函数模板的匹配规则。
整篇文章可以沿着这样一条主线来理解:
重复代码 ↓ 把类型抽象出来 ↓ 泛型编程 ↓ 模板 ├── 函数模板 └── 类模板 ↓ 模板实例化 ↓ 得到具体代码一、为什么需要模板?
模板出现的根本原因,是为了减少一类特殊的重复代码:
代码逻辑完全相同,只有处理的数据类型不同。
还是以交换函数为例:
voidSwap(int&left,int&right){inttemp=left;left=right;right=temp;}voidSwap(double&left,double&right){doubletemp=left;left=right;right=temp;}仔细观察会发现,两份代码真正发生变化的只有类型:
int → double交换的逻辑并没有发生任何变化。
如果能够把这里的类型也抽象成一个参数,那么就不需要针对每一种类型重新写一遍函数。
于是可以写成:
template<typenameT>voidSwap(T&left,T&right){T temp=left;left=right;right=temp;}这里的T不再代表某一种确定的数据类型,而是一个模板参数。
调用:
inta=10;intb=20;Swap(a,b);此时T对应int。
如果调用:
doublex=1.1;doubley=2.2;Swap(x,y);此时T对应double。
因此,只需要维护一份交换逻辑,就能够适用于不同的数据类型。
这种编写与具体类型无关的通用代码的方式,就是泛型编程。
而模板,就是 C++ 实现泛型编程的重要工具。
本节小结
函数重载可以让同名函数处理不同类型,但当多个函数只有类型不同、逻辑完全相同时,会产生大量重复代码。
模板进一步把类型本身也参数化:
不同类型 + 相同逻辑 ↓ 模板 ↓ 一份通用代码二、函数模板:把类型也变成参数
函数模板解决的是:
函数逻辑相同,但参与运算的数据类型不同。
普通函数中的参数,可以让传入的“数据”发生变化;而模板进一步把“类型”也抽象成参数。
1. 函数模板的基本格式
函数模板的基本形式为:
template<typenameT>返回值类型 函数名(参数列表){// 函数实现}例如:
template<typenameT>voidSwap(T&left,T&right){T temp=left;left=right;right=temp;}其中:
template<typenameT>表示声明一个模板参数T。
接下来在函数中出现的:
T就代表这个暂时还没有确定的类型。
模板参数也可以不止一个:
template<typenameT1,typenameT2>T1Add(T1 left,T2 right){returnleft+right;}此时:
T1 和 T2可以分别表示不同的类型。
2.typename和class
声明类型模板参数时,可以写:
template<typenameT>也可以写:
template<classT>例如:
template<classT>voidSwap(T&left,T&right){T temp=left;left=right;right=temp;}在这里,typename和class的作用相同,都表示T是一个类型模板参数。
需要注意的是:
template<structT>不能用来替代这里的typename或class。
本节小结
函数模板可以简单理解为:
先用模板参数代替具体类型,描述一套通用的函数逻辑。
例如:
template<typenameT>voidSwap(T&x,T&y);此时T还没有确定,它究竟是int、double还是其他类型,要等真正使用模板时才能知道。
三、函数模板是如何变成具体函数的?
定义:
template<typenameT>TAdd(constT&left,constT&right){returnleft+right;}并不意味着程序中已经存在一个确定类型的Add函数。
因为此时:
T = ?还是未知的。
真正调用:
inta=10;intb=20;Add(a,b);编译器才可以根据实参确定:
T = int然后根据函数模板生成对应类型的函数。
从理解原理的角度,可以把生成的函数看成:
intAdd(constint&left,constint&right){returnleft+right;}如果又调用:
doublex=1.1;doubley=2.2;Add(x,y);则:
T = double编译器会生成对应double类型的版本。
整个过程可以理解为:
Add<T> │ ┌────────────┴────────────┐ ▼ ▼ Add(1, 2) Add(1.1, 2.2) │ │ T=int T=double │ │ ▼ ▼ Add<int> Add<double>因此需要建立一个很重要的认识:
函数模板本身不是某一个具体类型的函数,而是编译器生成具体函数的一份模板。
根据模板生成对应具体函数的过程,就是模板实例化。
本节小结
模板帮我们减少的是“重复编写代码”的工作,而不是让所有类型真的共用同一个具体函数。
可以简单理解为:
函数模板 ↓ 确定模板参数 ↓ 模板实例化 ↓ 生成具体函数这也是理解后面模板参数推导的基础。
四、模板实例化:模板参数是怎么确定的?
模板要生成具体函数,就必须先确定模板参数。
例如:
template<classT>TAdd(constT&left,constT&right){returnleft+right;}这里的T最终必须变成一个具体类型。
根据模板参数的确定方式,可以分为隐式实例化和显式实例化。
1. 隐式实例化
隐式实例化,就是让编译器根据传入的实参自己推导模板参数。
例如:
inta1=10;inta2=20;Add(a1,a2);编译器根据两个参数得到:
a1 → int a2 → int 因此: T = int于是实例化出int类型的函数。
同理:
doubled1=1.1;doubled2=2.2;Add(d1,d2);可以得到:
T = double这就是模板参数的自动推导。
2. 为什么Add(int, double)会出现问题?
继续使用:
template<classT>TAdd(constT&left,constT&right){returnleft+right;}现在调用:
inta=10;doubled=20.0;Add(a,d);编译器推导T时会得到两个结果:
根据 a: T = int 根据 d: T = double但函数模板中只有一个T:
Add(constT&left,constT&right)同一个模板参数无法在这次推导中同时被确定成int和double,因此模板参数推导失败。
这里最容易产生一个误区:
int和double明明可以做加法,为什么模板不能调用?
问题其实不在+运算。
真正的问题发生得更早:
编译器首先要确定 T 是什么,但两个实参给出了不同的推导结果。
因此,模板参数推导阶段首先需要解决的是类型是否能够推导一致。
3. 显式指定模板参数
既然问题出在编译器不知道T应该选择哪个类型,那么也可以直接告诉它:
Add<int>(a,d);这里:
<int>明确指定:
T = int于是编译器不再需要根据两个实参推导T。
如果实际参数和指定的模板参数类型不同,只要能够完成相应的类型转换,就可以继续调用。
因此可以把两种实例化方式简单区分成:
Add(a, b) ↓ 编译器根据实参推导 T ↓ 隐式实例化 Add<int>(a, b) ↓ 程序员明确指定 T ↓ 显式实例化本节小结
模板实例化解决的是:
如何从通用模板得到具体函数?
其中:
隐式实例化 → 编译器根据实参推导模板参数 显式实例化 → 程序员主动指定模板参数特别要注意:模板参数推导失败,并不一定意味着某个运算本身不能进行,也可能只是编译器无法得到唯一一致的模板参数。
五、函数模板的匹配:到底调用哪一个?
有了函数模板以后,同一个函数名下面可能同时存在普通函数和函数模板。
例如:
intAdd(intleft,intright){returnleft+right;}template<classT>TAdd(T left,T right){returnleft+right;}这时调用:
Add(1,2);就产生了一个问题:
普通函数和函数模板都能处理这两个参数,到底应该选择谁?
1. 匹配程度相同时,优先普通函数
对于:
Add(1,2);普通函数:
intAdd(int,int);可以直接匹配。
函数模板同样可以实例化出:
Add<int>(int,int);两者都能够很好地匹配。
这种情况下,编译器会优先使用已经存在的普通函数。
如果明确写:
Add<int>(1,2);则表示主动使用函数模板生成的版本。
2. 模板也可能提供更好的匹配
因此,不能简单把规则记成:
“只要普通函数存在,就一定调用普通函数。”
真正需要考虑的是匹配程度。
如果普通函数需要进行类型转换,而函数模板能够根据实参生成一个更加合适的版本,那么模板也可能被选择。
所以更准确的理解是:
普通函数和函数模板匹配程度相同时,优先选择普通函数;如果函数模板能够产生更加匹配的版本,则选择更合适的版本。
本节小结
函数模板的匹配规则不需要死记很多情况,当前阶段先抓住一个核心:
编译器会寻找更加合适的匹配。
其中普通函数和函数模板匹配程度相同时,优先普通函数,而不是“任何情况下普通函数都优先”。
六、类模板:把泛型从函数扩展到类
前面解决的都是函数中的重复问题:
相同函数逻辑 + 不同数据类型 ↓ 函数模板但相同的问题也会出现在类中。
例如实现一个栈,如果只能保存int:
classStack{private:int*_array;size_t _capacity;size_t _size;};以后又希望保存double,如果重新实现一个DoubleStack,那么栈的扩容、入栈等逻辑都需要再写一遍。
实际上,栈的结构和操作逻辑没有变化,变化的仍然只是:
栈中保存的数据类型。
因此,可以把同样的泛型思想扩展到类:
相同类结构 + 不同数据类型 ↓ 类模板1. 类模板的基本格式
类模板的基本形式为:
template<classT1,classT2,...,classTn>class类模板名{// 成员};例如:
template<typenameT>classStack{public:Stack(size_t capacity=4){_array=newT[capacity];_capacity=capacity;_size=0;}voidPush(constT&data);private:T*_array;size_t _capacity;size_t _size;};原来固定的:
int*_array;变成:
T*_array;也就是说,Stack不再提前规定自己只能保存哪一种类型。
2. 类模板的成员函数
如果成员函数直接定义在类中,正常使用模板参数即可。
如果在类外定义,则需要重新声明模板参数:
template<classT>voidStack<T>::Push(constT&data){_array[_size]=data;++_size;}这里需要注意:
Stack<T>::Push而不是:
Stack::Push因为当前描述的是Stack<T>这个类模板对应的成员函数。
3. 类模板的实例化
函数模板在调用时,可以通过函数实参推导模板参数。
类模板则需要在使用时明确给出具体类型:
Stack<int>st1;Stack<double>st2;这里:
Stack<int>表示使用int实例化Stack;
而:
Stack<double>表示使用double实例化Stack。
需要特别区分:
Stack ↓ 类模板 Stack<int> ↓ 实例化后的具体类型因此:
Stack<int>st1;Stack<double>st2;得到的是两个不同的具体类型。
整个过程可以理解为:
Stack<T> │ ┌─────────┴─────────┐ ▼ ▼ Stack<int> Stack<double> │ │ 保存 int 保存 double本节小结
类模板和函数模板的核心思想其实完全一致:
函数模板 → 把函数中变化的类型抽象出来 类模板 → 把类中变化的类型抽象出来而且需要特别记住:
Stack是类模板,Stack<int>才是模板实例化后得到的具体类型。
七、模板的声明和定义
普通函数或者普通类通常可以把声明和定义分开:
.h → 声明 .cpp → 定义模板在使用时涉及根据具体类型进行实例化,因此模板的组织方式和普通函数、普通类存在一定区别。
例如:
template<typenameT>classStack{public:voidPush(constT&data);};成员函数在类外定义时:
template<typenameT>voidStack<T>::Push(constT&data){// ...}在实际使用模板时,通常不建议简单按照普通代码的方式,把模板声明放在.h中,再把模板定义单独放进.cpp中,否则可能出现链接错误。
当前阶段先记住这个使用特点即可:
模板的定义通常需要和模板声明放在一起,使模板在实例化时能够看到完整定义。
模板为什么会产生这种现象,与模板实例化以及 C++ 的编译、链接过程有关,可以在后续继续深入理解。
本节小结
模板与普通函数、普通类在代码组织上有所不同。
当前阶段最重要的是知道:
普通代码 → 经常声明、定义分离 模板代码 → 通常让模板定义保持可见不要简单照搬普通类.h + .cpp的组织方式。
八、总结
回过头来看,模板要解决的核心问题其实非常简单:
当代码逻辑不变,只有数据类型发生变化时,不应该为每一种类型重复编写一份代码。
C++ 通过模板把“类型”也抽象成参数,从而实现泛型编程。
函数模板解决的是函数逻辑的复用:
函数模板 ↓ 模板参数推导 / 指定 ↓ 模板实例化 ↓ 得到具体函数其中模板参数可以通过两种方式确定:
隐式实例化 → 编译器根据实参推导 显式实例化 → 程序员主动指定如果普通函数和函数模板同时存在,则需要比较匹配程度:
多个候选函数 ↓ 比较匹配程度 ↓ 选择更加合适的版本普通函数和函数模板匹配程度相同时,优先使用普通函数。
类模板则把相同的思想从函数扩展到了类:
类模板 ↓ 指定具体类型 ↓ 模板实例化 ↓ 得到具体类型例如:
Stack → 类模板 Stack<int> → 具体类型 Stack<double> → 另一个具体类型因此,整个模板初阶的知识可以压缩成下面这张图:
泛型编程 │ 编写与具体类型无关的代码 │ ▼ 模板 │ ┌────────────┴────────────┐ ▼ ▼ 函数模板 类模板 │ │ 确定模板参数 指定类型 │ │ ┌───────┴───────┐ │ ▼ ▼ │ 隐式推导 显式指定 │ │ │ │ └───────┬───────┘ │ ▼ ▼ 模板实例化 模板实例化 │ │ ▼ ▼ 具体函数 具体类型如果以后回来复习这一部分,不需要先去背template<typename T>的语法。
先回忆一句话:
模板的本质,是先编写一份与具体类型无关的通用代码,再根据实际使用的类型实例化出具体代码。
围绕这句话,再向下回忆:
为什么需要模板? ↓ 减少只有类型不同的重复代码 函数模板怎么使用? ↓ 模板参数 + 模板实例化 T 怎么确定? ↓ 隐式推导 / 显式指定 多个函数都能调用怎么办? ↓ 比较匹配程度 类怎么实现泛型? ↓ 类模板 Stack 和 Stack<int> 有什么区别? ↓ 一个是类模板,一个是具体类型把这条主线建立起来,函数模板、模板实例化、参数匹配和类模板就不再是几个零散的语法点,而是一套完整的泛型编程逻辑。