为什么要泛型:从重复到一份代码

假设你要写一个"取两个值里较大者"的函数。先写一份处理 int 的,过两天又要 double 的,再过几天要字符串的——于是你复制粘贴三份,只把类型改来改去。这就是泛型要消灭的事情:把"类型"本身也变成一个参数,一份逻辑适配所有能用它的类型。

C++ 的方案是模板:你写一份"带类型占位符"的代码,编译器在你实际使用时,按具体类型自动生成对应的真函数。这门技术叫泛型编程,也是 STL 里 vector、map、sort 能通吃各种元素类型的根本原因。

模板的核心思想一句话:不要针对具体类型写逻辑,要针对"任何支持这套操作"的类型写逻辑。编译器替你把类型填进去。
本文核心知识点
  • template在函数或类前声明类型参数 T,编译时按实参生成具体版本。
  • 类型推导调用时不必显式写类型,编译器从实参推出 T。
  • 特化为某个特定类型写专门实现,覆盖通用版本。
  • 头文件模板实现通常写在 .h 里,因为实例化发生在调用处。
C++ 模板与泛型配图:蓝青渐变背景上中央一个镂空模板方框轮廓,青蓝紫三色积木方块从左侧导入、右侧输出统一形状,周围环绕箭头与嵌套方框、参数插槽光点
模板像一个模具:左边塞进不同颜色的原料,右边出来形状统一的成品——逻辑只有一份,类型各不相同。

函数模板: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++ 模板这套看起来奇怪的组织方式,以及模板报错为什么动辄几屏长——那是编译器在替你把类型代进模板后报的错。