文章目录
- 一、泛型编程
- 二、函数模板
- 2.1 函数模板的原理
- 2.2 函数模板的实例化
- 2.3 模板参数的匹配原则
- 三、类模板
- 四、非类型模板参数
- 五、模板的特化
- 5.1 函数模板特化
- 5.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;
}
void Swap(char& left, char& right)
{
char temp = left;
left = right;
right = temp;
}
想要实现一个通用的交换函数不难,借助函数重载就可以。函数重载小伙伴们还记得嘛👀,忘了的小伙伴可以走传送门回去复习一下。如上面代码所示,我们借助函数重载实现了三份Swap
函数,分别用来交换两个整型变量、两个双精度浮点型变量、两个字符型变量。
小Tips:函数重载的通用性体现在函数调用的时候。当我们想交换两个变量的时候,不管变量是什么类型,都是直接使用Swap
函数,编译器会根据用户传递的实参数据类型,自动去匹配调用对应的交换函数,在用户看来仿佛就只有一份Swap
函数,实现了所有类型数据的交换。
📖函数重载的缺陷
通过上面的分析我们可以看出,函数重载的最大缺陷就是,当有一个新类型出现时,需要自己增加对应的重载交换函数,代码的复用率比较低。其次,因为所有的重载函数都是我们自己写的,过程繁琐并且难免会出现差错。
📖模板的引入
函数重载的主要问题就出在需要我们自己去写,那能否告诉编译器一个模子,让编译器代替我们,根据不同的类型利用该模子来生成代码呢?
如果在C++中,也能够存在这样一个模板,通过给这个模板涂上不同的颜色(类型),来获得不同颜色的五角星(生成具体的代码),那将会方便不少,我们的头发也能少掉一点。巧的是我们的先辈已经将树栽好,我们只需在此乘凉。
📖什么是泛型编程?
编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础,其中模板分为函数模板和类模板。
二、函数模板
📖概念
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。
📖格式
template <typename T1, typename T2,.......,typename Tn>
返回值类型 函数名(参数列表){函数体}
小Tips:typename
是用来定义模板参数的关键字,也可以用class
替换。T1
、T2
…是模板参数,表示类型。
//一个交换函数的函数模板
template<typename T>
void Swap( T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
2.1 函数模板的原理
函数模板是一个蓝图,它本身并不是函数,是编译器用来产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的事情交给了编译器。
//一个交换函数模板
template<typename T>
void Swap(T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
int main()
{
int i1 = 10;
int i2 = 20;
Swap(i1, i2);
double d1 = 1.1;
double d2 = 2.2;
Swap(d1, d2);
char c1 = 'a';
char c2 = 'b';
Swap(c1, c2);
}
在编译器编译阶段,对于函数模板的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。例如:当用double
类型的数据去调用Swap
函数,编译器通过对实参类型的推演,将T
确定为double
类型,然后生成一份专门处理double
类型的代码,对于int
类型和char
类型的数据也是这样处理的。所以在使用者看来,无论什么类型都是调用Swap
函数,但本质上不同的类型会去调用不同的Swap
函数。
小Tips:这里为了给大家展示函数模板的原理,专门写了一个交换函数Swap
的模板,实际中当大家需要交换两个数据的时候不需要自己写,因为库中已经帮我们实现好了,可以用库中的swap
(s小写)实现对两个同类型数据的交换。
2.2 函数模板的实例化
用不同类型的参数使用函数模板时,称为函数模板的实例化。模板参数实例化分为:隐式实例化和显式实例化。
📖隐式实例化
隐式实例化就是让编译器根据实参,自动推演模板参数的实际类型。
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);
//Add(a1, d1);//该语句编译不通过
Add(a, (int)d);//强制类型转换,将两个参数设置成同类型
return 0;
}
注意:Add(a1, d1)
会导致编译失败,因为在编译期间,当编译器看到该函数调用的时候,会去自动推演模板参数的类型,首先通过实参a1
将T
推演为int
,通过实参d1
将T
推演为double
,但是模板参数列表中只有一个T
,编译器无法确定此处到底应该将T
确定为int
或者double
类型而报错。此时这里有三种处理方法,第一种方法:在函数模板的参数列表中再增加一个模板参数;第二种方法:用户自己来强制类型转换,对一个参数进行强制类型转换,使得两个参数的类型相同;第三种方法:使用接下来介绍的显式实例化。
小Tips:模板参数也可以作为函数模板的返回值类型。
📖显式实例化
显式实例化是在函数调用阶段,在函数名后跟一个<>
,在里面指定模板参数的实际类型。
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main(void)
{
int a = 10;
double b = 20.0;
// 显式实例化
Add<int>(a, b);
return 0;
}
注意:如果传递的实参和实例化出的函数形参类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器会报错。
📖显式实例化的实际使用场景
template<typename T>//模板参数T
T* Alloc(int n)//函数模板的形参没有使用模板参数
{
return new T[n];
}
int main()
{
double* p = Alloc<double>(10);
return 0;
}
如上面的代码所示,函数模板Alloc
并没有模板参数T
类型的形参,因此编译器就无法去根据用户传递的实参类型推导出模板参数T
的具体类型。因此,当用户想要调用Alloc
函数时,必须进行显式实例化。从这里也可以看出,编译器支持隐式实例化的前提是:模板函数使用了模板参数类型的形参。
小Tips:编译器不会根据函数的返回值去推导模板参数T
的类型,就像上面的Alloc
函数模板,虽然返回值的类型是模板参数T
,但是不管用,编译器不会根据这里去推演T
的实际类型,也推演不出来。
2.3 模板参数的匹配原则
- 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数。
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template<class T>
T Add(T left, T right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非模板函数匹配,编译器不需要特化
Add<int>(1, 2); // 调用编译器特化的Add版本
}
- 对于非模板函数和同名的函数模板,如果其他条件都相同,在调用时会优先调用非模板函数,而不会从函数模板产生一个实例。如果函数模板可以产生一个具有更好匹配的函数,那么将选择函数模板。
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template<class T1, class T2>
T1 Add(T1 left, T2 right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非函数模板类型完全匹配,不需要函数模板实例化
Add(1, 2.0); // 模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函数
}
三、类模板
📖类模板的定义格式
template<class T1, class T2, ..., class Tn>//模板参数列表
class 类模板名
{
// 类内成员定义
};
// 动态顺序表
// 注意:Vector不是具体的类,是编译器根据被实例化的类型生成具体类的模具
template<class T>
class Vector
{
public :
Vector(size_t capacity = 10)
: _pData(new T[capacity])
, _size(0)
, _capacity(capacity)
{}
// 使用析构函数演示:在类中声明,在类外定义。
~Vector();
void PushBack(const T& data);
void PopBack();
// ...
size_t Size() {return _size;}
private:
T* _pData;
size_t _size;
size_t _capacity;
};
// 注意:类模板中函数放在类外进行定义时,需要加模板参数列表
template <class T>
Vector<T>::~Vector()
{
if(_pData)
delete[] _pData;
_size = _capacity = 0;
}
注意:Vector
不是具体的类,是编译器根据被实例化的类型生成具体类的模具。其次,类模板中的成员函数放在类外面进行定义时需要加模板参数列表,因为此时单独的Vector
已经不再表示类型了,编译器可能会根据Vector
这个模板,同时实例化出多个类,此时Vector<T>
表示一个具体的类型。建议类模板中的成员函数,声明和定义不要分离到两个文件中。
📖类模板的实例化
类模板的实例化与函数模板的实例化不同,类模板的实例化需要在类模板的名字后面跟<>
,然后将实例化的类型放在<>
中即可,类模板的名字不是真正的类,而实例化的结果才是真正的类。
// Vector类名,Vector<int>才是类型
Vector<int> s1;
Vector<double> s2;
📖类模板的优势
类模板的优势和函数模板一样,把本来需要我们自己干的事情,交给了编译器去干。以上面的动态顺序表为例,假如没有类模板,我需要在一个程序中同时定义一个存储int
型数据的顺序表,和存储double
型数据的顺序表,因为没有类模板的话,动态顺序表的成员变量_pData
的类型就只能时固定的int
或double
,此时我们就只能写两个动态顺序表的类,将其中一个类的成员变量_pData
设置成int
类型,用来存储int
型数据,将另一个类的成员变量_pData
设置成double
类型,用来存储double
型数据。当程序中还需要一个存储其他类型数据的顺序表时,我们还得自己再增加类,这工作量可见一斑,长时间下去,不管你能不能忍受,你的头发必定受不了。而类模板的出现,就极大的缓解了我们头发的压力,我们只需要写一份动态顺序表的模板代码出来,当要用动态顺序表存储某类型的数据时,我们只需要把该类型告诉编译器,让编译器根据我们写的模板去实例化一个对应的动态顺序表类出来,存储该类型的数据。
四、非类型模板参数
前文提到的模板参数,准确的说应该叫做模板的类型形参,出现在模板参数列表中,跟在class
、typename
之后,用来表示一种类型;除此之外,模板还有另一种参数:非类型形参,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
📖非类型模板参数的使用场景
//静态栈
//#define M 100
template<class T, size_t N = 10>
class Stack
{
public:
//一些成员函数
private:
T _arr[N];
//T _arr[M];
int _top;
};
int main()
{
Stack<int, 10> s1;//实例化一个栈可以存储10个整型数据
Stack<double, 100> s2;//实例化出一个栈,可以存储100个double型数据
return 0;
}
假设我们这里要实现一个静态的栈,即存储的数据量一经确认是无法扩容的,所以成员变量我们声明了一个数组_arr
,在没有非类型模板参数的时候,设计静态的栈一般是通过#define
来定义一个标识符常量,我们给这个常量一个初始值,就像上面代码中的M
,最终设计出来的栈就能存储M
个数据,但是一个程序中M
只能被定义一次,当我们需要存储的多组数据,并且每组数据的数据量大不相同,就像上面的,s1
需要存储10个int
型数据,s2
需要存储100个double
型的数据,为了满足两者的需求,此时的M
就只能大于100,这样对s1
来说,就会造成极大的空间浪费。
非类型模板参数的引入就很好的解决了这个问题,我们可以根据实际的需求去传递参数,实例化出最符合自己需求的栈。
📖注意事项:
- 非类型模板参数一定是一个常量,在类中不能对其进行修改。
- 非类型模板参数的类型必须是整型,即
int
、size_t
、char
等。浮点型、类类型等是不允许作为非类型模板参数的。 - 非类型模板参数必须在在编译阶段就能确认结果。
五、模板的特化
📖概念
在原模板的基础上,针对特殊类型惊醒特殊化的实现方式。模板特化分为函数模板特化和类模板特化。
📖为什么要有模板特化
通常情况下,使用模板可以实现一些与类型无关的代码,但是对于一些特殊的类型可能会得到一些错误的结果,此时就需要进行特殊处理。比如下面这个专门用来进行小于比较的函数模板:
template<class T>
bool Less(T left, T right)
{
return left < right;
}
int main()
{
int a = 10;
int b = 20;
cout << Less(a, b) << endl; // 可以比较,结果正确
int* pa = &a;
int* pb = &b;
//希望通过传递a,b的地址去比较a,b的大小
cout << Less(pa, pb) << endl; // 可以比较,结果错误
return 0;
}
第一次,我们希望直接去比较a
和b
的大小,于是就直接传递了a
和b
,此时的模板参数T
被隐式实例化为int
,Less
函数中就进行的是两个整数的比较,比较的结果符合我们的预期。第二次,我们希望通过a
和b
的地址去比较它们两个的大小,于是传递了a
和b
的地址,此时的模板参数T
被实例化为int*
,因此Less
函数就是进行两个地址的比较,并没有按照我们期望的那样,去比较两个地址中存储的数据大小,因此得到的结果也和我们预期的有所不同,此时我们就要对Less
函数模板进行特化,让它能够满足我们的要求。
5.1 函数模板特化
📖注意事项:
- 必须要先有一个基础的函数模板。
- 关键字
template
后面接一对空的尖括号<>
。 - 函数名后跟一对尖括号
<>
,尖括号中指定需要特化的类型。 - 函数形参列表必须要和函数模板的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
//函数模板
template<class T>
bool Less(T left, T right)
{
return left < right;
}
//对Less函数模板进行特化
template<>
bool Less<int*>(int* left, int* right)
{
return *left < *right;
}
int main()
{
int a = 10;
int b = 20;
cout << Less(a, b) << endl; // 可以比较,结果正确
int* pa = &a;
int* pb = &b;
//希望通过传递a,b的地址去比较a,b的大小
cout << Less(pa, pb) << endl; // 调用特化之后的版本,不走函数模板
return 0;
}
bool Less<int*>(int* left, int* right)
就是对Less
函数模板的一个特化,当用户调用Less
传递的是int*
类型的参数时,会去调用特化后的版本,而不走函数模板。
小Tips:根据2.3小节的内容,一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出,像下面这样。
bool Less(int* left, int* right)
{
return *left < *right;
}
这种实现简单明了,代码的可读性高,容易书写。
5.2 类模板特化
📖全特化
全特化就是将模板参数列表中所有的参数都确定化。
//类模板
template<class T1, class T2>
class Data
{
public:
Data() { cout << "Data<T1, T2>" << endl; }
private:
T1 _d1;
T2 _d2;
};
//对Data类模板进行特化
template<>
class Data<int, char>
{
public:
Data() { cout << "Data<int, char>" << endl; }
private:
int _d1;
char _d2;
};
void TestVector()
{
Data<int, int> d1;//使用类模板
Data<int, char> d2;//使用全特化
}
📖偏特化
任何针对模板参数进行进一步条件限制设计出来的特化版本,叫做偏特化。偏特化有以下两种表现方式:
- 部分特化
将类模板参数列表中的一部分参数进行特化。
//原类模板
template<class T1, class T2>
class Data
{
public:
Data() { cout << "Data<T1, T2>" << endl; }
private:
T1 _d1;
T2 _d2;
};
// 将第二个参数特化为int
template <class T1>
class Data<T1, int>
{
public:
Data() {cout<<"Data<T1, int>" <<endl;}
private:
T1 _d1;
int _d2;
};
void TestVector()
{
Data<int, int> d1;//使用部分特化
Data<double, int> d2;//使用部分特化
Data<char, int> d3;//使用部分特化
}
以上面为例,将原类模板的第一个参数仍旧使用模板T1
,第二个模板参数特化成int
型,此后只要第二个参数传的是int
,就会走特化后的类。
- 参数更进一步限制
//两个参数偏特化为指针类型
template <typename T1, typename T2>
class Data <T1*, T2*>
{
public:
Data() { cout << "Data<T1*, T2*>" << endl; }
private:
T1 _d1;
T2 _d2;
};
//两个参数偏特化为引用类型
template <typename T1, typename T2>
class Data <T1&, T2&>
{
public:
Data(const T1& d1, const T2& d2)
: _d1(d1)
, _d2(d2)
{
cout << "Data<T1&, T2&>" << endl;
}
private:
const T1& _d1;
const T2& _d2;
};
void test2()
{
Data<double, int> d1; // 调用特化的int版本
Data<int, double> d2; // 调用基础的模板
Data<int*, int*> d3; // 调用特化的指针版本
Data<double*, double*> d4; // 调用特化的指针版本
Data<int&, int&> d5(1, 2); //调用特化的引用版本
Data<char&, char&> d6('a', 'b'); //调用特化的引用版本
}
六、模板分离编译
📖什么是分离编译?
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有的目标文件链接起来形成一个可执行文件的过程就叫做分离编译模式。
📖模板的分离编译
将模板的声明与定义分离,在头文件中进行声明,源文件中完成定义。
// a.h
template<class T>
T Add(const T& left, const T& right);
//a.cpp
#include "a.h"
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
//text.cpp
#include "a.h"
int main()
{
Add(1, 2);
Add(1.1, 2.2);
return 0;
}
通过结果可以看出,上面的代码产生了error LNK2019
链接错误。为什么会这样呢?下面我们来分析一下原因。首先我们需要明确头文件是不会参与编译的,只有以.cpp
结尾的文件才能被编译,在预处理阶段,会把头文件的内容拷贝到包含了该头文件的.cpp
文件中,其次编译器对工程中的多个源文件是分离开单独编译,编译阶段干的主要工作是按照语言的特性进行词法、语法、语义分析,检查无误后生成汇编代码,最终的到一个.obj
结尾的目标文件。
📖先看没有使用模板的分离编译
// a.h
int Add(int left, int right);
//a.cpp
#include "a.h"
int Add(int left, int right)
{
return left + right
}
//text.cpp
#include "a.h"
int main()
{
Add(1, 2);
return 0;
}
以上面的代码为例,在编译text.cpp
时,编译器不知道Add
函数的实现,因为包含的a.h
头文件中只有关于Add
函数的一个声明,所以当编译器碰到对Add
函数的调用时只是给出一个指示,指示链接器应该为它寻找Add
函数的实现体。这也就是说test.obj
中没有关于Add
函数的任何一行二进制代码。
在编译a.cpp
的时候,编译器找到了Add
函数的实现。于是Add
的实现,也就是对应的二进制代码出现在a.obj
里。
链接时,链接器在a.obj
中找到Add
函数的实现代码(二进制)地址(通过符号表导出)。然后将test.obj
中悬而未定的call XXX
地址改成Add
函数的实际地址。
📖模板需要实例化
然而,对于模板来说,模板函数的代码其实并不能直接编译生成二进制代码,其中要有一个“实例化”的过程,以下面的代码为例:
//test.cpp
template<typename T>
void Swap(T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
int main()
{
int i1 = 10;
int i2 = 20;
Swap(i1, i2);
double d1 = 1.1;
double d2 = 2.2;
Swap(d1, d2);
}
如果在test.cpp
文件中没有调用Swap
函数,Swap
就得不到实例化,从而test.obj
中也就没有关于Swap
的任何一行二进制代码。如果像上面代码中那样,调用了Swap(i1, i2);
和Swap(d1, d2);
,此时test.obj
中就有了Swap<int>
和Swap<double>
两个函数的二进制代码段。
📖再来分析模板分离编译
//a.h
template<class T>
class A
{
public:
void f();//这里只是声明
};
//a.cpp
template<class T>
void A<T>::f()//模板的实现
{
//...实现
}
//test.cpp
int main()
{
A<int> a;
a.f();
return 0;
}
编译器在执行到a.f()
的时候并不知道A<int>::f
的定义,因为它不在a.h
里面,于是编译器只好寄希望于链接器,希望它能够在其他的.obj
文件里面找到A<int>::f
的实例,在本例中就是a.obj
中,然而a.obj
中是没有A<int>::f
的二进制代码。因为C++标准明确规定,当一个模板不被用到的时候,他就不该被实例化出来,a.cpp
中并有用到A<int>::f
,所以实际上a.cpp
编译出来的a.obj
文件中关于A::f
一行二进制代码也没有,因为没有进行任何的实例化,于是链接器就傻眼了,只好给出一个链接错误。
//a.cpp
//显式实例化
template
class A<int>;
如果在a.cpp
中加上上面这段代码,a.f();
就可以成功执行啦,上面这段代码会将模板专用化,于是,a.obj
的符号导出表中就有了A<int>::f
这个符号的地址,于是链接器就能够完成任务。但是如果在test.cpp
文件中再定义一个A<double> b;
,我们就需要在a.cpp
中再加入相应的显式实例化,模板的分离编译显然是麻烦的。
📖建议
将声明和定义放到一个文件xxx.hpp
里面或者xxx.h
其实也是可以的。在同一个文件中对模板的声明和定义进行分离是不会出现链接错误的。
注:模板分离编译这块参考了刘未鹏(pongba)大佬的文章,下面附上原文链接:传送门,感兴趣的小伙伴可以点进去看看。
七、模板总结
📖优点
- 模板复用了代码,将本来需要人去完成的工作交给编译器去做,使人们可以更快的迭代开发,C++的标准模板库(STL)因此而产生。
- 增强了代码的灵活性。
📖缺点
- 模板会导致代码膨胀问题,也会导致编译时间变长。
- 出现模板编译错误时,错误信息非常凌乱,不易定位错误。
结语:本篇文章只是基于语法层面对模板做了简单介绍,关于模板我们需要通过大量的实践,才能体会到模板的魅力以及它的语法细节。因此,在后面的文章中,我会通过模拟实现string和STL中的经典容器,来帮助大家更好的理解模板,感兴趣的小伙伴可以点下关注,更新时会第一时间告知。
好书推荐
在这里推荐两本我个人最近正在读的书,供暑假有意提升自己能力的小伙伴参考:
近期有购书需求的小伙伴可以直接点击下方书名前往选购!
NO.1《我看见了风暴:人工智能基建革命》
本书深入讲解了阿里、微软等业界巨头在人工智能技术领域的迭代历程,从框架设计、平台开发以及云基础设施等三个关键领域,对AI的发展历史进行详尽而深入的剖析,揭示对未来更远视野的洞察。
NO.2《趣话计算机底层技术》
本书的内容设计独特,通过富有吸引力的故事,深入浅出地解读了计算机中的CPU、存储、I/O、操作系统、系统编程以及安全六大主题。每一章都深入剖析了计算机的核心概念和关键技术,让读者在轻松的阅读时能够迅速提升自身计算机认知水平。