为什么要泛型:从重复到一份代码
假设你要写一个"取两个值里较大者"的函数。先写一份处理 int 的,过两天又要 double 的,再过几天要字符串的——于是你复制粘贴三份,只把类型改来改去。这就是泛型要消灭的事情:把"类型"本身也变成一个参数,一份逻辑适配所有能用它的类型。
C++ 的方案是模板:你写一份"带类型占位符"的代码,编译器在你实际使用时,按具体类型自动生成对应的真函数。这门技术叫泛型编程,也是 STL 里 vector、map、sort 能通吃各种元素类型的根本原因。
模板的核心思想一句话:不要针对具体类型写逻辑,要针对"任何支持这套操作"的类型写逻辑。编译器替你把类型填进去。
- template在函数或类前声明类型参数 T,编译时按实参生成具体版本。
- 类型推导调用时不必显式写类型,编译器从实参推出 T。
- 特化为某个特定类型写专门实现,覆盖通用版本。
- 头文件模板实现通常写在 .h 里,因为实例化发生在调用处。
函数模板:template 与类型推导
函数模板用 template <typename T> 开头,把类型写成占位符 T。调用时编译器看你传进去的实参,自动推导出 T 到底是 int 还是 double,无需你手写。
#include <iostream>
template <typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}
int main() {
std::cout << my_max(3, 7) << "\n"; // T 推为 int,输出 7
std::cout << my_max(2.5, 1.1) << "\n"; // T 推为 double,输出 2.5
std::cout << my_max<double>(3, 1.8); // 也可显式指定 T
return 0;
}
这里有个小坑:my_max(3, 1.5) 会编译失败,因为一个实参把 T 推成 int、另一个推成 double,编译器无法决定 T 到底是谁。解决办法要么显式写 my_max<double>(3, 1.5),要么把两个参数统一成同一类型。模板对"类型必须一致"这件事很较真。
类模板:写一个泛型容器
把模板用在类上,就能写出像 STL 那样"装什么类型都行"的容器。下面用数组模拟一个只能存一个元素的泛型 Box。
#include <string>
#include <iostream>
template <typename T>
class Box {
T value;
public:
explicit Box(T v) : value(v) {}
T get() const { return value; }
};
int main() {
Box<int> ibox(42);
Box<std::string> sbox("hello");
std::cout << ibox.get() << " " << sbox.get();
return 0;
}
注意类模板在使用时必须显式给出类型参数 Box<int>——类模板不像函数模板那样能从构造实参自动推导(C++17 之后才有类模板实参推导,属于进阶)。STL 的 vector<int>、map<string,int> 用的就是这套机制:尖括号里填什么类型,容器就装什么类型。
模板特化:为特定类型定制行为
有时候通用逻辑对某个特殊类型不适用——最典型的例子是处理字符串指针时,通用版本比较的是地址而不是内容。这时就用模板特化:为特定类型单独写一份实现,编译器在遇到这个类型时优先用它。
#include <iostream>
#include <cstring>
template <typename T>
bool equal(T a, T b) {
return a == b; // 通用版本
}
// 针对 const char* 的特化:按内容比较,不按地址
template <>
bool equal<const char*>(const char* a, const char* b) {
return std::strcmp(a, b) == 0;
}
int main() {
std::cout << equal(3, 3) << "\n"; // 用通用版本
std::cout << equal("abc", "abc"); // 用特化版本
return 0;
}
特化不是日常都要写的东西,但它是 STL 能既通用又对特殊情况做对的关键手段:通用模板负责绝大多数类型,特化负责把"通用逻辑在这里刚好不对"的那几个类型补齐。
auto 与类型推导:让编译器算类型
有了模板做底,现代 C++ 又给了 auto:当类型显而易见时,不必手写,让编译器帮你推。这和模板的类型推导是同一套机制,只是用在变量上。
#include <map>
#include <string>
int main() {
std::map<std::string, int> m = {{"a", 1}};
// 以前要写一长串迭代器类型,现在交给 auto
auto it = m.find("a");
auto x = 10; // int
auto p = &x; // int*
return 0;
}
经验法则:类型不重要或显而易见时用 auto,类型是接口契约、需要读者明确知道时就写出来。auto 不是"偷懒不写类型",而是"把冗长的类型名从视线里清掉,让代码聚焦在逻辑上"。
模板与头文件:为什么实现要写进 .h
普通函数可以声明在 .h、定义在 .c/.cpp;但模板通常要把实现也写进头文件。原因是:模板只有在被具体使用、实例化成某个类型时,编译器才真正生成代码。而这个"使用"发生在哪个 .cpp,编译器只看得到那个文件包含的头文件——如果实现藏在另一个 .cpp 里,链接时就找不到对应的实例,报一堆未定义符号。
// mymath.h
#ifndef MYMATH_H_
#define MYMATH_H_
template <typename T>
T my_max(T a, T b) { // 实现直接写在头文件里
return a > b ? a : b;
}
#endif
这也是为什么你打开 STL 头文件,会看到满屏模板实现而不是干净的声明。理解了"实例化发生在调用处",你就理解了 C++ 模板这套看起来奇怪的组织方式,以及模板报错为什么动辄几屏长——那是编译器在替你把类型代进模板后报的错。
实战:一个泛型最大值与交换工具
把前面的点串起来:写一个泛型工具库,既能比较任意可比较类型的大小,又能交换两个变量的值,对 int、double、string 都管用。
#include <iostream>
#include <string>
template <typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}
template <typename T>
void my_swap(T &a, T &b) { // 引用,直接改原变量
T tmp = a;
a = b;
b = tmp;
}
int main() {
int x = 1, y = 2;
my_swap(x, y);
std::cout << x << " " << y << "\n"; // 2 1
std::string s1 = "hi", s2 = "bye";
std::cout << my_max(s1, s2) << "\n"; // 字符串按字典序
return 0;
}
这份工具对 int、double、string 全都能用,而逻辑只写了一遍。这就是泛型编程的回报:你不再为每种类型复制一份几乎一样的代码,类型适配交给编译器。
泛型编程的检查清单
把 C++ 模板记成一句话:逻辑写一次,类型参数化,编译器在使用处实例化。提交代码前对照这份清单自查:
- 有没有为不同类型反复复制同一段逻辑?能否抽成函数或类模板?
- 调用模板时两个实参类型是否一致?不一致时是否显式指定了类型?
- 对某个特殊类型行为不对时,是否用特化补了一份?
- 模板实现是否放在了头文件里,而不是拆到 .cpp 导致链接错误?
- 迭代器这类冗长类型是否用 auto 简化了,同时保留了接口处需要明确的类型?
模板一开始看着绕,但它是把"重复"挡在门外的那道墙。一旦习惯类型参数化,你会回头觉得手写三遍同形函数才更难维护。
下一篇我们离开类型系统,回到 C 语言最朴素也最常用的两件事:读写磁盘文件,以及用 fork 起一个属于自己的子进程。