目录
一.泛型编程
二.函数模板
2.1函数模板的概念
2.2 函数模板的原理
三. 函数模板的实例化
四. 函数模板的匹配规则
五.类模板
六.模板参数
七.模板的特化
7.1为什么要有模板特化?
7.2函数模板的特化
7.3类模板的特化
7.3.1全特化
7.3.2偏特化
八.模板的分离编译
九.分离编译的概念
9.1模板的分离编译过程
9.2解决办法
十.模板总结
泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。
如何实现一个通用的交换函数 ?
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;
}
int main()
{
int a = 0, b = 1;
double c = 1.1, d = 2.2;
Swap(a, b);
Swap(c, d);
return 0;
}
说明
虽然能够达到目的,但是也有缺陷:
1.重载的函数仅仅只是类型不同,代码的复用率比较低,只要有新类型出现时,就需要增加对应的函数个数。
2.代码的可维护性比较差,一个出错可能所有的重载都出错。
能否告诉编译器一个模子,让编译器根据不同的类型利用该模子生成代码 ?
在 C++ 中,也能够存在这样一个模具,通过给这个模具中填充不同材料 (类型),来获得不同材料的铸件 (生成具体类型的代码),那将会节省大量时间。
概念
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。
函数模板的格式
template
返回值类型 函数名(参数列表){//具体代码
}
注意
typename 是用来定义模板参数的关键字,也可以使用 class (这里不能用 struct 代替 class)
函数模板实现 交换函数?
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
template
void Swap(T& left, T& right)
{
T tmp = left;
left = right;
right = tmp;
}
int main()
{
int a = 0, b = 1;
double c = 1.1, d = 2.2;
int* p1 = &a, *p2 = &b;
Swap(a, b); //如果有当前类型的函数,就会直接使用。不会去调用函数模板
Swap(c, d); //如果没有当前类型的,则回去调用函数模板生成一份函数代码
Swap(p1, p2);//如果没有当前类型的,则回去调用函数模板生成一份函数代码
return 0;
}
可以看到它可以针对多种类型完成交换。
函数模板实现加法运算
//函数模板
template //函数模板的参数列表: 作用:告诉编译器T是一个类型
T Add(T left, T right){
return left + right;
}
// 函数
int Add(int left, int right){
return left + right;
}
int main (){
Add(1,2); //如果有对于类型的函数,就会直接调用。
Add(3.0,4.0);//如果没有对应类型的函数,就要调用函数模板生成一个当前类型的函数。
Add('a','b');//如果没有对应类型的函数,就要调用函数模板生成一个当前类型的函数。
return 0 ;
}
可以看到它可以针对多种类型完成加法运算。
如上代码一个函数能完成这里几种函数的功能吗 ?
显然是不能的。
分析
它们在调用的时候都要执行 Swap 函数,如果它是一段指令,它是没法完成的。经调试每次调用都往 Swap 函数里走,实际上 VS 的编译器为了方便调试所以在调试器上做了手脚,所以实际还是调用了对应的函数,这个过程叫做模板的实例化。
理解
函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器,可以看到模板就是让你写的时候省劲了,但实际调用还是无差别。
原理
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。
比如:当用 double 类型使用函数模板时,编译器通过对实参类型的推演,将 T 确定为 double 类型,然后产生一份专门处理 double 类型的代码,对于其它类型也是如此。
概念:
用不同类型的参数使用函数模板时,称为函数模板的实例化。
模板参数实例化分为:隐式实例化和显式实例化。
隐式实例化:让编译器根据实参推演模板参数的实际类型。
显式实例化:在函数名后的 <> 中指定模板参数的实际类型。
1.模板函数不允许自动类型转换。
// 只有一个函数模板的情况下
T Add(const T& left, const T& right){
cout << typeid(T).name() << endl;
return left + right;
}
int main(){
cout << Add(1, 2) << endl; //可以
cout << Add(1.0, 2.0) << endl;//可以
cout << Add('1', '2') << endl;//可以
//Add(1, 2.0); //报错
Add(1, (int)2.0); //用户强转 可以
Add(1, 2.0); //显示转化 可以
return 0;
}
Add(1, 2) ,Add(1.0, 2.0), Add('1', '2'):都可以由编译器根据实参推演生成对应类型的函数。
Add(1,2.0):该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型,通过实参 1 将 T 推演为 int,通过实参 2.0 将 T 推演为 double,但模板参数列表中只有一个 T,所以编译器无法确定此处到底该将 T 确定为 int 还是 double 而报错。
Add(1, (int)2.0):用户采用强制转化,可以编译通过。
Add
(1,2.0):用户采用显示转化,可以通过编译。
2.但普通函数可以进行自动类型转换
int Add(int left, int right){
return left + right;
}
int main(){
Add(1, 2.0); //会隐式转化,将double2.0转化成int类型2
}
假设我们不用模板,那么编译器就不会推演了,而这里能从 double 到 int 的原因是它们是相近类型,其中发生了隐式类型转换。
1.一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板。
2.对于非模板函数和同名函数模板,如果其他条件都相同,在调用时会优先调用非模板函数,而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配度的函数,那么将选择模板。
//函数模板 和 函数都存在的情况
//专门处理int的加法函数q
int Add(int left, int right)
{
return left + right;
}
//通用加法函数
template
T Add(T left, T right)
{
return left + right;
}
int main()
{
//模板匹配原则:
//1、有现成完全匹配的,就直接调用,没有现成调用的,实例化模板生成
Add(1, 2);
//2、有需要转换匹配的,那么它会优先选择去实例化模板生成
Add(1.1, 2.2);
//3、没有当前类型的函数,但是模板函数可以生成一个更符合的当前类型的,就调用模板函数
Add(1,2.0);
return 0;
}
Add(1, 2):第一个是现成的,直接调用就行,编译对于非模板函数和同名函数模板,如果其他条件都相同,在调用时会优先调用非模板函数而不会从该模板产生出一个实例。
Add(1.1, 2.2):因为没有可以直接调用的函数,所以就要依靠函数模板根据实参推到形参生成。对于非模板函数和同名函数模板,如果没有当前类型匹配的,在调用时会调用模板函数生成一个匹配的。
Add(1,2.0):如果模板可以产生一个具有更好匹配的函数, 那么将选择模板。
定义格式
template
class 类模板名
{
//类内成员定义
}
动态顺序表的实现
template
class SeqList
{
public:
SeqList(size_t initCapacity = 5)
:_array(new T[initCapacity])
,_capacity(initCapacity)
, _size(0)
{}
~SeqList()
{
if (_array)
{
delete[] _array;
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
void PushBack(const T& data);//尾插函数放在类外进行定义
void PopBack()
{
if (IsEmpty())
{
return;
}
--_size;
}
T& GetFront()const
{
return _array[0];
}
T& GetBack()const
{
return _array[_size - 1];
}
size_t GetSize()const
{
return _size;
}
bool IsEmpty()const
{
return _size == 0;
}
private:
void ExpandCapacity()
{
size_t newCapacity = _capacity * 2;
T* tmp = new T[newCapacity];
for (size_t i = 0; i < _size; i++)
{
tmp[i] = _array[i];
}
delete[] _array;
_array = tmp;
_capacity = newCapacity;
}
private:
T* _array;
size_t _capacity;
size_t _size;
};
//类模板中的函数放在类外进行定义的时候。需要加上模板参数列表
template
void SeqList::PushBack(const T& data)//尾插函数放在类外进行定义
{
if (_capacity == _size)
{
//扩容
ExpandCapacity();
}
_array[_size++] = data;
}
class Date
{
friend ostream& operator <<(ostream& _cout, const Date& d);
public:
Date(int year = 1900,int month = 1,int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
//在使用的时候,需要将其声明为Date类的友元函数,否则在该函数内部无法访问Date类的成员变量
ostream& operator <<(ostream& _cout, const Date& d)
{
_cout << d._year << "-" << d._month << "-" << d._day;
return _cout;
}
int main()
{
//测试内置类型
SeqList s1;
s1.PushBack(1);
s1.PushBack(2);
s1.PushBack(3);
s1.PushBack(4);
s1.PushBack(5);
cout << s1.GetSize() << endl;
cout << s1.GetFront() << endl;
cout << s1.GetBack() << endl;
//测试自定义类型
SeqList s2;
s2.PushBack(Date(2022,3,27));
s2.PushBack(Date(2022, 3, 28));
s2.PushBack(Date(2022, 3, 29));
s2.PushBack(Date(2022, 3, 30));
s2.PushBack(Date(2022, 4, 1));
cout << s2.GetSize() << endl;
cout << s2.GetFront() << endl;
cout << s2.GetBack() << endl;
s2.PopBack();
cout << s2.GetSize() << endl;
cout << s2.GetFront() << endl;
cout << s2.GetBack() << endl;
return 0;
}
1、类模板名是一个类名,并不是类型,不能用来实例化对象。要实例化对象需要用 类模板名<具体类型>来进行实例化。
2、类模板中的函数若在类外定义,需要加上模板参数列表,并且需要加上所属类和作用域限定符。
模板参数分为两大类:
类型形参:出现在模板参数列表中,跟在class或者typename之后的参数类型名称。
非类型形参: 用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可以将该参数当作常量来使用。
例如:定义一个模板类型的静态数组(数组大小一旦被确定,不会改变)
template
class Array
{
public:
Array() //固定大小的顺序表
: _size(0)
{}
void PushBack(const T& data)
{
// N = 100; // 编译失败:因为N是一个常量,不可以修改。
_array[_size++] = data;
}
//[]重载,之后函数内可以直接使用。
T& operator[](size_t index)
{
assert(index < _size);
return _array[index];
}
private:
T _array[N]; //N是常量
size_t _size;
};
int main()
{
Array a1; //100个整形类型的数组,而且在这个数组上还绑定了很多方法。
a1.PushBack(1); //如果这里有参数,就不会用上面的默认参数
a1.PushBack(2);
a1.PushBack(3);
a1.PushBack(4);
cout << a1[0] << endl; //这里可以直接使用[]
Array a2; //如果这里没有参数,就会用上面那个默认值N=10
a2.PushBack(1.0);
a2.PushBack(2.0);
a2.PushBack(3.0);
a2.PushBack(4.0);
cout << a2[0] << endl; //这里可以直接使用[]
int a = 10;
int b = 20;
Array a3; // 编译成功:因为在编译阶段,编译器可以确定a+b表达式的结果
// Array a3; // 编译失败:因为在编译阶段,编译器无法确定a+b表达式的结果
return 0;
}
注意
1、浮点数、类对象以及字符串是不允许作为非类型模板参数的。
2、非类型的模板参数必须在编译期就能确认结果。
通常情况下,使用模板可以实现与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,此时就需要进行模板特化。
// 函数模板
template
const T& Max(const T& left, const T& right)
{
if (left > right)
return left;
return right;
}
// 当函数模板实现完成之后,大部分类型都可以处理,但是对于某些类型处理完成之后结果可能就是有个错误
int main()
{
cout << Max(10, 20) << endl;
cout << Max(2.4, 1.3) << endl;
char s1[] = "world";
char s2[] = "hello";
cout << Max(s2, s1) << endl; //出现错误
return 0;
}
//所以就需要特殊化处理
所谓模板特化就是在原来模板的基础之上,针对特殊类型所进行特殊化的实现方式。
模板特化分为两类
1、函数模板特化
2、类模板特化
下面我们详细介绍一下这两类模板特化
特化步骤如下:
1.先有一个基础的函数模板
2.关键字template后面跟着一对空的<>
3.函数名后面跟<需要特化的类型名>
4.函数形参表:必须要和模板函数的基础形参完全相同,如果不同编译器,可能会出现一些错误。
template
const T& Max(const T& left, const T& right)
{
if (left > right)
return left;
return right;
}
// 专门用来处理char*字符串
template<>
const char*& Max(const char*& left, const char*& right)//这就是函数模板特化
模板类型要和原来的完全一致
{
if (strcmp(left, right) > 0)
return left;
return right;
}
int main()
{
cout << Max(10, 20) << endl;
char* s1 = "world";
char* s2 = "hello";
cout << Max(s1, s2) << endl;
return 0;
}
// 此时调用的时候 还是去调用了模板函数
但是我们一般很少使用函数模板特化,而是那个类型报错,直接把那个类型的函数 自己写出来,调用的时候就不会报错。而且这样更容易,也不容易错。如下
// 模板
template
T Max(T left, T right) //此时原来的模板也会发生变化。
{
if (left > right)
return left;
return right;
}
//专门处理char* 类型
template<>
char* Max(char* left, char* right)
{
if (strcmp(left, right) > 0)
return left;
return right;
}
int main()
{
cout << Max(10, 20) << endl; //一般类型都还是会去找模板
char* s1 = "world"; //特殊类型就会去找特化版本
char* s2 = "hello";
cout << Max(s1, s2) << endl;
return 0;
}
将模板参数列表中所有的参数都确定化
//类模板
template
class Data
{
public:
Data()
{
cout << "Data" << endl;
}
private:
T1 _d1;
T2 _d2;
};
//类模板的全特化
template<>
class Data
{
public:
Data()
{
cout << "Data" << endl;
}
private:
int _d1;
char _d2;
};
int main()
{
Data d1; //模板
Data d2;//模板
Data d3; //模板
Data d4; //特化
return 0;
}
任何针对模板参数进一步进行条件限制设计的特化版本都属于是偏特化
有两种表现方式
1、部分特化
将模板参数列表中一部分参数特化。2、参数更进一步限制
偏特化并不仅仅是特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。
//类模板
template
class Data
{
public:
Data()
{
cout << "Data" << endl;
}
private:
T1 _d1;
T2 _d2;
};
// 形式1:部分特化
template
class Data
{
public:
Data()
{
cout << "Data" << endl;
}
private:
T1 _d1;
int _d2;
};
// 形式2:对参数更严格一点的限制 指针的版本
template
class Data
{
public:
Data()
{
cout << "Data" << endl;
}
private:
T1* _d1;
T2* _d2;
};
// 形式3:对参数更严格一点的限制 引用类的版本
template
class Data
{
public:
Data(const T1& d1 ,const T2& d2)
:_d1(d1)
,_d2(d2)
{
cout << "Data" << endl;
}
private:
const T1& _d1;
const T2& _d2;
};
int main()
{
// 在对Data类模板实例化时,只要第二个参数不是int类型,都使用的是类模板
Data d1;
// 只要第二个参数是int类型,不管第一个参数是什么类型,都使用部分特化版本
Data d2;
Data d3;
Data d4;
// 指针类型的参数
Data d5;
Data d6;
// 引用类型的参数
Datad7(1,2);
Dated8('a','b');
return 0;
}
知识铺垫
生成一份可运行代码需要经历预处理,编译,汇编,链接。
预处理
主要做6件事情,分别是:
①头文件展开: 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。
注意:这个过程是递归进行的,也就是说被包含的文件还可以包含其他文件
②宏替换:将所有的“#define”删除,并且展开所有的宏定义
③条件编译:处理所有的条件编译指令,比如“#if”、“#ifdef”、“#elif”、“#else” 、“#endif”
④去注释:删除所有注释 “//”和“/**/”
⑤添加行号和文件名标识: 目的是以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或者警告时能够显示行号
⑥保留所有的#pragma编译器指令,因为编译器要使用它们
编译
编译器将预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。这个过程往往是整个程序构建的核心部分
汇编
通过汇编器将汇编代码转变成机器可以执行的指令,相较于编译器的工作,该过程是一种比较简单的翻译。
链接
主要内容是把各个模块之间相互作用的部分都处理好,使得各个模块之间能够正确的衔接
该过程主要包括三个方面:
①地址和空间分配
模块:由若干个变量和函数构成
对于一个项目来说(拿C举例),它有若干个.c文件,每一个.c源文件由若干个模块构成。这些源代码按照文件目录结构来组织。
每个模块之间相互依赖又相互独立。这样的存储方式使得代码更容易阅读、理解和重用。每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序
这些模块被编译好之后如何组合在一起形成一个单一的程序是需要解决的问题。模块之间的组合问题可以归结为模块之间如何通信的问题。最常见的属于静态语言的C/C++模块之间的通信方式有两种“
Ⅰ:模块间的函数调用:需要知道目标函数的入口地址
Ⅱ:模块间的变量访问:需要知道目标变量的地址
综上:这两种方式可以归结为一种方式,那就是模块间符号的引用。这种通过符号来通信的方式类似于”拼图“,而这个拼接的过程就是链接的过程!
②符号决议
符号决议有时候也被称为符号绑定(Symbol Binding)、名称绑定(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址绑定(Address Binding) 、指令绑定(Instruction Binding)的,但是大体上他们都是一个意思。但是从细节上来说还是有些许差别的,比如“决议”更倾向于静态链接,“绑定”更倾向于动态链接。在静态链接部分,我们统一称为符号决议。
下图是最基本的静态链接的过程,每个模块的源代码经过编译器编译成目标代码,目标文件和库一起链接成最终的可执行文件
这里有一个新名词,库:是一组目标文件的包,其实就是一些最常用的代码编译成目标文件后打包存放。最常见的库就是运行时库,他是支持程序运行的基本函数的集合
③重定向
如我们在程序模块main.c中使用另外一个模块test.c中的函数test。我们在 main模块中
每一处调用test的时候都必须确切知道test这个函数的地址,但是由于每个模块都是单独编
译的,在编译器编译main.c的时候它并不知道test函数的地址,所以它会把这些调用test
的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果
没有链接器,须要我们手工把每个调用test的指进行修正,则填入正确的test函数地址。
当test.c模块被重新编译,test函数的地址有可能改变时,那么我们在 main中所有使用到
test的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接器,
你可以直接引用其他模块的函数和全局变量而无须知道它们的地址,因为链接器在链接的时
候,会根据你所引用的符号test,自动去相应的 test.c模块查找test的地址,然后将 main
模块中所有引用到test的指令重新修正,让它们的目标地址为真正的test函数的地址。这就
是静态链接的最基本的过程和作用。
对于一些变量,例如全局变量,也是如此。
上述讲的地址的修正过程 被称为重定位。 每个要被修正的目标地址叫做 重定位入口。 重定位做的就是给程序员中的每个这样的绝对地址引用的位子“打补丁”,使他们指向正确的地址。
概念:一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程
假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义
我现在在test.c源文件内实例化一下Add(xxx,xxx);再来观察现象
看到这里,我们要解决的矛盾点也就出来了。
目前我们通过现象得到的时:在定义模板的源文件内实例化过的Add函数,在其他源文件内可以正常使用。未实例化的,就不能被使用。
分析原因:
本质上是因为每个源文件都是单独编译的!对于该文件中未定义并且使用到的函数(变量)地址,会在链接的时候通过重定向来找到这些目标地址。如果找到了,也就不会报错。对于找不到的地址,编译器会报链接错误的标识!
下面通过图示的方式解释一下:
针对上述问题,如何解决呢?
有两种方式
1.将申明和定义放在同一个文件里面,xxx.hpp
2.模板定义的位置显示实例化(不推荐)。
优点
1.模板复用了代码,节省资源,更快迭代开发,c++标准模板库因此产生
2.增加了代码的灵活性
缺点
1.模板会导致代码膨胀问题,也导致代码运行时间变成
2.模板编译错误信息,不易分析定位错误。
谢谢大佬的观看!!!