$ cat ~ / posts /c++ /oop9 3.4k Words ~ 14 Mins
cover.png
面向对象程序设计09

#面向对象程序设计09

exdoubled Lv5

从重复代码到模板

DRY 原则:Don’t Repeat Yourself,不要重复自己

重复代码最大的问题是后续修改时很容易漏改

例如求绝对值或最大值,C 风格可能写成多个函数:

1
2
3
4
5
6
7
8
9
10
11
int max_int(int a, int b) {
return a > b ? a : b;
}

double max_double(double a, double b) {
return a > b ? a : b;
}

float max_float(float a, float b) {
return a > b ? a : b;
}

这三个函数的算法形状完全一样:

1
2
3
接收两个同类型的值
比较二者大小
返回较大的那个

唯一不同的是类型

函数模板把这个“类型不同”抽出来:

1
2
3
4
5
6
7
8
template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

int i = my_max(3, 5); // T = int
double d = my_max(3.14, 2.71); // T = double
char c = my_max('a', 'b'); // T = char

编译器看到 my_max(3, 5) 时,会推导出 T = int,生成一个近似如下的具体函数:

1
2
3
int my_max_int(int a, int b) {
return a > b ? a : b;
}

看到 my_max(3.14, 2.71) 时,又生成一个 double 版本

模板本身不是普通函数,而是生成函数的模式

模板机制拆解

函数模板:对算法的抽象

函数模板用于表达“同一套操作适用于不同类型”

1
2
3
4
5
6
7
template<typename T>
T abs_value(T x) {
return x < T{} ? -x : x;
}

auto a = abs_value(-3); // int
auto b = abs_value(-2.5); // double

这里的隐含约束是:T 必须能和 T{} 比较,必须支持一元负号,返回值必须能从表达式结果构造出来

模板的难点:这些要求不会自动写在函数签名里, T 背后存在语义承诺

类模板:对数据结构的抽象

类模板用于表达“同一种数据结构可以存不同类型的元素”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <utility>
#include <vector>

template<typename T>
class Stack {
public:
void push(T value) {
data_.push_back(std::move(value));
}

const T& top() const {
return data_.back();
}

void pop() {
data_.pop_back();
}

bool empty() const {
return data_.empty();
}

private:
std::vector<T> data_;
};

Stack<int> numbers;
Stack<std::string> words;

Stack<int>Stack<std::string> 是两个不同的类,由同一个类模板实例化而来

模板定义还没有实例化时,不是一个具体类;只有写出 Stack<int> 之后,编译器才生成具体类型

注意这里把 top()pop() 分开,而不是写成 T pop()

如果 pop() 既移动栈顶元素又弹出元素,移动构造一旦抛异常,容器状态可能很尴尬

标准库 std::stack 也采用 top() 观察、pop() 删除的分离设计,这体现了异常安全考虑

非类型模板参数

模板参数不只能是类型,也可以是编译期常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cstddef>

template<typename T, std::size_t N>
class Array {
public:
T& operator[](std::size_t i) {
return data_[i];
}

const T& operator[](std::size_t i) const {
return data_[i];
}

std::size_t size() const {
return N;
}

private:
T data_[N];
};

Array<int, 10> a;
Array<double, 3> b;

Array<int, 10>Array<int, 20> 是不同类型,因为容量 N 已经成为类型的一部分

好处是大小在编译期确定,编译器可以做更多检查和优化

变量模板与别名模板

变量模板和别名模板可以简述为“值和类型别名的泛型版本”

1
2
3
4
5
template<typename T>
inline constexpr T pi_v = static_cast<T>(3.14159265358979323846);

float pf = pi_v<float>;
double pd = pi_v<double>;

别名模板常用于降低复杂类型的书写成本:

1
2
3
4
5
6
7
#include <memory>
#include <vector>

template<typename T>
using PtrVector = std::vector<std::unique_ptr<T>>;

PtrVector<Notifier> notifiers;

这里 PtrVector<Notifier> 等价于 std::vector<std::unique_ptr<Notifier>>

它没有引入新类型,只是生成一个类型别名

实例化、头文件与代码膨胀

模板的零运行时开销不是免费的,代价主要发生在编译期

模板实例化

对下面的模板:

1
2
3
4
template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

如果代码中调用了:

1
2
3
my_max(1, 2);
my_max(1.0, 2.0);
my_max('a', 'b');

编译器会生成三个版本:

1
2
3
my_max<int>
my_max<double>
my_max<char>

每个版本都是独立代码

因此模板没有虚函数那种运行时间接调用开销,也更容易内联;但实例化数量过多时,二进制体积可能变大,编译时间也会增加。这就是代码膨胀

模板定义通常放头文件

普通函数可以在 .h 中声明,在 .cpp 中定义:

1
2
3
4
5
6
7
// math.h
int add(int, int);

// math.cpp
int add(int a, int b) {
return a + b;
}

模板通常不能这样分离

因为编译器实例化模板时必须看到完整定义,而不只是声明,假设某个 .cpp 调用 my_max<std::string>,编译器需要立即知道模板函数体,才能生成 std::string 版本

所以模板常写在头文件中:

1
2
3
4
5
6
7
// max.hpp
#pragma once

template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

例外是显式实例化:

1
2
3
4
5
6
7
8
// max.cpp
template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

template int my_max<int>(int, int);
template double my_max<double>(double, double);

这种做法可以控制编译时间和实例化范围,但必须提前列出允许的类型,不如头文件模板灵活

重载与模板推导

模板推导发生在编译期,编译器根据实参类型推导模板参数:

1
2
3
4
5
6
7
template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

auto x = my_max(1, 2); // OK, T = int
auto y = my_max(1.0, 2.0); // OK, T = double

