🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然
文章目录
- 前言
- 一、引言:函数重载的痛点与模板的诞生
- 二、泛型编程:模板的核心思想
- 三、函数模板:通用函数的实现方案
-
- 1. 函数模板概念
- 2. 函数模版的格式
- 3.函数模板的工作原理
- 4.函数模板的实例化(隐式 vs 显式)
- 5.模板参数的匹配原则
- 四、类模板:通用类的设计思路
-
- 1.类模板的定义格式
- 2.类模板的成员函数实现
- 3.类模板的实例化(关键区别)
- 4. 类模板的常见问题:声明与定义分离
- 总结
前言
在C语言中,我们经常会遇到 “逻辑相同但类型不同” 的代码场景 —— 比如实现交换两个变量的值、计算两个数的和等等诸如此类的问题,在C++中我们可以通过函数重载解决,但是却过于复杂并且没有和C语言有所重大的不同,但是自从C++有了模版以后,便让当时的计算机大佬们感受到C++的魅力。今天就带大家走进 C++ 模板的世界,看看它如何用 “通用模具” 优雅解决这类问题,开启泛型编程的大门。
一、引言:函数重载的痛点与模板的诞生
先从一个简单的需求入手:实现 “交换两个变量” 的函数。如果用函数重载,代码会是这样的:
// 交换int类型
void Swap(int& left, int& right) {
int temp = left;
left = right;
right = temp;
}
// 交换double类型
void Swap(double& left, double& right) {
double temp = left;
left = right;
right = temp;
}
// 交换char类型
void Swap(char& left, char& right) {
char temp = left;
left = right;
right = temp;
}
// 后续要交换float、long...还要继续加重载函数...
这段代码看似完成了需求,但是有两个致命问题:
1.代码复用率低: 只要新增一种类型(比如 float),就必须手动写一个对应的重载函数,重复劳动
2.可维护性差: 如果交换逻辑需要修改(比如加个日志),所有重载函数都要改,漏改一个就会出 bug(一个出错可能所有的重载均出错)
这时我们会想:能不能给编译器一个 “模具”,让它根据不同类型自动生成对应的代码?
如果在C++中,也能够存在这样一个模具,通过给这个模具中填充不同材料(类型),来获得不同材料的铸件(即生成具体类型的代码),那将会节省许多头发。巧的是前人早已将树栽好,我们只需在此乘凉。
C++ 的模板就是为了解决这个问题而生的 —— 它让我们写出 “与类型无关的通用代码”,把重复工作交给编译器,这就是泛型编程的核心思想。
二、泛型编程:模板的核心思想
泛型编程:编写与具体类型无关的通用代码,是代码复用的高级手段。而模板是泛型编程的 “基础设施”,分为两类:
1.函数模板:生成通用函数的模具
2.类模板:生成通用类的模具
形象点说,模板就像 “浇筑模具”—— 我们给模具填入不同的 “材料”(类型),编译器就会自动浇筑出不同的 “铸件”(具体类型的代码),从此告别重复手写!
三、函数模板:通用函数的实现方案
1. 函数模板概念
函数模板代表一个函数家族,它与类型无关,在使用时通过 “参数化”(指定类型),生成对应类型的具体函数
2. 函数模版的格式
template<typename T1, typename T2,…,typename Tn>
返回值类型 函数名(参数列表) { }
template <typename T1, typename T2, ..., typename Tn> // 模板参数列表
返回值类型 函数名(参数列表) {
// 函数逻辑(与类型无关)
}
template:声明 “这是模板” 的关键字
typename:定义模板参数的关键字(也可以用class,但不能用struct)
T1、T2:模板参数(相当于 “类型占位符”,后续用具体类型替换)
那么我们知道了模板的格式,下面我们来小试牛刀,用函数模板重写Swap:
// 通用交换模板(支持所有可赋值类型)
template <typename T> // T是模板参数,代表“任意类型”
void Swap(T& left, T& right) { // 参数类型用T表示
T temp = left; // 临时变量类型也用T
left = right;
right = temp;
}
这样一来,不管是int、double还是char,一个模板就能搞定,再也不用写一堆重载函数!
3.函数模板的工作原理
很多初学者会误以为 “函数模板是一个能处理所有类型的函数”—— 这是错的!
这里我们通过调用int和double类型的swap的汇编看下:
template<class T>
void Swap(T&x,T&y)
{
T tmp=x;
x=y;
y=tmp;
}
int main()
{
int i=1,j=2;
double m=1.1,n=2.2;
Swap(i,j);//调用1:实参是int,编译器推演T=int,生成Swap(int&, int&)
Swap(m,n);// 调用2:实参是double,编译器推演T=double,生成Swap(double&, double&)
}
我们发现在这里调用的并不是同一个函数,这里的两个函数是编译器帮我们生成的
总结:
函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。
所以其实模板就是将本来应该我们做的重复的事情交给了编译器
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。
编译器的工作流程如下:
1.编译阶段:当我们调用模板函数时(比如Swap(a, b),a是int类型),编译器会先推演模板参数类型
2.生成代码:根据推演的类型(比如int),编译器自动生成一份 “处理该类型” 的具体函数(相当于手动写的Swap(int&, int&))
3.调用函数:最终程序运行时,调用的是编译器生成的具体函数,而非模板本身
4.函数模板的实例化(隐式 vs 显式)
“用不同类型调用函数模板” 的过程,称为函数模板的实例化。根据 “是否手动指定类型”,分为两种方式:
- 隐式实例化:让编译器根据实参推演模板参数的实际类型
- 显式实例化:在函数名后的<>中指定模板参数的实际类型
下面我们先来看下隐式实例化:译器根据传入的实参类型,自动推演模板参数T的具体类型
比如实现一个 “加法函数模板”:
template <class T> // 这里用class代替typename,效果一样
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.5, d2 = 20.5;
// 隐式实例化:编译器根据实参类型推演T
Add(a1, a2); // T=int,生成Add(int, int)
Add(d1, d2); // T=double,生成Add(double, double)
return 0;
}
注意一个坑:如果实参类型不统一,编译器无法推演T,会直接报错!比如:
Add(a1, d1); // 错误!a1是int,d1是double,编译器不知道T该设为int还是double
编译器不会自动做类型转换(怕出 bug 背锅),解决方法有两种:
1.手动强制类型转换:Add(a1, (int)d1);(将d1转为 int)
2.使用显式实例化。
第二种便是显式实例化:手动指定类型
显式实例化不需要编译器推演 —— 我们在函数名后加<类型>,直接指定模板参数T的具体类型。
格式:函数名<具体类型>(实参列表);
比如解决上面 “int 和 double 混合加法” 的问题:
int main() {
int a = 10;
double b = 20.5;
// 显式实例化:指定T=int,编译器会尝试将b转为int
Add<int>(a, b); // 结果是30(20.5被截断为20)
// 也可以指定T=double,将a转为double
Add<double>(a, b); // 结果是30.5
return 0;
}
如果类型无法转换(比如Add(a, “hello”)),编译器会直接报错。
这里只是为了介绍实例化的两种类型,在真正实现add模板的时候,为了方便我们不同类型相加会多写一个模板如下:
template<class T1,class T2>
void func(const T1& x,const T2& y)
{
}
5.模板参数的匹配原则
当 “非模板函数” 和 “同名的函数模板” 同时存在时,编译器会怎么选择?记住 3 个原则:
原则1:非模板函数和模板函数可以共存
模板可以被实例化为与非模板函数完全相同的版本,比如:
// 非模板函数:专门处理int
int Add(int left, int right) {
cout << "非模板函数:";
return left + right;
}
// 函数模板:通用加法
template <class T>
T Add(T left, T right) {
cout << "模板函数:";
return left + right;
}
void Test() {
Add(1, 2); // 调用非模板函数(完全匹配,不用实例化模板)
Add<int>(1, 2); // 调用模板实例化的Add(int, int)(手动指定类型)
}
原则 2:优先调用非模板函数,除非模板匹配更好
如果非模板函数不完全匹配,但模板能生成更匹配的版本,编译器会选择模板:
// 非模板函数:只处理int
int Add(int left, int right) {
cout << "非模板函数:";
return left + right;
}
// 模板函数:支持两种不同类型(T1和T2)
template <class T1, class T2>
T1 Add(T1 left, T2 right) {
cout << "模板函数(多参数):";
return left + right;
}
void Test() {
Add(1, 2); // 非模板完全匹配,调用非模板
Add(1, 2.5); // 模板能生成Add(int, double),匹配更好,调用模板
}
原则 3:模板不支持自动类型转换,普通函数支持
函数模板的参数类型必须严格匹配(除非显式实例化),但普通函数可以自动做类型转换:
void Test() {
// 普通函数:int和double可以自动转换(2.5→2)
Add(1, 2.5); // 调用非模板Add(int, int),结果3
// 模板函数:T必须统一,不能自动转换(1是int,2.5是double)
Add<>(1, 2.5); // 错误!无法推演统一的T
}
四、类模板:通用类的设计思路
除了函数,类也会有 “通用逻辑但不同类型” 的场景 —— 比如栈(Stack)、链(LinkedList),既可以存 int,也可以存 double、string。这时就需要类模板
1.类模板的定义格式
类模板的定义和函数模板类似,先声明模板参数,再定义类:
template <class T1, class T2, ..., class Tn> // 模板参数列表
class 类模板名 {
// 类内成员(成员变量/函数的类型可以用模板参数)
};
以 “通用栈” 为例,实现一个类模板:
#include <iostream>
using namespace std;
// 类模板:通用栈
template <typename T> // T是栈中元素的类型
class Stack {
public:
// 构造函数:默认容量4
Stack(size_t capacity = 4)
: _array(new T[capacity]) // 动态开辟T类型数组
, _capacity(capacity)
, _size(0) {}
// 成员函数声明(定义可以在类内,也可以在类外)
void Push(const T& data); // 入栈:数据类型是T
void Pop(); // 出栈
T& Top(); // 获取栈顶:返回T类型引用
~Stack() { // 析构函数:释放内存
delete[] _array;
_capacity = _size = 0;
}
private:
T* _array; // 指向T类型数组的指针
size_t _capacity; // 栈的容量
size_t _size; // 栈的当前元素个数
};
2.类模板的成员函数实现
类模板的成员函数如果在类外定义,必须加 “模板参数声明”,并且在类名后指定模板参数
格式:
template <class T> // 重复模板参数声明
//返回值类型 类模板名<T>::成员函数名(参数列表) {
// 函数逻辑
}
比如实现Push和Top函数:
// 入栈函数:类外定义
template <class T>
void Stack<T>::Push(const T& data) {
// 简单扩容逻辑(省略判断,实际项目需完善)
if (_size == _capacity) {
T* newArray = new T[_capacity * 2];
for (size_t i = 0; i < _size; ++i) {
newArray[i] = _array[i];
}
delete[] _array;
_array = newArray;
_capacity *= 2;
}
// 存入数据
_array[_size++] = data;
}
// 获取栈顶函数:类外定义
template <class T>
T& Stack<T>::Top() {
// 实际项目需判断栈是否为空
return _array[_size - 1];
}
3.类模板的实例化(关键区别)
类模板的实例化和函数模板完全不同:
函数模板支持隐式实例化(编译器推演类型),但类模板必须显式实例化—— 因为编译器无法从构造函数的参数推断模板参数(比如Stack s(10),不知道T是 int 还是 double)
格式:类模板名<具体类型> 对象名(构造参数)
比如使用上面的 Stack 模板:
int main() {
// 显式实例化:Stack<int>是真正的类类型,st1是该类型的对象
Stack<int> st1; // 存储int类型的栈
st1.Push(10);
st1.Push(20);
cout << "栈顶:" << st1.Top() << endl; // 输出20
// 显式实例化:存储double类型的栈
Stack<double> st2;
st2.Push(3.14);
st2.Push(6.28);
cout << "栈顶:" << st2.Top() << endl; // 输出6.28
// 错误写法:类模板不能隐式实例化
// Stack st3; // 编译器不知道T是什么类型
return 0;
}
重要概念:Stack是类模板名(不是真正的类),Stack、Stack才是真正的类类型—— 这就像 “模具” 和 “铸件” 的区别,模具本身不是产品,铸件才是
4. 类模板的常见问题:声明与定义分离
很多初学者会把类模板的 “声明放在.h 文件,定义放在.cpp 文件”,结果编译通过但链接报错。原因是:
1.编译.cpp文件时,编译器不知道用户会用什么类型实例化模板,所以不会生成具体的成员函数实现
2.编译使用模板的文件(比如main.cpp)时,包含.h文件只能看到类声明,链接时找不到成员函数的具体实现,导致报错。
解决方案:
1.将类模板的声明和定义都放在.h 文件中(推荐,简单直接)
2.用.hpp文件(专门用于模板,将声明和定义合并)
总结
模板的核心优势:
1.代码复用:一个模板搞定所有相似类型的逻辑,不用重复写重载 / 重复类;
2.可维护性:修改逻辑只需改模板,不用改所有派生代码;
3.类型安全:编译时推演类型,避免运行时类型错误
初学者必记的注意事项:
1.函数模板可以隐式实例化(编译器推类型),类模板必须显式实例化(手动指定类型);
2.typename和class都能定义模板参数,但不能用 struct;
3.类模板的声明和定义不要分离到.h 和.cpp(会报链接错误),建议放同一个.h 或.hpp;
4.函数模板不支持自动类型转换(除非显式实例化),普通函数支持。
模板是 C++ 的核心特性之一,也是 STL(标准模板库)的基石。掌握模板,不仅能写出更优雅的代码,也能更好地理解 STL 的实现原理。下一篇我们会深入模板的进阶特性(模板特化、可变参数模板等),敬请期待!
往期回顾:
1.从 C 转 C++?先吃透这些语法升级点:重载 / 引用 / 内联函数详解
2.C++ 类与对象避坑指南上:默认成员函数 /this 指针常见误区拆解(附日期类小项目)
3.C++ 类与对象进阶下:从初始化列表到编译器优化,吃透 7 大核心特性
4.【C/C++ 面试必看】深入理解内存管理:从内存分布到 new/delete 底层原理
如果本文对你有帮助,欢迎点赞 + 收藏,有问题也可以在评论区留言~