文章目录
前言
路漫漫其修远兮,吾将上下而求索;
一、泛型编程
何为泛型编程?
- 针对广泛的类型去写代码;即编写与类型无关的通用代码,是代码复用的一种手段;
Q:如何实现一个通用的交换函数呢?
代码如下:
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
void Swap(double& left, double& right)
{
double temp = left;
left = right;
right = temp;
}
void Swap(char& left, char& right)
{
char temp = left;
left = right;
right = temp;
}
//还可以继续重载其他参数类型的函数...
上述代码是用函数重载实现的,这些函数的执行逻辑相同,仅要使用的数据的类型不同,就需要额外再写函数;
用函数重载实现的几个缺点:
- 1、重载的函数仅仅是参数的类型不同,代码复用率比较低,只有新类型出现的时,就需要用户自己增加对应的函数;
- 2、代码的可维护性比较低,一个出错可能所有的重载均会出错;
那么此时我们就会想,能否直接给编译器一个“模板”,然后编译器会通过不同的类型利用该“模板”生成函数呢;就像做月饼一样,我们可以用月饼模具做出许多不同馅料的月饼出来;
于是乎,C++就提供了一种机制:模板;
模板是泛型编程的基础;
二、函数模板
1、函数模板的概念
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本;
2、函数模板的格式
template<typename T1,typename T2,......,typename Tn>
返回类型 函数名(参数列表){}
新增加了两个关键字:template (模板)、 typename(类型名称);
注意: typename 是用来定义模板参数关键字,也可以使用class(切记:不能用struct 来代替class);即函数模板的格式还可以这么写:
template<classT1,classT2,......,classTn>
返回类型 函数名(参数列表){}
将上面用函数重载实现的交换的代码写成函数模板,代码如下:
template<typename T>
void Swap(T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
注:模板参数可以类比我们之前学习的函数参数;
- 对于模板参数来说,<> 中定义的是类型,如果有多个类型,其间需要用 , 进行分隔;模板参数:关键字template<typename 类型名称,...>
- 而对于函数参数,() 中定义的是参数对象、参数变量;可能会有多个参数,参数之间用 , 进行分隔。函数参数: 函数返回值类型 函数名(类型 变量名, ... )
使用该函数模板,代码如下:
//函数模板 --> 泛型
template<typename T>
void Swap(T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
int main()
{
int x = 1, y = 2;
Swap(x, y);
cout << "x:" << x << " " << "y:" << y << endl;
double m = 1.1, n = 2.2;
Swap(m, n);
cout << "m:" << m << " " << "n:" << n << endl;
return 0;
}
运行结果如下:
Q:模板的原理是什么?“模具”
调用的是编译器根据这个模板参数的匹配取去推演出生成T为int、double的Swap 函数(这是两个函数);也就是意味着,写成了模板,将工作交给了编译器,便利了我们去写代码;此处的底层实际上还是两个函数;
我们可以观察一下反汇编:
模板相当于是活字印刷的模具,而干活的人是编译器;模板的原理:给一个模板,编译器帮你去生成对应的函数;
3、函数模板的原理
编译器根据模板来生成对应的函数:
- 步骤一:推演:根据所传的实参来推演形参的类型
- 步骤二:模板实例化:编译器生成对应的函数(模板实例化是通过函数模板去实例化生成对应具体的函数)
函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生具体类型函数的模具,所以其实模板就是将本来应该我们做的重复的事情交给了编译器;
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。比如:当double 类型使用函数模板的时候,编译器通过对实参类型的推演,将T确定为double 类型,然后专门产生一份专门处理double 类型的代码,对于字符类型也是如此;
注意: typename 是用来定义模板参数关键字,也可以使用class(切记:不能用struct 来代替class);
4、函数模板的实例化
用不同类型的参数使用函数模板时,称为函数模板的实例化。模板的实例化分为:隐式实例化和显式实例化;
1、隐式实例化:
让编译器根据实参推演模板参数的实际类型;
代码如下:
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.0, d2 = 20.0;
Add(a1, a2);
Add(d1, d2);
return 0;
}
Add(a1,d1);是否可以调用成功?
该语句不能通过编译,因为在编译期间,当编译器要实例化的时候,需要推演其实参类型;通过实参a1 会将T推演为int ,但是通过实参 d1 又将T推演为 double 类型,但是模板参数列表中只有一个T,编译器无法确定此处到底应该将T确定为int 还是 double,于是就报错了;
需要注意的是,在模板中,编译器一般不会进行类型转换操作,因为一旦转换出了问题,编译器就需要背黑锅;
此处有两种处理方式:1、用户自己来强制转换 2、使用显式实例化
强制转换:Add((double)a1, d1);
2、显式实例化:
在函数名后面的<> 中指定模板参数的实际类型
使用代码如下:
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.0, d2 = 20.0;
//Add((double)a1, d1);//强制类型转换
Add<double>(a1, d1);//显式实例化
return 0;
}
如果实参的类型不匹配,我们可以在函数名后的<> 中指定模板参数的实际类型,这就是显式实例化;由我们直接决定模板所要实例化这个函数的形参类型;
所以上述问题有两种解决方案:
解决方案一:强制类型转换
解决方案二:显式实例化
在方案一中,强制类型转换实参的类型,然后编译器根据实参的类型进行推演;本质:推演实例化;
显式实例化需要在调用函数名和实参之间添加一个 <> ,并在其中指定类型;
Q:显式实例化还有没有其他的应用场景?
- 有些特殊的函数(eg. 该函数形参不使用模板类型),编译器无法通过其参数推演的时候就要使用显式实例化;
代码如下:
template<class T>
T* func(size_t n)
{
return new T[n];
}
int main()
{
func(10);//编译器无法通过实参推演并实例化
return 0;
}
运行结果如下:
不能这样用的原因:实参传给形参,编译器会根据实参来推演模板类型,但是此处函数func 的形参是size_t 类型的,并没有使用模板类型,所以编译器无法进行推演而隐式实例化;但是我们可以直接告诉编译器此处的模板类型是什么,即让编译器显式实例化该函数;
显式实例化:
template<class T>
T* func(size_t n)
{
return new T[n];
}
int main()
{
func<int>(10);//显式实例化
return 0;
}
5、模板参数的匹配原则
1、一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以实例化为这个非模板函数;并且”有现成吃现成“
代码如下:
//专门处理int 类型数据的加法函数
int Add(int left, int right)
{
return left + right;
}
//通用的加法函数
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.1, d2 = 20.2;
//推演实例化
cout << Add(a1, a2) << endl;
cout << Add(d1, d2) << endl;
return 0;
}
Q:为什么同名的函数模板可以与普通函数同时存在?
- 因为C++中支持函数重载;
编译器的原则:有“现成”就是用“现成”的,没有“现成”就自己使用函数模板去实例化;即当存在同名的非模板函数与模板函数,调用的时候如果参数类型与非模板函数向匹配就会优先使用非模板函数,不匹配的时候编译器才会根据函数模板进行实例化;
模板的本质就是为了方便程序员,传不同类型的参数就可以实现相应的逻辑,而底层实际上还是在调用不同的函数;
而如果我们使用显式实例化,就意味着强制性地让编译器去使用函数模板实例化,代码如下:
//专门处理int 类型数据的加法函数
int Add(int left, int right)
{
return left + right;
}
//通用的加法函数
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.1, d2 = 20.2;
//推演实例化
cout << Add(a1, a2) << endl;
//显式实例化,强制编译器去使用函数模板去实例化
cout << Add<int>(a1, a2) << endl;
cout << Add(d1, d2) << endl;
return 0;
}
2、对于非模板函数和同名函数模板,如果其他的条件相同,在调动的时候会优先调用非模板函数而不会从该模板中产生出一个实例;并且“在有现成吃现成的基础上,编译器还会选择更加匹配的”;
代码如下:
//非模板函数可以与模板函数同名
//专门处理int 类型数据的加法函数
int Add(int left, int right)
{
return left + right;
}
//通用的加法函数
template<class T1,class T2>
T1 Add(T1 left, T2 right)
{
return left + right;
}
int main()
{
//与非模板函数类型完全匹配
Add(1, 2);
//模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函数
Add(1, 2.0);
return 0;
}
但是倘若此时只有一个不太匹配的“现成”,编译器也会选择隐式类型转换强制性地走;
3、模板函数不允许自动类型转换,但是普通函数可以进行自动类型转换;
小结:
在参数匹配的情况下,编译器会“有现成吃现成”;但如果参数不匹配并且此时有现成的不匹配(不匹配的非函数模板),以及匹配的“自己做”(匹配的函数模板),编译器会去调用更加匹配的;
三、类模板
之前用C++实现的Stack 之中,有typedef int STDateType;不能将typedef 理解为泛型,因为typedef 存在巨大的缺陷,无法让实例化出来的对象一个存放int类型的数据,一个存放double 类型的数据...即typedef 只能允许一个类型存在,不能同时多个类型的数据同时使用;
如果我们非要利用typedef 实现也是可以的,这样的话,我们就需要写成多个类来实现不同类型数据的存放,eg.StackInt、StackDouble...... 如果还有其他类型,需要拷贝主逻辑针对该类型重新实现一个类出来;这样做的话,单单就只有数据的类型不同,这些类的主逻辑大体都是相似,而又大幅度地增加了代码量,不利于维护;基于这样的问题,C++中还提供了类模板;
注:也正是因为C语言的语法无法支持“模板”这一概念以同时满足不同类型数据的需求,所以C语言库中不实现数据结构;
类模板与函数模板的区别:
- 函数模板定义的参数只能给该函数使用,而类模板定义的参数是给整个类使用的;
1、类模板的定义格式
template<class T1,class T2,... ,class Tn>
class 类模板名
{
//类模板成员
};也可以是:
template<typename T1,typename T2,... ,typename Tn>
class 类模板名
{
//类模板成员
};
使用代码如下:
//类模板
template<typename T>
class Stack
{
public:
Stack(int n = 4)
{
_a = (T*)malloc(sizeof(T) * n);
if (nullptr == _a)
{
perror("malloc 申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
private:
T* _a;
size_t _capacity;
size_t _top;
};
int main()
{
//必须显式实例化
Stack<int> st1;
Stack<double> st2;
return 0;
}
需要注意的是,由于构造函数的形参不一定涉及T ,但构造函数之中又会使用T ,所以类模板的特点是必须显式实例化;
上例中的st1 与 st2 使用的并不是同一个类,是编译器根据同一个类模板实例化出来的不同的类;
2、类模板的实例化
类模板实例化与函数模板的实例化不同,类模板实例化需要在类模板名字后面跟<> ,然后将实例化类型放在<> 中即可,类模板名字并不是真正的类,而实例化的结果才是真正的类;
//Stack 是类名,Stack<int>、Stack<double> 才是类型
Stack<int> st1;//int
Stack<double> st2;//double
接下来,我们还可以与根据类模板来优化之前我们所写的栈;(优化其中的小细节:引用传参、const 修饰、引用返回)
//类模板
template<typename T>
class Stack
{
public:
Stack(int n = 4)
{
//_a = (T*)malloc(sizeof(T) * n);
//if (nullptr == _a)
//{
// perror("malloc 申请空间失败");
// return;
//}
//使用new 开辟空间
_a = new T[n];
_capacity = n;
_top = 0;
}
void Push(const T& x)//引用传参、const修饰
{
//扩容判断
if (_top == _capacity)
{
//走realloc 的逻辑
int newcapacity = _capacity * 2;
//异地扩容- 开辟新空间
T* tmp = new T[newcapacity];
memcpy(tmp, _a, sizeof(T) * _top);//拷贝数据
delete[] _a;//释放旧空间
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
void Pop()
{
assert(_top);
_a[_top - 1] = -1;
_top--;
}
const T& Top()//获取栈顶的数据,也需要用const 修饰
{
return _a[_top - 1];
}
~Stack()
{
delete[] _a;
}
private:
T* _a;
size_t _capacity;
size_t _top;
};
需要注意的是,在C++之中并没有renew ,所以扩容不能使用realloc ,需要使用new 、memcpy、delete 去模拟realloc 异地扩容的功能来满足扩容需求;在外部捕获异常即可;
对于获取栈顶的数据,初衷并不期望修改栈中的数据,所以Top的返回值为const 引用;
模板类中的成员函数声明与定义分离,分离的定义需要加上类模板:
需要注意的是,指定类域需要加上模板参数 T,而T的来源需要说清楚,所以要加上类模板;
如果写成:
如果存在多个类型的类,就会分不清楚这个Push 究竟是给谁用的了,并且在函数参数、函数体中也有可能会使用到模板参数T;所以在定义该函数的时候,需要声明其模板参数,其次是指定类域需要加上模板参数;修改如下:
其中还需要注意的是,类模板中成员函数不建议声明与定义分离到两个文件之中,否则会出现链接错误(与模板原理相冲突),原因此处暂不讲后续会有所提及;所以,类模板中成员函数的声明与定义一般是放在一个文件之中;
接下来,我们看一些stl_list.h 中的源码:
之所以stl源码中只有.h 文件而没有 .cpp 文件,就是因为类模板中成员函数的声明与定义不能分离到两个文件之中;短小的成员函数直接就在类中实现了,在类中实现的函数默认为内联函数,较长的成员函数便会在类中声明,类外定义;
总结
1、typename 是用来定义模板参数关键字,也可以使用class(切记:不能用struct 来代替class);
template<typename T1,typename T2,......,typename Tn>
返回类型 函数名(参数列表){}
template<classT1,classT2,......,classTn>
返回类型 函数名(参数列表){}
2、编译器根据模板来生成对应的函数:
- 步骤一:推演:根据所传的实参来推演形参的类型
- 步骤二:模板实例化:编译器生成对应的函数(模板实例化是通过函数模板去实例化生成对应具体的函数)
3、用不同类型的参数使用函数模板时,称为函数模板的实例化。模板的实例化分为:隐式实例化和显式实例化;
4、类模板定义格式:
template<class T1,class T2,... ,class Tn>
class 类模板名
{
//类模板成员
};也可以是:
template<typename T1,typename T2,... ,typename Tn>
class 类模板名
{
//类模板成员
};