下面这句通常会失败:

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
2
3
4
5
6
7
8
int abs_value(int x) {
return x < 0 ? -x : x;
}

template<typename T>
T abs_value(T x) {
return x < T{} ? -x : x;
}

当调用 abs_value(3) 时,非模板的精确匹配通常优先;调用其他类型时,可能选择模板

Concepts、constraints 与 requires

C++20 Concepts 把模板的隐含约束变成显式约束

没有 Concepts 时:

1
2
3
4
5
6
7
8
template<typename T>
T my_max(T a, T b) {
return a > b ? a : b;
}

struct NoCompare {};

auto x = my_max(NoCompare{}, NoCompare{});

错误根因:NoCompare 不支持 operator>,但编译器可能输出很多层模板实例化信息

使用 requires 后:

1
2
3
4
5
6
7
template<typename T>
requires requires(T a, T b) {
a > b;
}
T my_max(T a, T b) {
return a > b ? a : b;
}

此时约束写在接口上:T 必须支持 a > b

错误信息会更接近“约束不满足”,而不是深入模板体后爆出一串嵌套错误

也可以使用标准库概念:

1
2
3
4
5
6
#include <concepts>

template<std::totally_ordered T>
T my_max(T a, T b) {
return a > b ? a : b;
}

注意约束精度。std::totally_ordered 要求 <><=>===!= 等比较能力,而 my_max 实际只用到了 >

totally_ordered 可读性好,但技术上比最小约束更强,好的模板设计应当尽量只要求真正用到的能力。

缩写语法也要小心:

1
2
3
4
auto bad_max(std::totally_ordered auto a,
std::totally_ordered auto b) {
return a > b ? a : b;
}

这表示 ab 可以是两个不同类型,只是各自满足 totally_ordered

如果想强制两个参数同类型,应写:

1
2
3
4
template<std::totally_ordered T>
T good_max(T a, T b) {
return a > b ? a : b;
}

编译期多态与运行时多态

模板是编译期多态,虚函数是运行时多态,适用场景不同

维度模板虚函数
类型确定时间编译期运行期
调用开销通常可直接调用、可内联通过 vptr/vtable 间接调用
代码体积每个实例化可能生成独立代码多个对象共享同一虚函数入口
约束检查编译期检查,错误可能复杂接口由基类固定
适用场景类型已知、性能敏感、算法形状可复用运行时替换实现、对象异构管理

虚函数调用大致是:

1
2
3
4
base_ptr->func()
读取对象 vptr
查找 vtable
间接调用对应函数

模板调用大致是:

1
2
3
my_max<int>(3, 5)
编译器生成 int 版本
运行时直接调用或内联

因此,在排序、数值计算、容器算法这类类型编译期已知且性能敏感的场景中,模板非常合适

在插件、支付渠道、通知渠道这类运行时才选择具体实现的场景中,虚函数更自然

STL 的泛型设计

STL 是 C++ 泛型编程的经典例子。它把功能拆成三层:

  • 容器:std::vectorstd::liststd::map 等,负责存储数据
  • 算法:std::sortstd::findstd::count_if 等,负责处理数据
  • 迭代器:连接容器和算法,提供访问元素的统一方式

例如:

1
2
3
4
5
#include <algorithm>
#include <vector>

std::vector<int> values = {3, 1, 4, 1, 5};
std::sort(values.begin(), values.end());

std::sort 不关心容器是不是 vector,它关心的是迭代器是否满足随机访问迭代器要求。std::vector 的迭代器满足,所以可以排序;std::list 的迭代器只支持双向移动,不满足 std::sort 的约束,因此 list 提供自己的成员函数 list::sort()

这说明模板的关键不是“什么类型”,而是“这个类型支持哪些操作”

这和 Concepts 的思想完全一致。

inline、static 与 DRY

如果几行代码重复,就应该考虑抽成函数;如果担心函数调用和返回有开销,可以考虑 inline

1
2
3
inline int square(int x) {
return x * x;
}

inline 的语义重点不是允许函数定义出现在多个翻译单元中而不违反 ODR,同时给编译器一个内联优化机会

编译器最终是否真正展开,仍由优化器决定

如果是只在当前翻译单元使用的辅助函数,可以用 static 或匿名命名空间限制链接可见性:

1
2
3
static inline int clamp_non_negative(int x) {
return x < 0 ? 0 : x;
}

现代 C++ 更常在头文件中使用 inline 函数、inline constexpr 变量和模板定义

模板函数本身通常放头文件,因为实例化需要看到定义;普通小函数是否放头文件,则要根据是否需要跨翻译单元复用来判断

常见错误信息如何读

模板错误信息通常很长,阅读时可以按顺序找三个位置

先找第一个真正的 error

编译器可能输出很多 required fromin instantiation of。这些是实例化路径,不一定是根因

先找到第一个明确错误,例如:

1
error: no match for 'operator>' (operand types are 'NoCompare' and 'NoCompare')

这说明模板中使用了 >,但类型不支持

再看 instantiation 路径

路径通常会告诉你是哪个调用触发了模板实例化:

1
2
required from 'T my_max(T, T) [with T = NoCompare]'
required from here

这说明 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
2
3
4
5
template<typename T>
requires std::derived_from<T, Animal>
void make_sound(T& value) {
value.speak();
}

如果函数只需要 speak(),就不应该强制类型继承自 Animal

1
2
3
4
5
6
7
template<typename T>
requires requires(T value) {
value.speak();
}
void make_sound(T& value) {
value.speak();
}

这才是“模板是对算法形状的抽象”的含义:只要求用到的操作

$ discussion
# Comments
waline