模板基础
这一课你将能做到
- 编写函数模板与类模板,用不同实参类型实例化出各自独立的函数/类
- 解释 C++ 模板与 Java 泛型的本质区别(编译期实例化 vs 类型擦除)
- 用模板写一个通用「评测结果」容器,并理解为什么模板要放在头文件里
为什么要学
你每天都在用模板而可能没意识到:std::vector<int>、std::map<std::string, int>、std::unique_ptr<Report>、std::sort——全是模板。STL 能一套代码服务所有类型,靠的就是它。
C++ 模板和 Java 泛型解决同一个问题(类型安全的通用代码),实现路径完全相反:Java 泛型是类型擦除——编译后 List<String> 和 List<Integer> 都是 List,运行期不区分,不能 new T();C++ 模板是编译期实例化——vector<int> 和 vector<string> 生成两份完全独立的机器码,还能内联优化。理解这个差异,就理解了为什么 C++ 编译慢、二进制大,却几乎没有运行期泛型开销。
判题沙箱里「不同语言的提交用不同评测流程」这类需求,模板是最 C++ 的解法:一份流程代码,编译期生成 N 份特化实现,零虚函数开销。
先建立图像
不用模板:复制粘贴 N 份(改类型改到怀疑人生)
int maxInt(int a, int b) { return a > b ? a : b; }
double maxDouble(double a, double b) { return a > b ? a : b; }
string maxString(string a, string b) { ... }
用模板:写一份,编译器帮你生成
template <typename T>
T maxOf(T a, T b) { return a > b ? a : b; }
maxOf(1, 2) → 编译器实例化出 int 版
maxOf(1.5, 2.5) → 实例化出 double 版
模板不是一段运行的代码,是一份「生成代码的图纸」
跟着做
步骤 1:第一个函数模板
新建 templates.cpp:
#include <iostream>
#include <string>
template <typename T> // 声明模板参数 T:一个编译期才能确定的类型
T maxOf(const T& a, const T& b) { // 传 const 引用:大对象不复制
return a > b ? a : b;
}
int main() {
std::cout << maxOf(3, 7) << "\n"; // T = int(自动推导)
std::cout << maxOf(2.5, 1.5) << "\n"; // T = double
std::cout << maxOf(std::string("AC"),
std::string("WA")) << "\n"; // T = string,比较字典序
// 显式指定 T:自动推导不出来时用
std::cout << maxOf<double>(3, 7.5) << "\n";
// maxOf(3, 7.5); // 编译错误:int 和 double 推导冲突(T 不能既是又是)
return 0;
}
g++ -std=c++17 templates.cpp -o templates && ./templates
输出:
7
2.5
WA
7.5
maxOf(3, 7) 与 maxOf(2.5, 1.5) 编译后是两个不同的函数,可以用 nm 或看汇编验证——这就是「实例化」。
步骤 2:多类型参数与模板推导
#include <iostream>
#include <string>
// 两个模板参数:输入输出类型可以不同
template <typename R, typename T>
R convertTo(const T& value) {
return static_cast<R>(value);
}
// 返回类型依赖两个参数:用 auto 推导(C++14 起支持返回类型推导)
template <typename A, typename B>
auto multiply(A a, B b) -> decltype(a * b) { // decltype:取表达式的类型
return a * b;
}
int main() {
int i = convertTo<int>(3.9); // double → int,得 3(截断)
double d = convertTo<double>(42); // int → double
std::cout << i << " " << d << "\n";
std::cout << multiply(3, 0.5) << "\n"; // int * double → 1.5
return 0;
}
步骤 3:类模板——写一个通用容器
仿照 STL 的风格,写一个定长「评测结果槽」:
#include <iostream>
#include <string>
#include <stdexcept>
template <typename T>
class Slot {
public:
explicit Slot(const T& initial) : value_(initial), filled_(true) {}
Slot() : filled_(false) {} // 空槽
void set(const T& v) { value_ = v; filled_ = true; }
const T& get() const { // 取值前必须检查,否则抛异常
if (!filled_) throw std::runtime_error("槽位为空");
return value_;
}
bool filled() const { return filled_; }
private:
T value_;
bool filled_;
};
int main() {
Slot<int> score(95); // 实例化出 Slot<int> 这个具体的类
Slot<std::string> verdict("AC"); // 又一个独立的类
Slot<double> ratio; // 空槽
std::cout << score.get() << " " << verdict.get() << "\n";
try {
ratio.get();
} catch (const std::runtime_error& e) { // C++ 异常语法:try/catch,无 finally
std::cout << "捕获: " << e.what() << "\n";
}
return 0;
}
对比 Java:class Slot<T> 写法几乎一样,但 Java 里 Slot<int> 不合法(必须 Slot<Integer> 装箱),C++ 的模板参数可以直接是基本类型——零装箱。
步骤 4:非类型模板参数
模板参数不一定是类型,也可以是编译期常量:
#include <array>
#include <iostream>
// N 是编译期常量:每个不同的 N 实例化出不同的类
template <typename T, std::size_t N>
class Ring { // 固定容量的环形缓冲——判题沙箱的日志缓冲区常用结构
public:
void push(const T& v) {
buf_[head_] = v;
head_ = (head_ + 1) % N;
}
const T& last() const { return buf_[(head_ + N - 1) % N]; }
private:
T buf_[N]{};
std::size_t head_ = 0;
};
int main() {
Ring<int, 3> recent; // 只记最近 3 条耗时
recent.push(100); recent.push(200); recent.push(300); recent.push(400);
std::cout << "最近一次: " << recent.last() << "\n"; // 400
std::array<int, 3> a{1, 2, 3}; // STL 的 std::array 就是非类型模板参数的应用
std::cout << a[1] << "\n";
return 0;
}
容量 N 编译期确定 → 内存静态分配、无堆开销,性能敏感场景的惯用手法。
步骤 5:理解「模板必须在头文件」的正确姿势
前面所有模板都定义在一个文件里没问题。一旦拆分:
// slot.hpp —— 模板的完整定义必须放头文件
template <typename T>
class Slot {
public:
const T& get() const;
private:
T value_;
bool filled_ = false;
};
template <typename T> // 成员函数实现也写在头文件里
const T& Slot<T>::get() {
if (!filled_) throw std::runtime_error("槽位为空");
return value_;
}
原因:编译器实例化 Slot<int> 时需要看到完整模板代码,当场生成对应版本;如果实现藏在另一个 .cpp 里,那个 .cpp 看不到「要生成 Slot<int>」的需求,链接时就报 undefined reference。这就是 STL 全是头文件库(header-only)的原因——不是偷懒,是模板机制使然。
讲清楚要点
- 模板是编译期代码生成:
vector<int>、vector<string>是两个完全独立的类,各生成一份机器码。代价是编译时间与二进制体积,收益是零运行期开销 + 可内联。Java 泛型运行期一份代码,代价是不能用基本类型、不能运行期取类型参数。 - 类型推导规则要心里有数:
maxOf(3, 7.5)失败因为两个实参把 T 推导成不同类型;maxOf<int>(3, 7.5)显式统一。函数模板大多数场景靠实参推导,不需要手写类型。 - 模板的「鸭子类型」:模板代码对 T 的要求不是声明出来的,而是「用到什么就要求什么」——代码里写了
a > b,T 就必须支持operator>。不满足时报的编译错误又长又绕(C++20 的 Concepts 就是给这个补说明书的,知道即可)。 - 模板定义放头文件:除非显式实例化(
template class Slot<int>;写在 .cpp 里),否则实现必须对使用者可见。工程规范:模板代码统一进 .hpp。 - 特化(了解):可以为特定类型写专属版本,如
template <> class Slot<bool> {...}。STL 内部大量特化优化,应用层代码较少手写,先知道有这个机制。
你可能会懵的地方
-
typename和class在模板参数里有什么区别?template <typename T>与template <class T>完全等价。唯一必须用typename的场景:typename T::iterator it;——告诉编译器T::iterator是类型而不是静态成员(依赖名的歧义消解)。初学统一写typename即可。 -
模板和宏定义是一回事吗? 不是。宏是预处理期无脑文本替换、无类型检查;模板是编译期实体,有完整类型系统、能重载能递归。C++ 社区对「用宏实现泛型」是明确反对的。
-
std::vector<Submission>里的 Submission 要满足什么条件? 至少可复制或可移动(push_back 时要搬进容器)、可析构。上一课 vector 扩容讲的移动语义在这里兑现:Submission 定义了移动构造,扩容就是搬指针而非整体复制。 -
为什么模板报错信息那么恐怖? 实例化是层层嵌套的:你的错误藏在 STL 深处的某次实例化里,编译器把整条实例化链都打印出来。读法:从错误列表最下面(你的代码行)往上找第一个和自己文件相关的行。C++20 Concepts 和编译器改进(GCC/Clang 的彩色错误)已缓和很多。
-
运行期能根据类型走不同逻辑吗? 模板是编译期分派(
if constexpr,C++17:编译期 if,假分支直接不实例化);运行期分派要虚函数或std::variant。两条路互补,第 8 课会碰到if constexpr的实际用法。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 模板实现写在 .cpp 里给人 include .hpp | 链接错误 undefined reference | 模板完整定义放 .hpp |
maxOf(3, 7.5) 指望自动转换 | T 推导冲突,编译失败 | 显式 maxOf<double>(3, 7.5) |
| 模板里对 T 的操作 T 不支持 | 深处报错难以定位 | 注释写清对 T 的要求;C++20 可用 Concepts 约束 |
std::vector<T> v 里 T 用引用类型 | 容器要求可赋值元素 | 存值、指针或智能指针 |
模板参数用宏 #define T int | 无类型检查、作用域失控 | 正经写 template <typename T> |
卡点急救
- 链接报
undefined reference to 'Slot<int>::get()':模板实现没被看见。把成员函数定义搬进 .hpp,或在 .cpp 末尾显式实例化template class Slot<int>;。 - 报错信息长达百行:从最后往前读,定位到「第一个出现在你源文件里的行号」;开编译选项
-fno-dedicated无效时直接用-std=c++20尝试 Concepts 化,或查文档确认类型要求。 - auto 推导出意外类型(比如
auto x = m["k"]把 int 推成引用):用static_cast或显式类型;不确定时打印typeid(x).name()看推导结果。
面试常问
-
C++ 模板和 Java 泛型的区别? C++ 模板在编译期按类型实例化出多份代码,支持基本类型、非类型参数、特化,运行期零开销,代价是编译慢、代码膨胀;Java 泛型编译期类型检查、运行期擦除为统一类型,不能
new T()、基本类型需装箱。本质:C++ 图纸生成代码,Java 语法糖约束类型。 -
为什么模板通常定义在头文件中? 实例化发生在使用点,编译器必须在实例化位置看到完整模板定义才能生成对应类型的代码。定义在 .cpp 中则其他编译单元无法实例化,产生链接错误;显式实例化可以集中生成,但会丧失「任意类型即用」的灵活性。
-
什么是模板特化?举一个标准库中的例子。 针对特定模板实参提供专门实现:全特化(所有参数固定)与偏特化(部分参数固定,仅类模板支持)。标准库例子:
std::vector<bool>是位压缩特化(每 bool 占 1 bit);std::hash对 int/string 等各有特化版本供 unordered_map 使用。
自测三问
maxOf(3, 7.5)为什么编译失败?两种修复方式是什么?Ring<int, 3>和Ring<int, 4>是同一个类吗?- Java 泛型为什么不能写
new T(),而 C++ 模板「可以」(在模板代码里T t;)?
小结一句话
模板是编译期的代码生成器:一份图纸按类型实例化出 N 份零开销实现,STL 的根基、与 Java 类型擦除式泛型的根本分野——写通用代码时,C++ 交给编译器,Java 留在运行期。
延伸
- cppreference:函数模板
- cppreference:类模板
- 下一课:现代 C++ 特性——auto、结构化绑定与 optional/variant。