Kesh11头像
C++ 6:模板初阶封面图

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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--