三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【c++学习笔记】【基础】9.模板初阶:从函数模板到类模板

【c++学习笔记】【基础】9.模板初阶:从函数模板到类模板

前言

在 C++ 中,我们经常会遇到这样的代码:

voidSwap(int&left,int&right);voidSwap(double&left,double&right);voidSwap(char&left,char&right);

这些函数处理的数据类型不同,但完成的事情完全相同:交换两个变量的值

函数重载虽然能够解决问题,但如果以后还需要支持floatlong等更多类型,就要继续编写新的重载。代码逻辑没有变化,却因为类型不同被重复实现了很多次。

于是就产生了一个很自然的问题:

能不能只写一份代码,把具体的数据类型也抽象出来,让同一套逻辑适用于不同类型?

这就是 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.typenameclass

声明类型模板参数时,可以写:

template<typenameT>

也可以写:

template<classT>

例如:

template<classT>voidSwap(T&left,T&right){T temp=left;left=right;right=temp;}

在这里,typenameclass的作用相同,都表示T是一个类型模板参数。

需要注意的是:

template<structT>

不能用来替代这里的typenameclass

本节小结

函数模板可以简单理解为:

先用模板参数代替具体类型,描述一套通用的函数逻辑。

例如:

template<typenameT>voidSwap(T&x,T&y);

此时T还没有确定,它究竟是intdouble还是其他类型,要等真正使用模板时才能知道。


三、函数模板是如何变成具体函数的?

定义:

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)

同一个模板参数无法在这次推导中同时被确定成intdouble,因此模板参数推导失败。

这里最容易产生一个误区:

intdouble明明可以做加法,为什么模板不能调用?

问题其实不在+运算。

真正的问题发生得更早:

编译器首先要确定 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> 有什么区别? ↓ 一个是类模板,一个是具体类型

把这条主线建立起来,函数模板、模板实例化、参数匹配和类模板就不再是几个零散的语法点,而是一套完整的泛型编程逻辑。

← 返回列表