C++ 6:模板初阶
前面我们写过 Stack、Date,也搞懂了对象在内存里怎么生怎么死。但有个问题一直没解决:同一套逻辑,换个类型就得重写一遍。
这一篇讲 C++ 的「代码模具」——模板,以及它背后的泛型编程。
1. 泛型编程
1.1 先看一个痛点
想写一个交换两个变量的函数,于是有了:
void Swap(int& a, int& b) { int tmp = a; a = b; b = tmp; }
void Swap(double& a, double& b) { double tmp = a; a = b; b = tmp; }
void Swap(char& a, char& b) { char tmp = a; a = b; b = tmp; }
三个函数除了类型,其他一模一样。这种写法有三个毛病:
| 毛病 | 具体表现 |
|---|---|
| 代码冗余 | 同样的逻辑抄了三遍 |
| 不好维护 | 逻辑要改,得改三处,漏一处就埋 bug |
| 无法扩展 | 来了新类型(比如 float、自定义类),还得再抄一遍 |
💡 你可能会想到
typedef或者auto——但 C 语言里typedef只能固定一种类型,auto也不能让一个函数同时服务多种类型。
1.2 换个思路:给编译器一个「模具」
真正该复用的不是「代码」,而是**「生成代码的规则」**:
我只写一遍逻辑,把「类型」空出来当参数;编译器看我调用时传了什么类型,就自动帮我印出对应版本。
这就是泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。
💡 白话类比:模板是做月饼的模具。花纹(逻辑)只有一套,换什么馅(类型)就出什么口味的月饼,不用为每种馅重新刻一个模子。
一句话记住关系:泛型编程是思想,模板是 C++ 里实现它的工具。
2. 函数模板
2.1 函数模板概念
函数模板不是一个真正的函数,而是一个函数家族的图纸:它与具体类型无关,使用时才被「参数化」,编译器根据实参类型生成该类型的具体版本。
函数模板(一份图纸)
│ 编译器按实参类型「印」
┌──────┼──────┐
Swap(int) Swap(double) Swap(char)
⚠️ 注意这个认知:模板本身不占内存、也不是函数,真正被调用的是编译器生成出来的那些具体函数(原理见 2.3)。
2.2 函数模板格式
template<typename T> // ① 模板参数列表:声明一个「类型参数」T
void Swap(T& a, T& b) // ② 用 T 代替原来写死的类型
{
T tmp = a;
a = b;
b = tmp;
}
逐条说:
| 部分 | 说明 |
|---|---|
template<...> | 告诉编译器「下面是个模板」,必须紧贴函数 |
typename T | 声明类型参数 T;typename 也可以写 class,两者这里完全等价 |
T | 类型参数名,随便起(习惯 T、Ty、K、V),只在后面这个函数里有效 |
| 多个参数 | template<typename T1, typename T2>,用逗号隔开 |
template<class T> // ✅ class 和 typename 在这里一样
void Swap(T& a, T& b) { /* ... */ }
// struct 不行:
template<struct T> // ❌ 报错
用起来就跟普通函数一样,不用手动告诉它类型:
int main()
{
int a = 1, b = 2;
double d1 = 1.1, d2 = 2.2;
Swap(a, b); // 编译器推出 T = int,生成 Swap<int>
Swap(d1, d2); // 编译器推出 T = double,生成 Swap<double>
return 0;
}
两个坑先记下:
⚠️ ① 一个
T推不出两种类型:Swap(a, d1); // ❌ a 是 int、d1 是 double,T 到底是啥?编译器直接报错解法(显式实例化 / 两个模板参数 / 强转)放到 2.5 模板参数的匹配原则里讲。
⚠️ ② 模板定义一般写在头文件里:编译器必须看到完整代码才能「按类型印函数」,只放声明会导致链接错误。为什么,2.3 讲原理时一并解释。
2.3 函数模板的原理
先纠正一个最容易有的误解:
❌ 以为模板是「一个函数,运行时判断传进来的是什么类型」
✅ 实际上是「编译期就按顺序生成了好几个不同的函数」
看这段代码:
template<typename T>
void Swap(T& a, T& b) { T tmp = a; a = b; b = tmp; }
int main()
{
int a = 1, b = 2;
double d1 = 1.1, d2 = 2.2;
char c1 = 'a', c2 = 'b';
Swap(a, b); // → 编译器印出 Swap<int>
Swap(d1, d2); // → 编译器印出 Swap<double>
Swap(c1, c2); // → 编译器印出 Swap<char>
}
编译完之后,效果等价于编译器替你写了三份函数:
void Swap(int& a, int& b) { int tmp = a; a = b; b = tmp; }
void Swap(double& a, double& b) { double tmp = a; a = b; b = tmp; }
void Swap(char& a, char& b) { char tmp = a; a = b; b = tmp; }
调用时走的是三个不同的函数,不是一个函数内部做类型判断。
💡 白话版:模具不是月饼,压出来的才是月饼。模板本身不占内存、也不参与运行,干活的是编译器按图纸印出来的具体函数。
两个伏笔在这里收掉:
① 为什么模板定义要放头文件?
因为编译器必须在调用点看到完整代码才能「按类型印函数」。如果 .h 里只放声明、定义藏在 .cpp 里,编译 main.cpp 时编译器看不到模板长什么样,就生成不出 Swap<int> → 链接报错。
② 为什么 Swap(a, d1) 会报错?
推导阶段,编译器需要为一个 T 找到唯一的类型。你一个参数传 int、一个传 double,它没法决定 T 是啥,直接罢工(解法见 2.4 和 2.5)。
⚠️ 顺带提一句:编译器读模板时只做基本语法检查,跟
T相关的错误要等实例化之后才暴露。所以「模板写好了、一次没调用过」不代表它一定没问题。
2.4 函数模板的实例化
实例化:用具体类型去使用函数模板、让编译器生成该类型版本的过程。分两种。
隐式实例化(最常用)
让编译器自己从实参推导类型,前面一直在用的就是它:
Swap(a, b); // 实参是 int → 推出 T = int → 生成 Swap<int>
⚠️ 但有个前提:T 得出现在函数参数列表里,编译器才有线索去推。
template<typename T>
T* Alloc(int n) { return new T[n]; }
int* p = Alloc(10); // ❌ 参数只有 int,T 完全没露面,推不出来
显式实例化
推不出来(或不想让它推)时,在函数名后面直接加 <类型>:
int* p = Alloc<int>(10); // ✅ 明确告诉编译器 T = int
Swap<int>(a, b); // ✅ 也合法,只是没必要
显式实例化还有一个很关键的效果:指定 T 之后,函数参数类型就定死了,实参会按普通函数的规则做隐式类型转换(推导阶段可是不允许随便转的):
template<typename T>
T Add(const T& a, const T& b) { return a + b; }
Add(1, 2); // ✅ 推导 T = int,结果 3
Add(1, 2.2); // ❌ 推导冲突:T 是 int 还是 double?
Add<int>(1, 2.2); // ✅ 不推导了!2.2 被转成 int(2),结果 3
Add<double>(1, 2.2); // ✅ 1 被转成 double(1.0),结果 3.2
这也正好是 2.2 里那个 Swap(a, d1) 冲突的解法之一——用显式实例化强行统一类型(另外两种解法放到 2.5 讲)。
💡 一句话区分:
- 隐式实例化:编译器猜类型,猜不唯一就报错;
- 显式实例化:你指定类型,之后允许普通的类型转换。
灵魂测试题
template<typename T>
T Add(const T& a, const T& b) { return a + b; }
int main()
{
cout << Add(1, 2) << endl; // ①
cout << Add(1, 2.2) << endl; // ②
cout << Add<int>(1, 2.2) << endl; // ③
}
问: 哪一行编译不过?③ 输出的是 3 还是 3.2?
- ① 输出
3:推导出T = int; - ② 编译报错:一个
T推不出两种类型(int 和 double 冲突); - ③ 输出
3:显式指定T = int后不再推导,2.2被截断成2,1 + 2 = 3。
⚠️ 注意
3不是3.2:显式实例化会发生隐式类型转换,double 转 int 会丢小数。这也是它容易被忽略的副作用。
小结
- 模板在编译期按实参类型生成多个具体函数,不是运行时判断类型;
- 编译器要在调用点看到完整定义 → 模板一般写在头文件里;
- 隐式实例化靠推导,
T必须出现在参数里、且只能推出唯一类型; - 显式实例化写
函数名<类型>(...),指定后允许普通隐式转换。
2.5 模板参数的匹配原则
当普通函数和同名的函数模板同时存在时,编译器该选谁?先看一段代码:
// 普通函数:只处理 int
int Add(int a, int b)
{
cout << "普通函数" << endl;
return a + b;
}
// 函数模板:通用版
template<typename T>
T Add(T a, T b)
{
cout << "模板" << endl;
return a + b;
}
它俩可以同时存在、构成重载。三个调用,三种结果:
Add(1, 2); // ① 普通函数
Add(1.1, 2.2); // ② 模板
Add(1, 2.2); // ③ 普通函数
三条规则
| 规则 | 说明 |
|---|---|
| ① 条件相同时,优先普通函数 | ① 里 Add(int,int) 完全匹配,编译器懒得再实例化模板,直接用现成的 |
| ② 模板能生成「更匹配」的版本,就选模板 | ② 里模板生成 Add<double> 完全匹配;普通函数要把两个 double 都转成 int,明显更差 |
| ③ 模板推导不出来,普通函数仍能靠隐式转换顶上 | ③ 里一个 int 一个 double,T 推不出唯一类型 → 模板出局;普通函数允许 2.2 → 2,于是调它(结果是 3) |
💡 一句话:编译器先挑「最不用转换」的那个;一样好就优先现成的普通函数。
⚠️ 要和第 2.4 节连起来理解:这里说的「模板不允许自动类型转换」,指的是推导
T的阶段——编译器不会为了凑出同一个T而去把double转成int。
但如果用显式实例化Add<int>(1, 2.2),T已经定死了,之后参数转换就按普通函数规则来(2.4 讲过,结果是3)。两者不矛盾。
回收:Swap(a, d1) 冲突的另外两种解法
2.2 留了个坑:一个 T 推不出两种类型。除了 2.4 的显式实例化,还有两条路:
解法二:改成多个模板参数
template<typename T1, typename T2>
T1 Add(T1 a, T2 b) { return a + b; } // 各推各的,不冲突
Add(1, 2.2); // ✅ T1 = int, T2 = double
⚠️ 但要注意:返回值写 T1 的话,1 + 2.2 = 3.2 会被截断成 3。想保留精度,得写成两个模板参数 + 尾置返回类型(auto ... -> decltype(a + b),进阶内容,了解即可)。
解法三:手动统一实参类型
Add(1, (int)2.2); // 都变 int
Add((double)1, 2.2); // 都变 double
💡 但对
Swap来说,交换两个不同类型的变量本身就不太合理(会截断),所以实战里更常见的是「别混着传」,而不是硬凑类型。
灵魂测试题
int Add(int a, int b) { cout << "N"; return a + b; }
template<typename T>
T Add(T a, T b) { cout << "T"; return a + b; }
template<typename T1, typename T2>
T1 Add(T1 a, T2 b) { cout << "TT"; return a + b; }
int main()
{
Add(1, 2); // ①
Add(1, 2.2); // ②
Add(1.1, 2.2); // ③
Add<int>(1, 2); // ④
}
问: 分别打印什么?
👉 点开看答案| 调用 | 输出 | 理由 |
|---|---|---|
① Add(1,2) | N | 普通函数与单参数模板都完全匹配,优先普通函数 |
② Add(1,2.2) | TT | 单参数模板推导冲突出局;双参数模板完全匹配,比需要转换的普通函数更好 |
③ Add(1.1,2.2) | T | 单参数模板生成 Add<double> 完全匹配;双参数模板也能匹配,但单参数模板更特化,优先它 |
④ Add<int>(1,2) | T | 显式 <int> 只认模板,且排除了双参数模板(只给了 1 个类型) |
💡 判断顺序:先看谁被排除(推导失败 / 显式指定)→ 再看谁匹配最好 → 一样好就优先普通函数。
3. 类模板
前面写了 Swap、Add 这种函数模板。但上一篇《内存管理》里的 Stack 也有同样的问题:现在只能存 int,想存 double 就得再抄一个类。
类模板就是把「类型」也做成参数,让一个类能装各种数据。
3.1 类模板的定义格式
template<class T> // T 是类型参数,和函数模板一样
class Stack
{
public:
Stack(int n = 4)
: _a(new T[n]) // ✅ T 可以当普通类型用:数组、指针、参数、返回值
, _top(0)
, _capacity(n)
{}
~Stack() { delete[] _a; }
void Push(const T& x); // 成员函数声明,定义在类外
private:
T* _a;
int _top;
int _capacity;
};
T 的作用范围是整个类:成员变量类型、成员函数参数和返回值,都能用它。
类外定义成员函数时,写法要注意:
template<class T> // ① 每个成员函数都要单独写一遍模板头
void Stack<T>::Push(const T& x) // ② 类名必须写 Stack<T>,不能只写 Stack::
{
if (_top == _capacity) { /* 扩容,省略 */ }
_a[_top++] = x;
}
⚠️ 两个高频坑:
- 写成
void Stack::Push(...)❌ 编译器不认识「Stack」是哪个类型;- 类外定义也要放在头文件里——原因和 2.3 一样:编译器在调用点必须看到完整代码才能按
T生成类。
3.2 类模板的实例化
这是类模板和函数模板最大的区别:
函数模板可以让编译器从实参推导
T;
类模板不行,必须显式写出<类型>。
Stack<int> st1; // ✅ 存 int 的栈
Stack<double> st2(10); // ✅ 存 double 的栈
Stack<char> st3; // ✅ 存 char 的栈
Stack st4; // ❌ 编译报错:缺少模板参数列表
为什么不能推导?因为类的构造函数参数未必包含 T(比如上面的 Stack(int n = 4)),编译器根本没线索去猜。
💡 C++17 起有「类模板参数推导(CTAD)」,但要求构造函数参数里带
T,初学阶段一律显式写<T>最稳妥,也不容易踩坑。
一个必须转过弯的概念
Stack → 类模板名(图纸,不是类型)
Stack<int> → 实例化后的具体类(真正的类型)
Stack<int> 和 Stack<double> 是两个完全独立、互不相关的类型,编译器会分别给它们生成一份代码:
Stack<int> st1;
Stack<double> st2;
st1 = st2; // ❌ 类型不同,不能赋值
💡 类比:
Stack是图纸,Stack<int>和Stack<double>是按同一张图纸盖出来的两栋不同房子——长得像,但不是同一栋。
3.3 灵魂测试题
template<class T>
class A
{
public:
T _x;
};
int main()
{
A<int> a1;
A<double> a2;
cout << sizeof(a1) << " " << sizeof(a2) << endl;
a1 = a2;
}
问: sizeof 输出什么?a1 = a2; 能编译过吗?
sizeof(a1) = 4、sizeof(a2) = 8(分别是int/double的大小)——类模板是为每种类型各生成一份类,成员类型不同,大小自然不同;a1 = a2;❌ 编译报错:A<int>和A<double>是两个不同类型,编译器提供的默认赋值重载只接受同类型。
💡 记住:
<>里换了类型,就是换了一个类。
3.4 函数模板 vs 类模板
| 对比 | 函数模板 | 类模板 |
|---|---|---|
| 实例化 | 可隐式推导 Swap(a,b) | 必须显式写 Stack<int> |
| 名字含义 | Swap 是模板 | Stack 是模板名,Stack<int> 才是类型 |
| 类外定义 | — | 要写 template<class T> + 类名<T>:: |
| 定义位置 | 头文件 | 头文件(含成员函数定义) |
4. 全篇小结
《模板初阶》一张表收尾:
| 小节 | 一句话记住 |
|---|---|
| 1. 泛型编程 | 写与类型无关的通用代码;模板是实现它的工具 |
| 2.1 概念 | 函数模板是图纸,不是函数 |
| 2.2 格式 | template<typename T> + 用 T 写函数;typename/class 都行,struct 不行 |
| 2.3 原理 | 编译期按实参生成多个具体函数;所以定义要放头文件 |
| 2.4 实例化 | 隐式靠推导(唯一类型)、显式写 <T>(之后允许隐式转换) |
| 2.5 匹配原则 | 匹配最好的赢,一样好时普通函数优先;推导阶段不转换 |
| 3. 类模板 | 必须显式 类名<T>;类名 是模板,类名<T> 才是类型 |
三句话串起来:
- 为什么要有模板:同一套逻辑换个类型就要重写,太冗余;
- 模板是什么:给编译器的「代码模具」,编译期按类型印出具体函数/类;
- 最容易错在哪:
T推不出唯一类型、模板定义没放头文件、类模板忘了写<T>。
🔗 系列导航:《类和对象(上)》 → 《(中)》 → 《(下)》 → 《C/C++ 内存管理》 → 本篇:模板初阶
模板是 STL 的地基,下一篇进 STL 时,容器里那些
vector<int>、list<double>就全是今天这套语法了。下一篇见~
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/yanziStefanie/article/details/170248219




