88、STL与模板方法模式:算法骨架、子类实现、钩子方法
模板方法模式,说白了就是「把流程定死,把细节留活」。
我在做底层框架的时候,经常遇到这样的场景:多个模块的处理流程几乎一样,只是中间某几步的具体实现不同。如果每个模块都写一遍完整流程,代码冗余不说,改一个公共步骤还得挨个改,太痛苦了。
模板方法模式就是来解决这个问题的。它把算法的骨架放在基类里,把可变的部分留给子类去实现。C++ 里用 STL 的思维去理解它,你会发现很多算法其实都暗合这个模式。
什么是模板方法模式
模板方法模式定义了一个操作中的算法骨架,而将一些步骤延迟到子类中实现。子类可以不改变算法结构,就能重新定义某些特定步骤。
核心思想就两点:
- 不变的部分:算法骨架写在基类,通常是 public 方法
- 可变的部分:具体步骤写成虚函数,让子类去 override
你想想看,STL 里的很多算法其实也是这个思路。比如 std::sort,它内部有快排、堆排、插入排序的组合策略,但对外暴露的接口是固定的。你只需要提供迭代器,排序的「骨架」由 STL 帮你搞定。
关键区别:模板方法模式用的是继承 + 虚函数,而 STL 算法用的是泛型 + 函数对象。前者是运行时多态,后者是编译时多态。但思想是相通的——把骨架固定,把细节开放。
经典结构:算法骨架 + 钩子方法
模板方法模式里有个很有意思的概念叫「钩子方法」。它本质上是一个空实现或者默认实现的虚函数,子类可以选择性地覆盖它,也可以不覆盖。
为什么要搞个钩子?
我举个例子。假设你要写一个数据处理的基类,流程是:打开文件 → 读取数据 → 处理数据 → 写入结果 → 关闭文件。大部分情况下这个流程是固定的,但有些子类想在「处理数据」之前做点额外的校验,或者想在「写入结果」之后发个通知。
这时候你如果在基类里硬编码这些步骤,子类就没办法插队了。钩子方法就是给你留的「扩展点」。
class DataProcessor {
public:
// 模板方法:定义算法骨架
void process(const std::string& filePath) {
openFile(filePath);
readData();
if (beforeProcessHook()) { // 钩子方法
processData();
}
writeResult();
afterWriteHook(); // 另一个钩子
closeFile();
}
virtual ~DataProcessor() = default;
protected:
// 子类必须实现的步骤
virtual void readData() = 0;
virtual void processData() = 0;
virtual void writeResult() = 0;
// 钩子方法:默认返回 true,子类可覆盖
virtual bool beforeProcessHook() { return true; }
virtual void afterWriteHook() {} // 默认空实现
private:
void openFile(const std::string& path) {
// 公共的打开文件逻辑
}
void closeFile() {
// 公共的关闭文件逻辑
}
};
你看,beforeProcessHook 和 afterWriteHook 就是钩子。子类如果不需要,完全不用管它们。需要的时候,覆盖一下就行。
我的习惯:在设计基类时,我会把钩子方法写成 protected 的虚函数,并且给一个合理的默认行为。这样既保留了扩展性,又不会强迫子类实现一堆用不上的接口。
STL 里的模板方法思想
STL 虽然不直接使用模板方法模式(它更偏向泛型编程),但很多设计思路是相通的。我挑两个典型的例子说说。
1. std::accumulate 的「骨架 + 操作」
std::accumulate 的算法骨架是:遍历区间,累加结果。但「如何累加」这个步骤,你可以通过第四个参数传入自定义操作。
// 默认累加:骨架固定,操作固定
int sum = std::accumulate(vec.begin(), vec.end(), 0);
// 自定义操作:骨架固定,操作可变
int product = std::accumulate(vec.begin(), vec.end(), 1,
[](int a, int b) { return a * b; });
这不就是模板方法模式吗?算法骨架(遍历 + 累加)是固定的,具体的「累加方式」由调用者决定。只不过 STL 用的是函数对象,而不是虚函数。
2. std::sort 的策略混合
std::sort 内部会根据数据量、有序程度等因素,在快排、堆排、插入排序之间切换。这个「切换策略」对用户是透明的,你只需要提供比较函数。
我在项目中遇到过类似的需求:需要根据不同的数据特征选择不同的排序算法。当时我就是参考了 STL 的做法,把算法骨架封装在基类里,具体的排序策略通过模板参数传入。
我曾经踩过的坑:在设计模板方法时,把钩子方法设成了纯虚函数。结果每个子类都得实现一堆空方法,代码变得又臭又长。记住,钩子方法应该是「可选」的,不是「必须」的。默认给个空实现或者合理的默认值,才是正确的做法。
实战:用模板方法模式封装数据校验框架
下面我写一个实际点的例子。假设我们要做一个数据校验框架,不同的数据源(CSV、JSON、数据库)校验流程类似,但具体校验逻辑不同。
class DataValidator {
public:
bool validate(const std::string& source) {
if (!loadData(source)) {
logError("数据加载失败");
return false;
}
if (!preCheckHook()) { // 钩子:前置检查
logError("前置检查未通过");
return false;
}
bool result = doValidate(); // 核心校验,子类实现
postProcessHook(result); // 钩子:后置处理
return result;
}
virtual ~DataValidator() = default;
protected:
virtual bool loadData(const std::string& source) {
// 默认实现:从文件加载
// 子类可以覆盖
return true;
}
virtual bool preCheckHook() { return true; }
virtual bool doValidate() = 0;
virtual void postProcessHook(bool /*result*/) {}
void logError(const std::string& msg) {
std::cerr << "[ERROR] " << msg << std::endl;
}
};
// 子类:CSV 校验器
class CsvValidator : public DataValidator {
protected:
bool doValidate() override {
// CSV 特有的校验逻辑
return true;
}
bool preCheckHook() override {
// CSV 需要检查文件头
return checkHeader();
}
};
这个框架的好处是:
- 校验流程统一写在基类的
validate方法里 - 子类只需要关注自己特有的校验逻辑
- 钩子方法让子类可以灵活地插入额外操作
- 错误处理集中管理,不会散落在各个子类中
模板方法 vs 策略模式
很多人会把这两个模式搞混。我简单区分一下:
| 对比维度 | 模板方法模式 | 策略模式 |
|---|---|---|
| 核心机制 | 继承 + 虚函数 | 组合 + 委托 |
| 变化粒度 | 算法中的某些步骤 | 整个算法 |
| 适用场景 | 流程固定,步骤可变 | 算法可整体替换 |
| STL 对应 | std::accumulate(骨架固定) | std::sort(可换排序算法) |
说白了,模板方法模式是「在基类里定流程,子类填细节」;策略模式是「把算法整个抽出来,运行时替换」。两者不冲突,甚至可以结合使用。
SVG 结构图:模板方法模式的核心逻辑
总结
模板方法模式在 C++ 里是个很实用的设计工具。它和 STL 的泛型思想并不冲突,反而可以互补。STL 用模板和函数对象实现了编译时的「骨架 + 策略」,而模板方法模式用虚函数实现了运行时的「骨架 + 步骤」。
我个人建议:
- 如果算法流程非常稳定,只是某些步骤的实现不同,用模板方法模式
- 如果整个算法都可能被替换,用策略模式
- 如果需要在编译期就确定策略,用 STL 的泛型方式
- 钩子方法不要设计成纯虚函数,给个默认实现更友好
嗯,差不多就这些。模板方法模式看着简单,但用好了能让代码结构清晰很多。下次你写类似「流程固定、细节可变」的代码时,不妨试试这个思路。
一个小技巧:在基类的模板方法里,可以用 final 关键字防止子类重写整个流程。这样能保证算法骨架不被破坏,子类只能修改你允许修改的部分。