泛型编程模板
约 3383 字大约 11 分钟
2025-06-21
1.泛型编程
在 C 语言中是针对具体的类型编程的,但是 C++ 解决了这样的问题。最典型的就是使用交换函数 Swap() 的时候:
//C code
void SwapInt(int* x, int* y);
void SwapDouble(double* x, double* y);
//……使用 C 编写函数,我们会很容易发现一个问题,只要类型不符合 swap() 的参数,我们就必须写新的交换函数,让它的参数符合交换数据的类型(尽管它们的功能都是交互两个数据!),明明是统一操作的函数,却需要写出不同的调用 SwapInt()、SwapDouble()、...。
而 C++ 支持的“引用”和“重载”可以让函数调用变成相同的,这看起来就使用了同一个函数。
//C++ code
void Swap(int& x, int& y);
void Swap(double& x, double& y);
//……调用函数的时候,只需要使用 Swap() 一个就够了,这大大提高了代码的可读性。
但是依旧有一些重复性的本质问题没有解决:“我们还是需要编写多个不同类型的 Swap() 来支持重载”,因此,C++ 又增加了一个“模板”的语法,使得不同类型的数据都可以用同一个模板生成的 swap() 函数,这种编程方法就是 泛型编程,这种编程风格能适用大多的类型,也让程序的编写难度提高了许多,后续我们要学习的 STL 就是模板大师的作品,不过在领略次之前,您还需要考虑一些问题:什么是模板?函数模板、类模板又是什么?我们为什么需要模板?怎么写模板?
2.函数模板
2.1.基本格式
template <typename T1, typename T2, …, typename Tn>//其中 typename 可以换成 class
函数返回值 函数名()
{...}2.2.隐式实例化
#include <iostream>
using namespace std;
template <typename T>//“虚拟类型 T”或“广泛类型 T”
void Swap(T& left, T& right)
{
T tmp = left;
left = right;
right = tmp;
}
int main()
{
int i = 10;
int j = 20;
double x = 3.14;
double y = 8.9;
Swap(i, j);//1
cout << "i,j=" << i << " " << j << endl;
Swap(x, y);//2
cout << "x,y=" << x << " " << y << endl;
return 0;
}我们可以看到这个虚拟类型 T 很像是函数参数,我们姑且可以叫“类型参数”,平时我们使用的参数姑且可以叫“数据参数”,也就是说,C++ 不仅可以传递“数据参数”还可以传递“类型参数”。
补充:
C++其实本身也有一个swap()专门用于交换,底层就是采用函数模板的。
在使用模板的时候需要注意隐式类型转化:
#include <iostream>
using namespace std;
template <typename T1, typename T2>//“虚拟类型 T”或“广泛类型 T”
T2 Add(const T1& x, const T2& y)
{
return x + y;
}
int main()
{
int a = 10;
double b = 1.23;
printf("%lf" ,Add(a, b));
return 0;
}2.3.显式实例化
上述 Add() 的问题还能通过显式实例化来解决,调用得语句如下:
#include <iostream>
using namespace std;
template <typename T>//“虚拟类型 T”或“广泛类型 T”
T Add(const T& x, const T& y)
{
return x + y;
}
int main()
{
int a = 10;
double b = 1.23;
Add<double>(a, b);//相当于类型也被手动传递过去了
return 0;
}#include <iostream>
using namespace std;
template <typename T>//“虚拟类型 T”或“广泛类型 T”
T* init(int n)
{
T* p = new T[n];
return p;
}
int main()
{
init<int>(10);//不用显式实例化是没有办法调用这个函数的
return 0;
}有的时候,隐式实例化会导致无法调用,这里举一个例子:
#include <iostream>
using namespace std;
template<class T>
T* Function(int n)
{
return new T[n];
}
int main()
{
Function(10);//这个函数没有办法调用
return 0;
}2.4.一些新的问题
如果模板和模板的其中一个实例同时存在,则会优先使用实例,下面代码您可以尝试在编译器中调试一下,看看代码的跳转逻辑:
#include <iostream>
using namespace std;
template<typename T>
T Add(T x, T y)
{
return x + y;
}
int Add(int x, int y)
{
return x + y;
}
int main()
{
int i = 2, j = 3;
double x = 5, y = 4;
cout << Add(i, j) << endl;
cout << Add(x, y) << endl;
return 0;
}当然这种 code 是不建议写出来的,这里仅仅是作为演示……
2.5.模板参数推演与实例化
上述例子中 1 和 2 处使用的 Swap() 是否一样呢?不一样!模板会先对参数进行推演然后进行实例化。这两个步骤都交给了编译器来操作,理论上是会增加编译器的一点负担(编译复杂度的提高),但合理使用的话其实还好。
为什么 C 没有比较官方的数据结构和算法库呢?很大的原因就是因为写出来的库不具有泛化的特点,冗余度很大。
注意 1:
auto不能使用在函数形参部分,不要和模板混淆了。注意 2:模板运行时不检查数据类型,也不保证类型安全,相当于类型的宏替换,因此不保证类型安全。
注意 3:模板是可移植的,只要支持模板语法,模板的代码就是可移植的。
3.类模板
类模板的使用频率要比函数模板要高,类模板实际上是为了解决 typedef 的问题的。
#include <iostream>
using namespace std;
typedef int STDataType;
//这里的 typedef 有点泛型编的意思
//但不是真正的泛型编程
//没有办法交给编译器自动替换类型
//只能自己手动更改类型
class Stack
{
public:
Push(STDataType x)
{}
private:
STDataType* _data;
int _top;
int _capacity;
};
int main()
{
Stack s1;//存储 int 的栈
s1.Push(10);//这个倒是没有什么问题
Stack s2;//存储 float 的栈
s2.Push(10.2);//这里就出现问题了,没有办法存储多种类型
return 0;
}注意:类模板是定义了一个模板,用于生成具有相同结构的类;而模板类是通过实例化类模板生成的具体类。
3.1.基本格式
因此我们需要使用类模板,类模板的基本格式如下:
template<class T1, class T2, ..., class Tn>//也可以写成 typename
class Stack
{...}
//需要注意的是,类模板必须使用显式实例化,不能隐式推导3.2.使用例子
需要我们注意的是,在代码中使用类模板,一定要显式使用:
#include <iostream>
using namespace std;
template<class T>//也可以写成 typename
class Stack
{
public:
Stack(size_t capacity = 4)
:_data(nullptr), _top(0), _capacity(0)//最后一个是为了防止 Stack 传 0 的情况
{
if (capacity > 0)
{
_data = new T[capacity];
_capacity = capacity;
_top = 0;
}
}
private:
T* _data;
size_t _top;
size_t _capacity;
};
int main()
{
Stack<int> a(10);//1,显式使用
Stack<char> b(5);//2,显式使用
Stack<double> c(8);//3,显式使用
return 0;
}使用类模板的时候也需要注意,上述例子中 1、2、3 使用的也不是同一个类型。接下来让我们把上面得 Stack 类写得更加完整一些:
#include <iostream>
#include <cassert>
using namespace std;
template<typename T>//也可以改 typename 成 class
class Stack
{
public:
//2.构造函数
Stack(size_t capacity = 4)//默认在创建对象的时候开辟 4 个空间
:_data(nullptr), _top(0), _capacity(0)//初始化列表初始化
{
cout << "Stack(size_t capacity = 4)" << endl;//打印,表示调用了构造函数
if (capacity > 0)//小于 0 就无法创建一个栈
{
_data = new T[capacity];//创建了容量为 capacity 的数组
_capacity = capacity;
_top = 0;
}
else
{
cout << "栈容量不合法" << endl;
}
}
//3.析构函数
~Stack()
{
cout << "~Stack()" << endl;
delete[] _data;//释放资源
_capacity = _top = 0;
}
//4.入栈
void Push(const T& x)
{
if (_top == _capacity)//扩容机制
{
size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
T* tmp = new T[newCapacity];
if (_data)//如果数组之前申请成功
{
memcpy(tmp, _data, sizeof(T) * _top);//复制一份
delete[] _data;
}
_data = tmp;
_capacity = newCapacity;
}
_data[_top] = x;
++_top;
}
//5.出栈
void Pop()
{
assert(_top > 0);
_top--;
}
//6.判栈
bool Empty()
{
return _top == 0;
}
//7.取栈顶
T& Top()//这里最好加上引用,这里还可以替代修改栈顶数据的功能,不过如果在最前面加上 const 就不行了,这具体看需求
{
assert(_top > 0);
return _data[_top - 1];
}
private:
//1.定义栈类的相关成员变量
T* _data;//存储栈数据的数组
size_t _top;//栈顶
size_t _capacity;//栈容量
};
int main()
{
//有关 try 和 catch 配合 new 的使用以后我们会提及
try//这里是申请空间成功的做法
{
Stack<int> a(10);
a.Push(1);
a.Push(2);
a.Push(3);
a.Push(4);
a.Push(5);
a.Push(6);
a.Top()++;
while (!a.Empty())
{
cout << a.Top() << " ";
a.Pop();
}
cout << endl;
Stack<float> a(10);
a.Push(1.1);
a.Push(2.2);
a.Push(3.3);
a.Push(4.4);
a.Push(5.5);
a.Push(6.6);
a.Top()++;
while (!a.Empty())
{
cout << a.Top() << " ";
a.Pop();
}
cout << endl;
}
catch (exception& e)//这里是申请空间失败的做法
{
cout << e.what() << endl;
}
return 0;
}4.新的问题
模板是不支持分离文件编译(这是指声明放在 .h,定义放在 .cpp,而不是指在一个文件内函数的声明和定义分离),如果您这样做了,编译会报错,最好是单独放在一个文件里,原因我们以后再提及。
不过可以先看看下面的 code,下面代码我们将类模板的定义和声明放在了一个文件中:
#include <iostream>
#include <cassert>
using namespace std;
template<class T>//也可以写成 typename
class Stack
{
public:
Stack(size_t capacity = 4)
:_data(nullptr), _top(0), _capacity(0)
{
cout << "Stack(size_t capacity = 4)" << endl;
if (capacity > 0)
{
_data = new T[capacity];
_capacity = capacity;
_top = 0;
}
}
~Stack()
{
cout << "~Stack()" << endl;
delete[] _data;
_capacity = _top = 0;
}
void Push(const T& x);
void Pop()
{
assert(_top > 0);
_top--;
}
bool Empty()
{
return _top == 0;
}
T& Top()//这里最好加上引用,这里还可以替代修改栈顶数据的功能,不过如果在最前面加上 const 就不行了,这具体看需求
{
assert(_top > 0);
return _data[_top - 1];
}
private:
T* _data;
size_t _top;
size_t _capacity;
};
template<class T>//这里是声明一下模板参数
void Stack<T>::Push(const T& x)//类模板声明与定义分离(同一个文件下)
{
if (_top == _capacity)
{
size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
T* tmp = new T[newCapacity];
if (_data)//如果栈不为空
{
memcpy(tmp, _data, sizeof(T) * _top);
delete[] _data;
}
_data = tmp;
_capacity = newCapacity;
}
_data[_top] = x;
++_top;
}
int main()
{
//有关 try 和 catch 配合 new 的使用以后我们会提及
try//这里是申请空间成功的做法
{
Stack<int> a(10);
a.Push(1);
a.Push(2);
a.Push(3);
a.Push(4);
a.Push(5);
a.Push(6);
a.Top()++;
while (!a.Empty())
{
cout << a.Top() << " ";
a.Pop();
}
cout << endl;
}
catch (exception& e)//这里是申请空间失败的做法
{
cout << e.what() << endl;
}
return 0;
}类模板只需要放在一个单独的文件就可以(一般也是这么做的),由于这个文件比较特殊,既有声明又有定义。因此我们可以叫这个新的文件为 .hpp,代表“既有定义又有声明的意思”。
实际上,对于类模板来说,放在 .h 文件和 .hpp 文件都是可以的(推荐使用后者)。
补充:对于类模板来说,要分清楚两个概念:“类的类名、类的类型”。在上述代码中,
Stack是类的类名,而Stack<T>是类的类型。在没学类模板之前,类的函数声明和定义分离时,函数定义需要使用“类的类名”和“作用域访问限定符”来确定是类内部的函数:
class Data { public: void Print(); }; void Data::Print() { //函数的具体定义 }而学习和使用了类模板后,就会发现这里使用的不是“类的类名”而是“类的类型”:
template<typename T> class Data { public: void Print(); }; template<class T>//这里是单独声明一下模板参数 void Data<T>::Print() { //函数的具体定义 }
5.模板缺省值 ==== ===
既然函数形参有缺省值,模板里的类型是否也有缺省值呢?答案是有的:
template <typename T = 缺省类型>
//后续要使用缺省类型可以如此调用:Stack <> sta;还有一些关于模板的更加复杂的知识我们以后再来做详细解答……
6.模板参数包
这一语法承自 C 语言的可变参数列表,分为“模板参数包”和“函数形参参数包”,是一一对应关系。
#include <iostream>
#include <string>
using namespace std;
template<class ...Args>
void Func(Args... args)
{
cout << sizeof...(args) << endl;//输出具体的参数个数
}
int main()
{
Func();
Func<int>(1);
Func<int, char>(100, 'c');
Func<double, char, int>(3.14, 'c', 230);
Func<string, double, char, int>("limou", 0.984, 'x', 860);
return 0;
}那怎么解析参数包呢,C++ 使用的解析方法很奇怪,是使用递归来操作的:
#include <iostream>
#include <string>
using namespace std;
//终止条件的重载函数
void Func() { cout << '\n'; }
template<class T, class ...Args>
void Func(const T& val, Args... args)
{
cout << val << " ";
Func(args...);
}
int main()
{
Func();
Func<int>(1);
Func<int, char>(100, 'c');
Func<double, char, int>(3.14, 'c', 230);
Func<string, double, char, int>("limou", 0.984, 'x', 860);
return 0;
}还可以有以下两种写法:
#include <iostream>
#include <string>
using namespace std;
//终止条件的重载函数
void _Func() { cout << '\n'; }
template<class T, class ...Args>
void _Func(const T& val, Args... args)
{
cout << val << " ";
_Func(args...);
}
template<class ...Args>
void Func(Args... args)
{
_Func(args...);
}
int main()
{
Func();
Func<int>(1);
Func<int, char>(100, 'c');
Func<double, char, int>(3.14, 'c', 230);
Func<string, double, char, int>("limou", 0.984, 'x', 860);
return 0;
}#include <iostream>
#include <string>
using namespace std;
//终止条件的重载函数
template <class T>
void PrintArg(T t)
{
cout << t << " ";
}
//展开函数
template <class ...Args>
void Func(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
int main()
{
Func(1);
Func(1, 'A');
Func(1, 'A', string("sort"));
return 0;
}那什么地方使用模板参数包呢?用在线程上比较多。
另外,还有一个地方也使用了参数包,就是在 C++ 11 后新增加的容器接口 emplace 系列,有些人认为这个接口比 push 类的接口要高效,但这样的说法有些片面。