文章目录
写在前面
进入C++以后,C++支持了函数重载也就是在同一作用域中可以存在功能类似的同名函数,例如swap函数来完成int,double,char等数据类型的交换。
void swap(int& e1, int& e2)
{
int tmp = e1;
e1 = e2;
e2 = tmp;
}
void swap(double& e1, double& e2)
{
double tmp = e1;
e1 = e2;
e2 = tmp;
}
void swap(char& e1, char& e2)
{
char tmp = e1;
e1 = e2;
e2 = tmp;
}
但是函数重载面临的问题是:
- 重载的函数仅仅是类型不同,功能是类似,所以代码复用率比较低,而且一旦有新类型出现,就需要用户自己 增加对应的函数。
- 代码的可维护性比较低,一个出错可能所有的重载均出错
如果在C++中,能够存在这样一个模具,通过给这个模具中填充不同材料(类型),来获得不同材料的铸件(即生成具体类型的代码)。类似于活字印刷术,模板可以帮助我们快速生成我们所需的代码。
巧的是前人早已将树栽好,我们只需在此乘凉。
泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。
1. 函数模板
1.1 函数模板的概念
- 函数模板概念:函数模板代表了一个函数家族,该函数模板与类型无关,在使用时通过传递不同的类型参数来实例化不同类型的函数。这种机制使得代码更加简洁和通用,减少了重复代码的编写。
- 函数模板格式:定义一个函数模板时,需要使用关键字 template或者class(切记:不能使用struct代替class),后跟一个模板参数列表。
template<typename T1, typename T2,…,typename Tn>
返回值类型 函数名(参数列表){}
以下是一个简单的例子,展示了如何使用模板来创建一个通用的函数模板,它实现了两个数值的交换:
//template <typename T>
template <class T>
void swap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
1.2 函数模板的原理
大家都知道,瓦特改良蒸汽机,人类开始了工业革命,解放了生产力。机器生产淘汰掉了很多手工产品。本质是什么,重复的工作交给了机器去完成。有人给出了论调:懒人创造世界。马云曾经说过:"懒不是傻懒,如果你想少干,就要想出懒的方法。要懒出风格,懒出境界"。而函数模板是一个蓝图,它本身并不是函数,是编译器根据使用方式产生特定具体类型函数的模具。所以模板就是将本来应该我们做的重复的事情交给了编译器。
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然后产生一份专门处理double类型的代码。通过下面例子来理解一下:
#include <iostream>
using namespace std;
//函数模板
template<class T>
void Swap(T& e1, T& e2)
{
T tmp = e1;
e1 = e2;
e2 = tmp;
}
int main()
{
int a = 10, b = 20;
double x = 10, y = 20;
Swap(a, b);
Swap(x, y);
return 0;
}
通过汇编代码再看一下上面的两次Swap函数调用,也能看出调用的函数不是同一个,因为它们call的地址都不同。
1.3 函数模板的实例化
用不同类型的参数使用函数模板时,称为函数模板的实例化。模板参数实例化分为:隐式实例化和显示实例化。
-
隐式实例化:让编译器根据实参推演模板参数的实际类型。
例如上面的Swap(a, b) 和 Swap(x, y),编译器根据传递给模板函数的参数自动推导出模板参数的类型。实际上,在大多数情况下编译器都能够推导出模板参数的类型,但在下面这种情况,编译器推导不出来模板参数的具体类型,导致编译错误:
这是因为当调用 Add(e1, e2) 时,e1 的类型是 int,而 e2 的类型是 double,这种情况下编译器无法自动推导出模板参数 T,因为 int 和 double 是不同的类型。
解决办法一:自己来强制转化,使得e1和e2类型相同。
解决办法二:显示实例化,下面就来介绍一下什么是显示实例化。 -
显示实例化:在函数名后的<>中指定模板参数的实际类型。
在调用 Add<int> 时,将 e2 转换为 int 类型。
在调用 Add<double> 时,将 e1 转换为 double 类型。
通过显式类型转换,确保传递给模板函数的参数类型一致,从而避免编译错误。
1.4 函数模板的实例化模板参数的匹配原则
- 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数。
- 对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数, 那么将选择模板。
- 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换。
在C++中,模板函数不会进行隐式类型转换来匹配参数类型,而普通函数会进行隐式类型转换。如果模板函数不能完全匹配参数类型,编译器将尝试调用非模板函数,这时可能会发生隐式类型转换。
2. 类模板
- 类模板的定义格式:
template<class T1, class T2, …, class Tn>
class 类模板名
{
// 类内成员定义
};
这里,typename 是一个关键字,用于指定 T 是一个模板参数。你也可以使用 class 关键字来代替 typename,它们在这里是等价的。
在类模板中,如果要在类外定义成员函数,则需要在定义成员函数时提供模板参数列表。这是为了让编译器知道这些函数是属于哪个模板类的实例。下面的例子,展示了如何在类外定义类模板的成员函数:
// 类模板定义
template <class T>
class MyClass
{
private:
T data;
public:
MyClass(T d); // 构造函数声明
void Print(); // 成员函数声明
};
// 在类外定义构造函数
template <class T>
MyClass<T>::MyClass(T d) : data(d) {}
// 在类外定义成员函数
template <class T>
void MyClass<T>::Print()
{
cout << data << endl;
}
- 类模板的实例化
类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中即可,类模板名字不是真正的类,而实例化的结果才是真正的类。实例化类模板意味着根据模板定义创建具体的类,这些具体类使用特定的数据类型。
int main()
{
MyClass<int> m1;
MyClass<double> m1;
return 0;
}
3. 非类型模板参数
模板参数分类类型形参与非类型形参。
类型形参即:出现在模板参数列表中,跟在class或者typename之类的参数类型名称。
非类型形参,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
例如使用非类型形参来定义一个静态数组:
#include <iostream>
#include <assert.h>
using namespace std;
namespace zzb
{
template<class T, size_t N = 10>
class array
{
public:
T& operator[](size_t index)
{
assert(index >= 0 && index < _size);
return _nums[index];
}
const T& operator[](int index) const
{
assert(index >= 0 && index < _size);
return _nums[index];
}
size_t size() const
{
return _size;
}
size_t empty()const
{
return 0 == _size;
}
private:
T _nums[N];
size_t _size = N;
};
}
int main()
{
zzb::array<int, 10> nums;
return 0;
}
ps:浮点数、类对象以及字符串是不允许作为非类型模板参数的,也就说只有整数类型(包括枚举)可以作为非类型模板参数。
而且非类型模板参数必须在编译期确定,这意味着它们的值或大小必须在编译时就能确定,而不能依赖于运行时的计算或输入。这样做是为了在编译期间能够生成对应的代码,以便在程序运行时能够直接使用这些参数值。
4. 模板的特化
4.1 概念
模板的特化是允许我们为特定类型提供定制的实现,通常情况下使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理,在这些情况下是非常有用的。
比如:我们定义一个通用的模板函数来进行小于比较。
// 通用模板函数,用于比较两个值
template <typename T>
bool Less(const T& a, const T& b)
{
return a < b;
}
当我们调用函数模板来比较两个整数时,发现可以正常比较。
当传参传过去的是指针时,它是按照指针的大小来比较的,不是按照指针指向的内容来比比较的,不符合我们的预期。
可以看到,Less绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示例中,x是小于y的,但是Less内部并没有比较a和b指向的对象内容,而按照地址的大小比较的,这就无法达到预期而错误。此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
4.2 函数模板特化
函数模板的特化步骤:
1.必须要先有一个基础的函数模板,它可以处理大多数情况。
// 通用模板函数,用于比较两个值
template <typename T>
bool Less(const T& a, const T& b)
{
return a < b;
}
2.关键字template后面接一对空的尖括号<>。
3.函数名后跟一对尖括号,尖括号中指定需要特化的类型。
4.函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
//函数模板的特化
template <>
bool Less<int*>(const int*& a, const int*& b)
{
return *a < *b;
}
此时传参再传过去指针时,它就是按照指针指向的内容来比较的,符合我们的预期。这是因为特化模板在模板实例化时会优先于通用模板。
我们可以看出上面的特化版本看着特别奇怪 const 写在int* 后面,因此一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出。
bool Less(const int* a, const int* b)
{
return *a < *b;
}
该种实现简单明了,代码的可读性高,容易书写,因为对于一些参数类型复杂的函数模板,特化时特别给出,因此函数模板不建议特化。
4.3 类模板特化
例如有如下专门用来按照小于比较的类模板Less:
template<class T>
struct Less
{
bool operator()(const T& e1, const T& e2)
{
return e1 < e2;
}
};
调用sort函数排序一个整数数组,发下可以直接排序。
调用sort函数排序一个整型指针数组,可以直接排序,但是结果错误。
同理,这里也需要提供int*的特化版本,而特化分为全特化与偏特化。
1.全特化:将模板参数列表中所有的参数都确定化。
template<>
struct Less<int*>
{
bool operator()(int* e1, int* e2)
{
return *e1 < *e2;
}
};
上面提供的就是int*的全特化版本,模板参数列表中所有的参数都确定化了,此时运行程序,结果正确。
2.偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。
偏特化有以下两种表现方式:
- 参数更进一步的限制:偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。
template<class T>
struct Less<T*>
{
bool operator()(T* e1, T* e2)
{
return *e1 < *e2;
}
};
上面提供的就是T的偏特化版本,对模板参数列表中的参数进行了更进一步的条件限制,不仅能匹配int类型的,是能匹配所有类型的指针。
此时运行程序,结果也是正确的。
- 偏特化的另一种表现方式是部分特化:将模板参数类表中的一部分参数特化。
例如有如下类模板:
template <typename T1, typename T2>
class MyClass
{
public:
void print()
{
cout << "MyClass <T1, T2>" << endl;
}
};
对上面类进行部分特化:
template <typename T1>
class MyClass<T1, int>
{
public:
void print()
{
cout << "MyClass <T1, int>" << endl;
}
};
MyClass<T1, int> 是对第二个模板参数 T2 为 int 的情况进行的特化,在main 函数中,分别调用了基础模板和部分特化模板,可以看到编译器根据传入的类型选择了合适的模板版本。
5. 模板分离编译
分离编译:一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。 关于编译链接的相关知识可以看下之前的写的文章:详解C语言的编译与链接,这里不再赘述。
而模板分离编译的分离编译是将模板的声明和定义分离到不同的文件中,通常是声明放到xxx.h,定义放到xxx.cpp。
假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:
1.我们首先在头文件中声明函数模板,但不包括具体的实现。
//Swap.h
template<class T>
void Swap(T& e1, T& e2);
2.然后创建一个单独的实现文件,包含模板的完整定义。
//Swap.cpp
#include "Swap.h"
template<class T>
void Swap(T& e1, T& e2)
{
T tmp = e1;
e1 = e2;
e2 = tmp;
}
3.在使用模板的源文件test.cpp中,包含头文件Swap.h,然后调用模板Swap来完成两个数的交换。
//test.cpp
#include <iostream>
#include "Swap.h"
using namespace std;
int main()
{
int x = 10, y = 20;
cout << "交换前 x: " << x << " " << "y: " << y << endl;
Swap(x, y);
cout << "交换后 x: " << x << " " << "y: " << y << endl;
return 0;
}
运行程序,发现报如下错误:
这是因为,一个C/C++程序要变成一个可执行程序,要经过如下过程:
而编译器对工程中的源文件是单独编译的,因此:
对于上面的问题有如下两个解决办法:
1.在模板定义的位置显式实例化(不推荐)。
这种办法存在一定的弊端,当再有两个double类型的变量调用函数模板完成交换时,又会报错。
要想解决这个错误,就只能在模板定义的地方再去实例化一份Swap<double>的出来。因此每当出现一个新类型去调用这个模板的时候,都需要去模板定义的地方去显示实例化一份出来。这种显式实例化方式只适用于我们能预先知道所需类型的情况且这在泛型编程中并不常见,下面来介绍另一种解决方式。
2. 将声明和定义放到同一个文件 “xxx.hpp” 里面或者xxx.h(推荐)。
//Swap.h
template<class T>
void Swap(T& e1, T& e2)
{
T tmp = e1;
e1 = e2;
e2 = tmp;
}
这也就意味着,当在test.cpp中包含Swap.h的以后,在test.cpp中可以找到函数模板的完整定义,因此可以根据需求实例化出任意需要的函数,就不会报链接错误了。
6. 总结
关于模板的优缺点总结如下:
优点:
1.代码复用:通过模板,可以编写通用的代码,而不需要为每个数据类型编写单独的代码,实现了代码的高效复用。模板的使用加速了开发过程,因为可以更容易地引入新的数据类型,而无需修改大量现有代码。C++ 标准模板库(STL)的产生就是模板技术的重要应用之一,提供了大量高效的容器、算法和迭代器,极大地提高了开发效率。
增强代码的灵活性:
2.泛型编程:模板允许编写与类型无关的代码,可以处理不同类型的数据,增强了代码的通用性和灵活性。
缺陷:
1.代码膨胀:模板的实例化会导致生成多个实例代码,可能导致二进制文件变大,尤其是在大型项目中。模板实例化需要更多的编译时间,特别是当模板被广泛使用时,编译时间会显著增加。
2. 模板编译错误信息复杂:模板编译错误信息通常非常复杂且冗长,不易理解和调试,定位错误可能需要更多的时间和经验。
至此,本片文章就结束了,若本篇内容对您有所帮助,请三连点赞,关注,收藏支持下。
创作不易,白嫖不好,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
如果本篇博客有任何错误,请批评指教,不胜感激 !!!