模板:同功能、不同类型的抽象

封装解决了「很多地方做同一件事」的重复;但还有另一种重复——同一件事,只是类型不同。写十个几乎一样的函数,只因为参数是 int、float、string、结构体……模板把「类型」也变成参数,让一个函数覆盖所有类型。这是和原子化接口正交的另一条线。


一、先看一种被忽略的重复

原子化接口线解决了这种重复:

1
2
open(path);  // 到处都是
close(fd);

到处复制的是同一个调用。封装一次,处处复用。

但还有另一种重复,形态完全不同——同一段逻辑,参数类型不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 从文件偏移处读一个 int
int32_t read_i32_at(int fd, size_t off) {
int32_t v;
if (pread(fd, &v, sizeof(v), off) != sizeof(v)) throw ReadError();
return v;
}

// 从文件偏移处读一个 float —— 函数体几乎一样
float read_f32_at(int fd, size_t off) {
float v;
if (pread(fd, &v, sizeof(v), off) != sizeof(v)) throw ReadError();
return v;
}

// 从文件偏移处读一个 uint8
uint8_t read_u8_at(int fd, size_t off) {
uint8_t v;
if (pread(fd, &v, sizeof(v), off) != sizeof(v)) throw ReadError();
return v;
}

三个函数,逻辑完全相同:pread → 检查字节数 → 异常处理。不同的只有一个词:类型。

复制粘贴的代价不在当下,在以后:

  • 改异常策略(throw → 返回错误码)→ 三个函数都要改;
  • 加一个类型(double)→ 再复制一遍;
  • 有一处忘了同步修改 → 悄悄不一致。

这是「同功能不同类型」的重复——复制调用是横向重复,这种是纵向重复。


二、解法:把类型也变成参数

C 语言做不到,因为 C 的类型是硬编码的。C++ 给出模板:

1
2
3
4
5
6
template<typename T>
T read_at(int fd, size_t off) {
T v;
if (pread(fd, &v, sizeof(v), off) != sizeof(v)) throw ReadError();
return v;
}

用法:

1
2
3
int32_t a = read_at<int32_t>(fd, 100);   // 读 int
float b = read_at<float>(fd, 200); // 读 float
uint8_t c = read_at<uint8_t>(fd, 300); // 读 uint8

一个函数,覆盖所有类型。 逻辑只写一遍,类型作为参数传入。

注意它和普通参数的区别:

1
2
普通函数参数:运行时传入的值      → read_at(fd, 100)
模板参数: 编译期确定的类型 → read_at<int32_t>(fd, 100)

类型不是运行时数据,是编译期选择。模板在编译期针对每个用到的类型各生成一份代码——read_at<int32_t>read_at<float> 编译后是两份独立的函数,只是源码里只写了一份。


三、模板封装原子化接口

回到原子化接口线的例子。safe_opensafe_mmapsafe_close 每个只针对一种底层接口,不需要模板。

但再往上一步——功能函数要「读文件里的任意类型」——就有了纵向重复。模板正好落在原子化接口之上:

1
2
3
4
功能函数 = 数据结构 + 算法 + 原子化接口

模板化的原子化接口:
read_at<T>(fd, off) ← 一个接口,适用于所有 T

这就是「模板封装原子化接口」的含义:

1
2
3
4
模板 = 原子化接口的泛化
→ 一个原子化接口,不再绑定单一类型
→ 封装库接口 + 异常处理 不变
→ 类型成为参数

功能函数因此可以写一次:

1
2
3
4
5
6
7
template<typename T>
T read_config_value(int fd, size_t off) {
return read_at<T>(fd, off); // 功能函数也模板化
}

uint32_t version = read_config_value<uint32_t>(fd, 0);
bool flag = read_config_value<bool>(fd, 4);

异常处理仍被收敛在 read_at 里,功能函数不需要每个类型各写一份。


四、两种模板:函数模板与类模板

模板不只在函数层面。

函数模板——把类型作为函数参数:

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

int n = max_of(3, 7);
double d = max_of(3.14, 2.71);

类模板——把类型作为类的成员类型:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class RingBuffer {
public:
void push(const T& v);
T pop();
private:
std::vector<T> data_;
};

RingBuffer<int> ints;
RingBuffer<Message> msgs; // 同一个类,不同的 T

选择依据和接口形态线一致:

1
2
无状态、单次调用   → 函数模板(如 max_of)
有状态、要管理生命周期 → 类模板(如 RingBuffer)

模板是「接口形态演化」线在类型维度上的推广:函数导出 → 函数模板;类封装 → 类模板。


五、模板和两条主线的关系

模板解决的是重复,不是拆分。两条主线各有分工:

1
2
3
职责混乱 → 拆分(函数、文件、模块、系统)
重复代码 → 封装(函数、类、库)
同功能不同类型 → 模板(类型维度上的封装)

放到演化主线的收束里:

1
2
一条指令 → … → 函数(逻辑复用)→ 文件(编译单元)→ 分层(职责分离)
→ 模块三要素 → 类(状态+生命周期封装)→ 模板(类型维度复用)

主线「从指令到业务系统」讲的是结构怎么长出来(拆分);模板讲的是类型怎么被抽象(复用)——它不改变结构,只消灭同一结构下的类型重复。


六、模板不是万能的

模板是手段,不是目的。过度模板化的症状:

1
2
3
❌ 只为「可能以后要换类型」而模板化
❌ 把模板写进性能关键路径却不考虑实例化开销
❌ 模板错误信息可读性差 → 复杂模板让维护者看不懂

判断标准:

1
2
现在就有多种类型 → 模板化(如容器、通用算法)
只有一种类型 → 普通函数/类,等第二种类型出现再模板化

软件组织原则也说得很清楚:「使用合适的抽象方式减少重复,而不是固定使用模板。」 普通函数、继承、组合、数据驱动都可以减少重复,模板只是其中一种——专门针对「类型」这一维度的重复。


七、和其他线的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
模板 ←→ 原子化接口
模板封装原子化接口 = 一个原子化接口适用所有类型
(封装+异常处理不变,类型成为参数)

模板 ←→ 接口形态演化(01,本主题内)
函数模板 = 函数导出的类型泛化
类模板 = 类封装的类型泛化

模板 ←→ 从指令到系统主线
主线讲结构演化(拆分),模板讲类型复用(抽象)
主线第十三章「面向对象」之后是模板出现的位置

模板 ←→ 依赖接口
模板在编译期绑定具体类型,虚函数在运行期绑定实现
两者是「什么时候决定用哪个实现」的两种极端

收束

1
2
3
4
5
6
同功能不同类型 → 一堆几乎相同的函数(纵向重复)
模板 → 把类型变成参数 → 一个函数覆盖所有类型
函数模板 → 无状态单次调用
类模板 → 有状态要管理生命周期
模板封装原子化接口 → 一个接口适用所有类型
模板不是目的 → 有真实的多类型需求才用

模板是「重复代码就封装」这条原则在类型维度上的延伸:前面所有线封装的是行为,模板封装的是类型