#面向对象程序设计09
从重复代码到模板
DRY 原则:Don’t Repeat Yourself,不要重复自己
重复代码最大的问题是后续修改时很容易漏改
例如求绝对值或最大值,C 风格可能写成多个函数:
1 | int max_int(int a, int b) { |
这三个函数的算法形状完全一样:
1 | 接收两个同类型的值 |
唯一不同的是类型
函数模板把这个“类型不同”抽出来:
1 | template<typename T> |
编译器看到 my_max(3, 5) 时,会推导出 T = int,生成一个近似如下的具体函数:
1 | int my_max_int(int a, int b) { |
看到 my_max(3.14, 2.71) 时,又生成一个 double 版本
模板本身不是普通函数,而是生成函数的模式
模板机制拆解
函数模板:对算法的抽象
函数模板用于表达“同一套操作适用于不同类型”
1 | template<typename T> |
这里的隐含约束是:T 必须能和 T{} 比较,必须支持一元负号,返回值必须能从表达式结果构造出来
模板的难点:这些要求不会自动写在函数签名里, T 背后存在语义承诺
类模板:对数据结构的抽象
类模板用于表达“同一种数据结构可以存不同类型的元素”
1 |
|
Stack<int> 和 Stack<std::string> 是两个不同的类,由同一个类模板实例化而来
模板定义还没有实例化时,不是一个具体类;只有写出 Stack<int> 之后,编译器才生成具体类型
注意这里把 top() 和 pop() 分开,而不是写成 T pop()
如果 pop() 既移动栈顶元素又弹出元素,移动构造一旦抛异常,容器状态可能很尴尬
标准库 std::stack 也采用 top() 观察、pop() 删除的分离设计,这体现了异常安全考虑
非类型模板参数
模板参数不只能是类型,也可以是编译期常量
1 |
|
Array<int, 10> 和 Array<int, 20> 是不同类型,因为容量 N 已经成为类型的一部分
好处是大小在编译期确定,编译器可以做更多检查和优化
变量模板与别名模板
变量模板和别名模板可以简述为“值和类型别名的泛型版本”
1 | template<typename T> |
别名模板常用于降低复杂类型的书写成本:
1 |
|
这里 PtrVector<Notifier> 等价于 std::vector<std::unique_ptr<Notifier>>
它没有引入新类型,只是生成一个类型别名
实例化、头文件与代码膨胀
模板的零运行时开销不是免费的,代价主要发生在编译期
模板实例化
对下面的模板:
1 | template<typename T> |
如果代码中调用了:
1 | my_max(1, 2); |
编译器会生成三个版本:
1 | my_max<int> |
每个版本都是独立代码
因此模板没有虚函数那种运行时间接调用开销,也更容易内联;但实例化数量过多时,二进制体积可能变大,编译时间也会增加。这就是代码膨胀
模板定义通常放头文件
普通函数可以在 .h 中声明,在 .cpp 中定义:
1 | // math.h |
模板通常不能这样分离
因为编译器实例化模板时必须看到完整定义,而不只是声明,假设某个 .cpp 调用 my_max<std::string>,编译器需要立即知道模板函数体,才能生成 std::string 版本
所以模板常写在头文件中:
1 | // max.hpp |
例外是显式实例化:
1 | // max.cpp |
这种做法可以控制编译时间和实例化范围,但必须提前列出允许的类型,不如头文件模板灵活
重载与模板推导
模板推导发生在编译期,编译器根据实参类型推导模板参数:
1 | template<typename T> |
下面这句通常会失败:
1 | auto z = my_max(1, 2.0); |
因为第一个参数推导出 T = int,第二个参数推导出 T = double,同一个 T 得到两个不同结果
编译器不会随便替你决定用哪个类型
可以显式指定类型:
1 | auto z = my_max<double>(1, 2.0); |
此时 1 会被转换为 double
函数重载和模板可能同时存在:
1 | int abs_value(int x) { |
当调用 abs_value(3) 时,非模板的精确匹配通常优先;调用其他类型时,可能选择模板
Concepts、constraints 与 requires
C++20 Concepts 把模板的隐含约束变成显式约束
没有 Concepts 时:
1 | template<typename T> |
错误根因:NoCompare 不支持 operator>,但编译器可能输出很多层模板实例化信息
使用 requires 后:
1 | template<typename T> |
此时约束写在接口上:T 必须支持 a > b
错误信息会更接近“约束不满足”,而不是深入模板体后爆出一串嵌套错误
也可以使用标准库概念:
1 |
|
注意约束精度。std::totally_ordered 要求 <、>、<=、>=、==、!= 等比较能力,而 my_max 实际只用到了 >
用 totally_ordered 可读性好,但技术上比最小约束更强,好的模板设计应当尽量只要求真正用到的能力。
缩写语法也要小心:
1 | auto bad_max(std::totally_ordered auto a, |
这表示 a 和 b 可以是两个不同类型,只是各自满足 totally_ordered
如果想强制两个参数同类型,应写:
1 | template<std::totally_ordered T> |
编译期多态与运行时多态
模板是编译期多态,虚函数是运行时多态,适用场景不同
| 维度 | 模板 | 虚函数 |
|---|---|---|
| 类型确定时间 | 编译期 | 运行期 |
| 调用开销 | 通常可直接调用、可内联 | 通过 vptr/vtable 间接调用 |
| 代码体积 | 每个实例化可能生成独立代码 | 多个对象共享同一虚函数入口 |
| 约束检查 | 编译期检查,错误可能复杂 | 接口由基类固定 |
| 适用场景 | 类型已知、性能敏感、算法形状可复用 | 运行时替换实现、对象异构管理 |
虚函数调用大致是:
1 | base_ptr->func() |
模板调用大致是:
1 | my_max<int>(3, 5) |
因此,在排序、数值计算、容器算法这类类型编译期已知且性能敏感的场景中,模板非常合适
在插件、支付渠道、通知渠道这类运行时才选择具体实现的场景中,虚函数更自然
STL 的泛型设计
STL 是 C++ 泛型编程的经典例子。它把功能拆成三层:
- 容器:
std::vector、std::list、std::map等,负责存储数据 - 算法:
std::sort、std::find、std::count_if等,负责处理数据 - 迭代器:连接容器和算法,提供访问元素的统一方式
例如:
1 |
|
std::sort 不关心容器是不是 vector,它关心的是迭代器是否满足随机访问迭代器要求。std::vector 的迭代器满足,所以可以排序;std::list 的迭代器只支持双向移动,不满足 std::sort 的约束,因此 list 提供自己的成员函数 list::sort()。
这说明模板的关键不是“什么类型”,而是“这个类型支持哪些操作”
这和 Concepts 的思想完全一致。
inline、static 与 DRY
如果几行代码重复,就应该考虑抽成函数;如果担心函数调用和返回有开销,可以考虑 inline
1 | inline int square(int x) { |
inline 的语义重点不是允许函数定义出现在多个翻译单元中而不违反 ODR,同时给编译器一个内联优化机会
编译器最终是否真正展开,仍由优化器决定
如果是只在当前翻译单元使用的辅助函数,可以用 static 或匿名命名空间限制链接可见性:
1 | static inline int clamp_non_negative(int x) { |
现代 C++ 更常在头文件中使用 inline 函数、inline constexpr 变量和模板定义
模板函数本身通常放头文件,因为实例化需要看到定义;普通小函数是否放头文件,则要根据是否需要跨翻译单元复用来判断
常见错误信息如何读
模板错误信息通常很长,阅读时可以按顺序找三个位置
先找第一个真正的 error
编译器可能输出很多 required from、in instantiation of。这些是实例化路径,不一定是根因
先找到第一个明确错误,例如:
1 | error: no match for 'operator>' (operand types are 'NoCompare' and 'NoCompare') |
这说明模板中使用了 >,但类型不支持
再看 instantiation 路径
路径通常会告诉你是哪个调用触发了模板实例化:
1 | required from 'T my_max(T, T) [with T = NoCompare]' |
这说明 T 被推导为 NoCompare,问题不是模板本身语法错,而是这个类型不满足模板的隐含约束
最后回到模板约束
如果错误是 no matching function for call to my_max(int, double),往往是模板参数推导冲突;如果错误是 constraints not satisfied,则要看 requires 要求的操作哪个没满足
当模板错误难读时,先把模板参数显式写出来,或临时加 requires 缩小错误范围
1 | auto x = my_max<double>(1, 2.0); |
这样可以判断问题是推导失败,还是类型能力不足
设计判断
什么时候用模板
适合用模板的情况:
- 算法逻辑与具体类型无关
- 类型在编译期已知
- 性能敏感,希望避免虚函数间接调用
- 希望和 STL 容器、迭代器、算法配合
- 约束可以清楚表达,例如“可比较”“可迭代”“可调用”
什么时候不用模板
不适合用模板的情况:
- 类型要在运行时动态替换
- 需要通过基类指针管理一组异构对象
- 算法其实依赖某个具体业务类的大量细节
- 只是为了少写一个类型名,却让错误信息和编译时间变差
例如 OOD 里的支付策略更适合虚函数,因为用户下单时可能在运行时选择微信或支付宝;而 std::sort 更适合模板,因为排序元素类型在编译期已知,比较操作可以内联。
约束要最小但可读
坏的模板设计会过度绑定继承体系:
1 | template<typename T> |
如果函数只需要 speak(),就不应该强制类型继承自 Animal:
1 | template<typename T> |
这才是“模板是对算法形状的抽象”的含义:只要求用到的操作