C++ 模板初阶:从重复代码到泛型编程
C++ 模板初阶:从重复代码到泛型编程
前言
刚开始写 C++ 时,我们很容易遇到一种尴尬情况:逻辑明明一模一样,只是类型不同,代码却要写好几遍。
比如交换两个变量,int要写一份,double要写一份,char可能还要再写一份。写的时候感觉只是复制粘贴,后面维护时就不一定这么舒服了。万一某个版本里写错了,其他版本还得挨个检查。
模板要解决的就是这类问题:把和类型无关的逻辑抽出来,让编译器根据实际类型去生成对应代码。简单说,模板不是让程序运行时变“万能”,而是让编译器在编译阶段帮我们少写重复代码。
这篇先整理模板入门阶段最核心的几块:
| 学习点 | 先抓住什么 |
|---|---|
| 泛型编程 | 为什么“类型不同但逻辑相同”的代码不适合一直重载 |
| 函数模板 | 怎么写出一份通用函数逻辑 |
| 模板实例化 | 编译器什么时候把模板变成具体函数 |
| 匹配规则 | 普通函数和函数模板同名时,编译器怎么选 |
| 类模板 | 怎么让类的数据类型也变得通用 |
先把这些基础问题理顺,后面再看 STL、容器、迭代器、仿函数这些内容,会轻松很多。
一、先从重复代码说起
如果没有模板,我们想写一个通用的交换函数,最直观的办法是函数重载:
voidSwapValue(int&left,int&right){inttmp=left;left=right;right=tmp;}voidSwapValue(double&left,double&right){doubletmp=left;left=right;right=tmp;}voidSwapValue(char&left,char&right){chartmp=left;left=right;right=tmp;}这段代码能用,但问题也很明显:
- 逻辑高度重复,只是类型不一样。
- 新增一个类型,就可能要新增一个函数。
- 一旦交换逻辑要改,每个重载版本都要跟着改。
所以这里真正需要的不是“更多重载”,而是一个能描述通用逻辑的模子。
这就是泛型编程的思想:写出和具体类型无关的代码,让同一份逻辑适配不同类型。C++ 里的模板,就是泛型编程最基础也最重要的工具。
二、函数模板:先写一个“函数模子”
函数模板的基本格式是:
template<typenameT>返回值类型 函数名(参数列表){// 函数体}typename后面的T表示一个模板类型参数。这里的T不是固定类型,它更像一个占位符,等到真正调用函数时,编译器再根据实参类型把它替换成具体类型。
用模板改写前面的交换函数:
template<typenameT>voidSwapValue(T&left,T&right){T tmp=left;left=right;right=tmp;}调用时可以这样写:
inta=10;intb=20;SwapValue(a,b);// T 被推导为 intdoublex=1.1;doubley=2.2;SwapValue(x,y);// T 被推导为 double这时我们写的只有一份模板代码,但编译器会根据实际调用生成对应类型的函数版本。
这里补一个小细节:定义模板类型参数时,typename和class都可以用。
template<classT>voidPrint(constT&value){cout<<value<<endl;}在这种场景下,class并不是说T必须是类类型,内置类型也可以。只是关键字写法不同而已。不过不能把这里的class换成struct。
三、模板本身不是函数
这一点很重要:函数模板本身不是一个真正能被调用的函数。
它更像一张图纸。只有当我们用具体类型去调用它时,编译器才会根据这张图纸生成一份真正的函数代码。
比如:
template<typenameT>TSum(T lhs,T rhs){returnlhs+rhs;}intmain(){Sum(1,2);// 生成 int Sum(int, int)Sum(1.5,2.5);// 生成 double Sum(double, double)}编译器看到Sum(1, 2),会推导出T是int,于是生成一份处理int的函数。
看到Sum(1.5, 2.5),又会推导出T是double,于是生成一份处理double的函数。
所以模板帮我们省下的,是手动重复写这些函数的工作。真正的代码生成,发生在编译阶段。
四、函数模板的实例化
用具体类型使用函数模板的过程,叫做模板实例化。
函数模板常见的使用方式有两种:让编译器隐式推导类型,或者由我们显式指定模板参数。
1. 隐式实例化
隐式实例化就是让编译器自己根据实参推导类型。
template<typenameT>TSum(T lhs,T rhs){returnlhs+rhs;}intmain(){inta=10;intb=20;Sum(a,b);// T 推导为 intdoublex=1.1;doubley=2.2;Sum(x,y);// T 推导为 double}这种写法最自然,也是平时最常见的用法。
但隐式推导有一个容易踩的点:如果同一个模板参数从不同实参里推导出了不同类型,编译器就不知道该听谁的。
inta=10;doubleb=2.5;Sum(a,b);// 这里会出问题a希望T是int,b希望T是double。可模板参数列表里只有一个T,编译器不能擅自替我们决定,所以这类调用通常会编译失败。
这不是编译器“不会变通”,而是模板推导阶段本来就比较严格。它要先把类型推导清楚,不能一边推导一边随便做类型转换。
2. 显式指定模板参数
如果我们就是想指定模板参数类型,可以在函数名后面加<>。
Sum<int>(a,b);这表示明确告诉编译器:这次T就按int处理。
于是b会尝试转换成int后再参与调用。如果转换不合法,还是会报错。
也可以自己先做强制类型转换:
Sum(a,static_cast<int>(b));这两种方式都能解决“一个T推导出多个类型”的问题。区别在于,一个是显式指定模板参数,一个是先把实参类型处理一致。
很多入门资料会把这种调用方式也放在“显式实例化”里讲,复习时知道它想表达的是“模板参数由我们明确给出”就可以了。
五、模板参数匹配时的几个规则
函数模板和普通函数可以同名存在,这也是初学时比较容易绕的地方。
先看一段代码:
intSum(intlhs,intrhs){returnlhs+rhs;}template<typenameT>TSum(T lhs,T rhs){returnlhs+rhs;}intmain(){Sum(1,2);Sum<int>(1,2);}第一句Sum(1, 2)会优先调用普通函数,因为普通函数已经能完全匹配,编译器没必要再用模板生成一份一样的函数。
第二句Sum<int>(1, 2)明确写了模板参数,所以会调用模板生成的版本。
再看另一种情况:
intSum(intlhs,intrhs){returnlhs+rhs;}template<typenameT1,typenameT2>autoSum(T1 lhs,T2 rhs){returnlhs+rhs;}intmain(){Sum(1,2);// 普通函数完全匹配Sum(1,2.5);// 模板可以生成更合适的版本}Sum(1, 2.5)如果调用普通函数,就需要把2.5转成int,这会发生类型转换。
而函数模板可以直接生成类似Sum<int, double>的版本,匹配程度更高,所以编译器会选择模板。
这里可以简单记成三句话:
- 普通函数和函数模板可以同名。
- 如果普通函数和模板实例化出来的函数一样合适,优先调用普通函数。
- 如果模板能生成更匹配的版本,就会选择模板。
还有一个细节也要记住:模板参数推导时通常不会主动做普通类型转换。显式指定模板参数以后,函数调用阶段才可能发生可行的类型转换。
六、类模板:让类也能和类型解耦
函数可以写模板,类当然也可以。
类模板适合用在这种场景:类的整体逻辑一样,只是内部存储的数据类型不同。
比如一个简单的栈,存int、存double、存自定义对象,本质操作都是入栈、出栈、取栈顶。区别只是元素类型。
函数模板和类模板可以先这样区分:
| 对比点 | 函数模板 | 类模板 |
|---|---|---|
| 解决的问题 | 函数逻辑重复 | 类结构和成员操作重复 |
| 生成结果 | 具体类型的函数 | 具体类型的类 |
| 常见写法 | Sum(1, 2)或Sum<int>(1, 2) | Stack<int> s; |
| 初学重点 | 模板参数推导和匹配规则 | 类名后面要带具体类型 |
类模板的基本格式是:
template<typenameT>class类名{// 成员变量和成员函数};写一个简化版栈:
#include<cstddef>template<typenameT>classStack{public:Stack(std::size_t cap=8):_data(newT[cap]),_cap(cap),_size(0){}~Stack(){delete[]_data;}voidPush(constT&value);private:T*_data;std::size_t _cap;std::size_t _size;};如果成员函数在类外定义,写法要注意两点:
- 前面仍然要带模板参数列表。
- 类名后面要写上模板参数。
template<typenameT>voidStack<T>::Push(constT&value){if(_size==_cap){// 这里先省略扩容逻辑,重点看模板写法return;}_data[_size]=value;++_size;}Stack<T>::Push里的Stack<T>不能写成单纯的Stack。因为Stack只是类模板名,Stack<int>、Stack<double>这种实例化结果才是真正的类型。
七、类模板的实例化
类模板和函数模板有一个明显区别:类模板通常不能只靠构造对象时的参数自动推导出来,基础写法里需要在类名后面写上具体类型。
Stack<int>s1;Stack<double>s2;这里的Stack<int>才是一个真正的类型,表示“存放int的栈”。
Stack<double>也是一个真正的类型,表示“存放double的栈”。
它们来自同一个类模板,但实例化之后是两个不同类型。
所以不要把Stack和Stack<int>混成一回事:
Stack<int>s1;// 正确Stack<double>s2;// 正确模板名只是模子,带上具体类型后,才得到能创建对象的类。
八、模板为什么不建议声明和定义分离
普通类的成员函数经常可以声明放在.h,定义放在.cpp。
但模板不太一样。模板代码需要在编译阶段根据具体类型生成代码。如果编译器在使用模板时只看到了声明,看不到定义,就没办法完成实例化,后面很容易出现链接错误。
所以实际写模板时,常见做法是:
- 函数模板直接写在头文件里。
- 类模板的成员函数定义也放在头文件里。
- 或者使用
.hpp这类文件专门放模板实现。
入门阶段先记住这个结论就够了:模板不是普通函数的简单替代品,它依赖编译期实例化,因此定义通常要让使用它的编译单元看得见。
九、这一部分怎么串起来
模板初阶可以按这条线理解:
重复代码太多 -> 提出泛型编程 -> 用函数模板描述通用函数逻辑 -> 编译器根据实参类型实例化具体函数 -> 理解隐式推导和显式指定模板参数 -> 搞清楚模板函数和普通函数的匹配规则 -> 用类模板描述通用数据结构如果只背语法,很容易写着写着就乱了。我的建议是先抓住一句话:模板就是把“类型不同但逻辑相同”的代码交给编译器生成。
理解了这句话,template <typename T>、Sum<int>、Stack<double>这些写法就不是孤立语法了,它们都是在告诉编译器:请按这个类型,把模子变成真正能用的代码。
小结
这篇主要整理了 C++ 模板入门阶段的几个基础点。
函数模板解决的是函数逻辑重复的问题。它本身不是函数,而是生成函数的模子。使用时可以让编译器根据实参进行隐式推导,也可以通过函数名<类型>的方式显式指定模板参数。
模板匹配时,普通函数和函数模板可以同名存在。完全匹配时普通函数优先;如果模板能生成更合适的版本,编译器也会选择模板。模板参数推导阶段一般不会随便做类型转换,这一点在混合类型调用时尤其要注意。
类模板解决的是类型不同但类结构和操作相同的问题。Stack是类模板名,Stack<int>才是具体类型。类模板的成员函数如果写在类外,需要带上template <typename T>,并且使用Stack<T>::指明作用域。
模板刚学的时候看起来有点绕,但它的出发点其实很朴素:少写重复代码,把类型变化交给编译器处理。后面学习 STL 时,会发现容器、算法、迭代器这些东西都离不开这个基础。
