在编写 C++ 程序时,我们经常会遇到 “处理逻辑完全一样,仅仅是数据类型不同” 的情况。如果每次换一种数据类型,都要把相同的代码重新写一遍,不仅非常繁琐,后期修改和维护起来也很麻烦。
为了解决这个问题,C++ 提供了 “模板(template)” 机制。简单来说,模板就是先不把代码里的数据类型写死,而是把它当成一个可变的参数,让同一份代码能够适配多种数据类型。
为什么要使用模板?
假设我们要编写一个求和函数 add(),用来计算两个数相加的结果。如果既想支持整数 int,又想支持小数 double,按照前面学过的函数重载,我们需要编写两个同名函数:
// 计算两个 int 相加
int add(int a, int b) {
return a + b;
}
// 计算两个 double 相加
double add(double a, double b) {
return a + b;
}对比上面两个 add() 函数可以发现,除了参数和返回值的类型一个是 int、一个是 double 之外,函数体内部的代码 return a + b; 完全一样。
如果后面还需要支持 float、long long 等更多类型,难道要把相同的函数体复制粘贴五六遍吗?一旦以后需要调整函数里的计算逻辑,我们还得把每一个重载版本挨个改一遍,非常容易漏改出错。
既然函数体里的计算步骤完全相同,唯一变化的地方只有数据类型,我们不妨拿熟悉的普通函数参数来对比理解:
- 普通函数把 “数值” 当成参数:比如定义
add(int a, int b),我们不需要为1 + 2写一个函数,再为3 + 4写一个函数,而是把具体数值抽成参数a和b,调用时传什么数就算什么数。 - 模板把 “数据类型” 当成参数:既然数值可以作为参数,数据类型同样也可以作为参数。编写代码时,我们先使用一个占位符号
T代表待定的数据类型,等真正使用时需要什么类型,再传入什么类型。
像这样把通用逻辑与具体数据类型分离开来,让一份代码适配多种数据类型的编程方式,通常也称为 “泛型编程”。
C++ 模板的两种形式
在 C++ 中,模板统一使用 template 关键字来声明,一般分为 2 种:一种是 “函数模板”,另一种是 “类模板”。下面我们通过两个简单的例子,先来直观认识一下这两种模板的基本写法。
1. 函数模板
把函数中可变的数据类型抽取为类型参数,就得到了函数模板。普通函数的参数写在小括号 “()” 里,而模板参数则写在一对尖括号 “<>” 里。
定义函数模板时,我们只需要在函数的正上方加上一行 template <typename T>,接下来的函数中就可以使用 T 来代表待定的数据类型了。
其中,template 是英文 “模板” 的意思,typename 表示 “类型名称”,而 T 则是 “Type(类型)” 的首字母。
示例 1:使用函数模板求和
#include <iostream>
// 定义求和函数模板
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(10, 20) << '\n';
std::cout << add(1.5, 2.3) << '\n';
return 0;
}运行结果如下。
30
3.8分析:
上面这个例子非常简单,我们把原本需要写两遍的 add() 函数合并成了一份函数模板,将参数和返回值类型统一换成了 T。
当调用 add(10, 20) 传入两个 int 整数时,编译器会自动识别出 T 是 int,计算出整数结果 30。当调用 add(1.5, 2.3) 传入两个 double 小数时,编译器又会把 T 当成 double 来计算,输出小数结果 3.8。
2. 类模板
同样的思路也可以用在类上面。如果一个类中成员变量或成员函数使用的数据类型需要灵活变化,我们就可以在 class 定义的正上方加上 template <typename T>,将其定义为类模板。
比如在表示平面坐标点时,有时坐标是整数(如像素位置),有时坐标又是小数(如几何坐标)。使用类模板创建对象时,我们只需要在类名后面的尖括号 “<>” 中填入具体的数据类型即可。下面来看一个例子。
示例 2:使用类模板表示坐标点
#include <iostream>
// 定义坐标点类模板
template <typename T>
class Point {
private:
T x;
T y;
public:
Point(T px, T py) {
x = px;
y = py;
}
void show() {
std::cout << x << ", " << y << '\n';
}
};
int main() {
Point<int> p1(10, 20);
p1.show();
Point<double> p2(1.5, 3.8);
p2.show();
return 0;
}运行结果如下。
10, 20
1.5, 3.8分析:
Point<int> p1(10, 20); 表示将类型参数 T 指定为 int,创建一个保存整数坐标的对象 p1。而 Point<double> p2(1.5, 3.8); 则将 T 指定为 double,用来保存小数坐标。
细心的小伙伴可能会发现,前面调用 add(10, 20) 时并没有写 <int>,这是因为编译器根据小括号里的实参就能自动推导出 T 是 int。而使用类模板创建对象时,我们通常会在类名后面显式写出 <int> 或 <double>,直接指明内部的数据类型。
看懂了这个例子,再回头看前面学过的 std::vector 容器就很清楚了。它本质上就是标准库提供的一个类模板,填入 int 就得到 std::vector<int>,填入 std::string 就得到 std::vector<std::string>。
C++ 模板的工作原理
初学模板时,可能有些小伙伴会觉得奇怪:“既然模板里的 T 什么类型都能传,这是不是意味着 C++ 变成动态类型语言了?程序运行速度会不会变慢呢?”
实际上,C++ 依然是严格的静态类型语言,使用模板也不会降低程序的运行速度。这是因为模板采用的是一种叫做 “模板实例化” 的编译期机制。
我们编写的函数模板或类模板本身只是一份 “代码图纸”,并不是最终直接运行的机器指令。当编译器在编译阶段看到 add(10, 20) 和 add(1.5, 2.3) 时,会根据这份图纸把 T 分别替换成 int 和 double,自动生成两个独立的具体函数:
// 编译器自动生成的 int 版本
int add(int a, int b) {
return a + b;
}
// 编译器自动生成的 double 版本
double add(double a, double b) {
return a + b;
}也就是说,以前需要我们手动复制粘贴好几遍的重载函数,现在交由编译器在编译阶段自动生成了。等程序真正运行起来时,执行的依然是类型明确的普通函数,既省去了重复写代码的麻烦,又保持了 C++ 原生的高运行效率。
需要注意的是,模板虽然能把数据类型抽成参数 T,但并不代表任何类型都能随便传进去。传入的具体类型必须支持模板内部用到的运算操作,否则在编译实例化时就会报错。比如对于前面的 add() 函数模板,下面这种写法就是错误的:
struct Item {
int id;
};
Item a = {1};
Item b = {2};
Item c = add(a, b); // 编译报错在上面代码中,add() 模板内部使用了加号(+)来计算 a + b。而 Item 是我们自定义的普通结构体,默认并没有通过运算符重载定义两个 Item 相加的规则。当编译器尝试把 T 替换成 Item 生成代码时,遇到 a + b 就会直接报错。
提示 如果一个函数模板或类模板在定义之后,从来没有在代码中被调用或使用过,编译器就不会为它生成任何具体的机器代码。只有当我们真正传入某种具体类型去使用它时,编译器才会按需生成对应类型的代码。
