文章目录
- 前言
- 泛型编程(模板)
- 1. 函数模板
- 1.1 函数模板格式
- 1.2 函数模板的实例化
- 隐式实例化
- 显式指定模板参数实例化
- 1.3 函数模板实例化的原理
- 1.4 模板参数的匹配原则
- 2. 类模板
- 2.1 类模板的格式
- 2.2 类模板的实例化
- 2.3 类模板实例化的原理
- 2.4 类模板的匹配原则
- 模板的特化
- 3. 函数模板特化
- 4. 类模板特化
- 4.1 全特化
- 4.2 偏特化
- 4.3 类模板特化应用示例
- 模板分离编译
- 5.什么是分离编译
- 5.1 函数模板的分离编译
- 5.2 类模板的分离编译
- 非类型模板参数
- 模板优缺
- 总结
前言
通过00【C++ 入门基础】前言得知,C++是为了解决C语言在面对大型项目的局限而诞生:
C语言面对的现实工程问题(复杂性、可维护性、可扩展性、安全性)
C语言面临的具体问题:
struct 数据公开暴露,函数数据分离,逻辑碎片化。(复杂性、安全性)
修改数据结构,如 struct 新增字段,可能导致大量相关函数需要修改。(可维护性)
添加新功能常需修改现有函数或结构体,易引入错误。(可扩展性)
资源(内存、文件句柄)需手动管理,易泄漏或重复释放。(安全性)
之前我们知道了,函数重载,可以帮我们解决:类似功能(加法)的函数命名域污染问题,这解决了我们代码的复杂性问题,但是没有很好的实现对代码的复用性。
本文讲解的泛型编程,就是C++用于对代码复用、代码可维护性、类型安全的手段之一,不仅对于函数,对于类也可以。
泛型编程(模板)
我们尝试实现一个简单的交换函数:
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;
}
我们可以根据参数类型不同,重载多个功能相同的同名函数,命名重复的问题我们通过函数重载的问题解决了,但是有多少个类似的应用场景,我们就需要手动的实现多少个,所以它就有以下一些不好的地方:
- 重载的函数仅仅是类型不同,代码复用率比较低,只要有新类型出现时,就需要用户自己增加对应的函数。
- 代码的可维护性比较低,一个出错可能所有的重载均出错。
那能否告诉编译器一个模子,让编译器根据不同的类型利用该模子来生成代码呢?
如果在C++中,也能够存在这样一个模具,通过给这个模具中填充不同材料(类型),来获得不同材料的铸件(即生成具体类型的代码),那将会节省许多头发。
这个“模具”(函数模版、类模版),就是我们要编写的通用代码,最终我们通过传不同的参数,让编译器自动生成对应的“铸件”(函数、类)。
泛型编程:编写与类型无关的通用代码,最终是代码复用的一种手段。模板是泛型编程的基础。
1. 函数模板
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。
1.1 函数模板格式
template<typename T1, typename T2,…,typename Tn>
返回值类型 函数名(参数列表){}
现在我们使用函数模版,再去实现一个交换函数:
template<typename T>
void Swap( T& left, T& right)
{T temp = left;left = right;right = temp;
}
注意:typename是用来定义模板参数关键字,也可以使用class(切记:不能使用struct代替class)
1.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);/*该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型通过实参a1将T推演为int,通过实参d1将T推演为double类型,但模板参数列表中只有一个T,编译器无法确定此处到底该将T确定为int 或者 double类型而报错注意:在模板中,编译器一般不会进行类型转换操作,因为一旦转化出问题,编译器就需要背黑锅Add(a1, d1);*/// 此时有两种处理方式:// 1. 用户自己来强制转化Add(a1, (int)d1); // 2. 使用显式实例化//TODO...return 0;
}
显式指定模板参数实例化
在函数名后的<>中指定模板参数的实际类型,相当于给模版传一个类型参数。
template<class T>
T Add(const T& left, const T& right)
{return left + right;
}
int main()
{int a = 10;double b = 20.0;// 显式传参实例化Add<int>(a, b);return 0;
}
如果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器将会报错。
1.3 函数模板实例化的原理
函数模板是一个蓝图,它本身并不是函数,是我们写给编译器看的模具,最终编译器再根据我们给这个模具传入的材料(类型),去生成最终的函数。
所以其实模板就是将本来应该我们做的重复的事情(重复实现多个功能类似的函数)交给了编译器去做。
函数的隐式实例化:
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。
比如:当用double类型的参数传入函数模板使用时,编译器通过对实参类型的推演,将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。
我们知道普通的一个函数调用,编译器会根据AST树的域节点向上查找,直到找到与调用处函数名称相同的声明,然后根据函数的参数,生成唯一的函数符号,这个函数符号将会持续到链接过程。但是我们的模板呢?
如果编译器找到与函数调用处同名的是一个函数模板,那么如果遇到函数的模版,编译器就会根据函数调用处的参数,自动的推导出这个函数的声明,并自动生成这个函数的定义,然后给函数调用处推导出一个符号,这个符号照样会持续到链接过程,最终由链接器,生成不同的函数地址:
显式指定模板参数实例化:
实例化行为与隐式推导完全相同,只是显示传入参数。
1.4 模板参数的匹配原则
我们知道编译器会自动去查找到函数调用处同名的函数声明或者定义,其实如果有多个同名,它会怎么办呢?
编译器的处理步骤:
-
名称查找:
编译器在main函数中看到MyNS::foo(42)。
它首先解析MyNS命名空间,然后在MyNS的作用域内查找名为foo的所有函数。
它成功找到了三个候选函数:#1, #2, #3。至此,名称查找完成。 -
重载解析:
编译器开始分析调用foo(42),实参是int类型的42。
它将实参int(42)与每个候选函数的形参进行匹配:
匹配 #1 foo(int):精确匹配!不需要任何转换。这是最好的情况。
匹配 #2 foo(double):需要标准转换(int-> double)。匹配度良好,但不如精确匹配。
匹配 #3 foo(char):需要标准转换(int-> char)。但这是一个窄化转换(可能丢失信息),在重载解析中优先级通常低于提升或标准算术转换,几乎不会被选择。
匹配 #4 template foo(T):通过模板转换成foo(int)。匹配度良好,但不如精确匹配。
结果:编译器确定#1是最佳匹配。
- 代码生成:一旦重载解析完成,编译器就确切地知道该调用哪个函数了。
此时,编译器才会根据这个具体函数的签名(名称+参数类型)去进行名称修饰,生成一个唯一的链接符号(例如,_ZN4MyNS3fooEi可能代表MyNS::foo(int))。
此时如何选中模板,就会根据模板和选中的参数生成代码,
这个修饰后的符号会被写入目标文件,链接器后续会使用它来找到函数的定义。
其实编译器在查找的时候,会找到所有与函数调用处同名的候选者,编译器会把所有找到的同名函数(无论参数如何)都加入到候选列表中,最后选举出与调用处最匹配的函数,如果选的是普通函数就正常的生成符号,并在调用处(Call)生成的符号;如果选的是模板就根据类型生成定义然后再在调用处生成(Call)符号。
(1)一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数
// 专门处理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版本
}
(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;
}
void Test()
{Add(1, 2); // 与非函数模板类型完全匹配,不需要函数模板实例化Add(1, 2.0); // 模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函数
}
(3)模板函数不允许自动类型转换,但普通函数可以进行自动类型转换
说的是我们模板函数接收的类型参数不可以私自转换,但是我们普通函数接受的参数,可以强转为别的类型。
2. 类模板
2.1 类模板的格式
template<class T1, class T2, ..., class Tn>
class 类模板名
{// 类内成员定义
};
2.2 类模板的实例化
类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中即可,类模板名字不是真正的类,而实例化的结果才是真正的类。
// 动态顺序表
// 注意: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; }T& operator[](size_t pos){assert(pos < _size);return _pData[pos];}private:T* _pData;size_t _size;size_t _capacity;
};
// 注意:类模板中函数放在类外进行定义时,需要加模板参数列表
template <class T>
Vector<T>::~Vector()
{if (_pData)delete[] _pData;_size = _capacity = 0;
}// Vector类名,Vector<int>才是类型
int main()
{Vector<int> s1;Vector<double> s2;
}
2.3 类模板实例化的原理
类模板的实例化和函数的显示实例化原理相似,编译器通过名称的查找到同名类型,然后通过显示传入的参数,去生成对应的实体代码。
2.4 类模板的匹配原则
类模板的成员函数(尤其是构造函数)在调用时,会经历与函数模板非常相似的重载解析和实例化过程,但类模板本身也有一些特殊性。
-
类模板实例化 vs 成员函数调用:
类模板实例化: 当你使用一个类模板时(如 MyClass obj;),编译器会根据提供的模板参数(int)实例化整个类模板。这意味着:
生成一个具体的类类型 MyClass。
实例化该类所有非模板、非虚的成员变量声明(类型确定)。
但不会立即实例化所有成员函数的定义体! 这是关键。
成员函数调用: 当你调用一个类模板实例的成员函数时(如 obj.doSomething(42);),过程与函数模板调用类似:
名称查找: 在 MyClass的作用域内查找 doSomething的声明(包括普通成员函数、成员函数模板、从基类继承的成员等)。
重载解析: 对找到的候选(包括成员函数模板推导出的签名)进行匹配,选择最佳匹配。
实例化: 只有当选中的成员函数是一个模板,或者它属于一个尚未完全实例化的类模板时,编译器才会在需要时实例化该特定成员函数的定义体。这就是 “惰性实例化” (Lazy Instantiation) 原则:只实例化真正被用到的成员函数。 -
构造函数的重载解析:
构造函数的调用(如 MyClass obj(42);)是类模板成员函数调用的一个特例,也是最常见的场景。
过程完全遵循上述成员函数调用的规则:
名称查找:在 MyClass中查找构造函数声明。
重载解析:将实参 42(int) 与所有候选构造函数(包括构造函数模板推导出的签名)进行匹配,选择最佳匹配(如 MyClass::MyClass(int))。
实例化:如果选中的构造函数是模板或者尚未实例化,则实例化它的定义体。
过程是否一样?
核心机制相同: 成员函数(包括构造函数)的重载解析过程(名称查找、生成候选集、参数匹配、选择最佳)与普通函数和函数模板的机制在逻辑上是一致的。都涉及模板参数推导(如果是模板成员函数)和转换序列比较。
作用域不同: 名称查找发生在类作用域(MyClass::)内,而不是全局或命名空间作用域。
实例化触发点: 类模板的实例化(生成类类型)是成员函数调用的前提。成员函数的实例化发生在类实例化之后,并且是按需(惰性)进行的。
特殊成员: 类模板的友元函数可能涉及 ADL,规则更复杂一些。
总结(类模板):
类模板本身的实例化(MyClass)会生成一个具体的类类型。
对该类实例的成员函数的调用(包括构造函数),会触发一个与函数模板调用非常相似的重载解析和按需实例化过程。
核心区别在于作用域(类作用域)和实例化的层次(先实例化类,再按需实例化成员函数)。
模板的特化
概念: 通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{return left < right;
}
int main()
{cout << Less(1, 2) << endl; // 可以比较,结果正确Date d1(2022, 7, 7);Date d2(2022, 7, 8);cout << Less(d1, d2) << endl; // 可以比较,结果正确,内部会调用Date类的operate<赋值重载函数.Date* p1 = &d1;Date* p2 = &d2;cout << Less(p1, p2) << endl; // 可以比较,结果错误,指针之间的比较没有意义,要正确比较的逻辑,我们就需要对该模板特化一下.return 0;
}
可以看到,Less绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示例中,p1指向的d1显然小于p2指向的d2对象,但是Less内部并没有比较p1和p2指向的对象内容,而比较的是p1和p2指针的地址,这就无法达到预期而错误。
此时,就需要对模板进行特化。
模板的特化: 在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
3. 函数模板特化
函数模板的特化步骤:
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{return left < right;
}
// 对Less函数模板进行特化
template<>
bool Less<Date*>(Date* left, Date* right)
{return *left < *right;
}
int main()
{cout << Less(1, 2) << endl;Date d1(2022, 7, 7);Date d2(2022, 7, 8);cout << Less(d1, d2) << endl;Date* p1 = &d1;Date* p2 = &d2;cout << Less(p1, p2) << endl; // 调用特化之后的版本,而不走模板生成了return 0;
}
注意:一般情况下如果函数模板遇到模板特化比较复杂,类型不能处理或者处理有误的情况,通常直接将该函数给出更加简单方便。
bool Less(Date* left, Date* right)
{return *left < *right;
}
4. 类模板特化
类模板只可以使用显示的传类型实例化,不可以隐式的推导类型,因为我们调用构造函数传参的过程,不一定会传入类型。
4.1 全特化
全特化即是将模板参数列表中所有的参数都确定化。
template<class T1, class T2>
class Data
{
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};
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;
}
4.2 偏特化
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。偏特化可以从两个方面。
比如对于以下模板类:
template<class T1, class T2>
class Data
{
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};//(1). 将模板参数类表中的一部分参数特化:
// 将第二个参数特化为int
template <class T1>
class Data<T1, int>
{
public:Data() { cout << "Data<T1, int>" << endl; }
private:T1 _d1;int _d2;
};//(2). 参数更进一步的限制:
//两个参数偏特化为指针类型
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<int, double> d2; // 调用基础的模板 Data<double, int> d1; // 调用特化的int版本Data<int*, int*> d3; // 调用特化的指针版本Data<int&, int&> d4(1, 2); // 调用特化的指针版本
}
类模板的偏特化,可以从两个方面:
- 将模板参数类表中的一部分参数特化
- 参数更进一步的限制
4.3 类模板特化应用示例
有如下专门用来按照小于比较的类模板Less,使用重载operate()实现像使用函数一样使用该类:
#include<vector>
#include <algorithm>
using namespace std;
template<class T>
struct Less
{bool operator()(const T& x, const T& y) const{return x < y;}
};int main()
{Date d1(2022, 7, 7);Date d2(2022, 7, 6);Date d3(2022, 7, 8);vector<Date> v1;v1.push_back(d1);v1.push_back(d2);v1.push_back(d3);// 可以直接排序,结果是日期升序sort(v1.begin(), v1.end(), Less<Date>());vector<Date*> v2;v2.push_back(&d1);v2.push_back(&d2);v2.push_back(&d3);// 可以直接排序,结果错误日期还不是升序,而v2中放的地址是升序// 此处需要在排序过程中,让sort比较v2中存放地址指向的日期对象// 但是走Less模板,sort在排序时实际比较的是v2中指针的地址,因此无法达到预期sort(v2.begin(), v2.end(), Less<Date*>());return 0;
}
通过观察上述程序的结果发现,对于日期对象可以直接排序,并且结果是正确的。但是如果待排序元素是指针,结果就不一定正确。因为:sort最终按照Less模板中方式比较,所以只会比较指针,而不是比较指针指向空间中内容,此时可以使用类版本特化来处理上述问题:
// 对Less类模板按照指针方式特化
template<>
struct Less<Date*>
{bool operator()(Date* x, Date* y) const{return *x < *y;}
};
特化之后,在运行上述代码,就可以得到正确的结果。
这种通过operate()重载,让类的使用和函数一样的,我们称它为仿函数,仿函数最常用的使用场景是在算法中传入,代替函数指针使排序顺序改变。
为什么函数重载没有偏特化?
- 因为函数有函数重载,而类没有,所以函数不需要特化,而类需要。
- 如果允许函数模板偏特化,会导致规则冲突:
template <typename T> void foo(T); // #1 主模板
template <typename T> void foo(T*); // #2 指针的重载
template <typename T> void foo<int>(T); // #3 伪偏特化(如果允许)foo(42); // 应匹配#1还是#3?
foo(&x); // 应匹配#2还是#1的特化?
模板分离编译
5.什么是分离编译
我们知道一个C++程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件,这个过程我们称为分离编译模式。
5.1 函数模板的分离编译
假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:
// a.h
template<class T>
T Add(const T& left, const T& right);
// a.cpp
template<class T>
T Add(const T& left, const T& right)
{return left + right;
}
// main.cpp
#include"a.h"
int main()
{Add(1, 2);Add(1.0, 2.0);return 0;
}
解决方法:
- 模板的定义直接放在头文件中 “xxx.hpp” 或者"xxx.h"里面,这样在包含头文件的地方,编译器就能看到完整的定义,从而在需要时实例化模板。(推荐这种做法)
// a.h
template<class T>
T Add(const T& left, const T& right)
{return left + right;
}// main.cpp
#include"a.h"
int main()
{Add(1, 2);Add(1.0, 2.0);return 0;
}
- 模板定义的位置显式实例化,这样编译器会针对a.cpp中的显示实例化,生成对应类型的普通函数实体,这样就可以像普通的函数一样,具有外部连接属性,在最后被main.cpp链接的时候找到并使用。
// a.h
template<class T>
T Add(const T& left, const T& right);// a.cpp
template<class T>
T Add(const T& left, const T& right)
{return left + right;
}
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);
template float Add<float>(const float&, const float&);// main.cpp
#include"a.h"
int main()
{Add(1, 2);Add(1.0, 2.0);return 0;
}
推荐第一种做法,因为第一种做法可以最大限度的发挥我们使用模板的优势,第二种方式有点华而不实了。
5.2 类模板的分离编译
类模板的分离编译和函数模板类似。
非类型模板参数
模板参数分类类型形参与非类型形参。
类型形参即:出现在模板参数列表中,跟在class或者typename之类的参数类型名称。
非类型形参,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
// 定义一个模板类型的静态数组
template<class T, size_t N = 10>
class array
{
public:T& operator[](size_t index) { return _array[index]; }const T& operator[](size_t index)const { return _array[index]; }size_t size()const { return _size; }bool empty()const { return 0 == _size; }private:T _array[N];size_t _size;
};
注意:
- 浮点数、类对象以及字符串是不允许作为非类型模板参数的。
模板优缺
【优点】
- 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
- 增强了代码的灵活性
【缺陷】 - 模板会导致代码膨胀问题,也会导致编译时间变长
- 出现模板编译错误时,错误信息非常凌乱,不易定位错误
总结
- 泛型编程在C++中就是模板,是一种代码复用、代码可维护性的手段,它会根据传入的参数自动的推导类型。
- 函数模板可以隐式实例化和显示传入参数的实例化。
- 函数模板的匹配原则在C++编译器自动查找函数调用的前提下,根据推导出的模板参数(如 double或 char)去实例化该模板的完整定义(函数体代码)。
- 类模板的匹配原则在整体类型的实例化上和函数的过程相似,但是其内部模板成员函数的调用,也依旧遵循模板函数的匹配原则,即作用域中查找和按需求实例化,没有使用到的模板成员变量和模板成员函数是不会被实例化的。
- 模板我们可以提前按照一些特殊情况写出一些特殊的模板,让编译器在查找的过程中根据优先匹配原则,优先匹配到,特殊的去实例化,即模板的特化。
- 函数模板不可以偏特化,因为已经有函数重载了。
- 类模板可以偏特化,因为它没有重载。
- 类模板的特化可以分为针对部分参数的类型指定和对参数的进一步限制。
- 模板的分离编译会导致编译器扫描不到不同编译单元之间的模板定义,从而无法根据定义生成最终的代码,从而找不到类、函数的定义,从而报错。
- 非类型
本文章为作者的笔记和心得记录,顺便进行知识分享,有任何错误请评论指点:)。