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() {
        // 公共的关闭文件逻辑
    }
};

你看,beforeProcessHookafterWriteHook 就是钩子。子类如果不需要,完全不用管它们。需要的时候,覆盖一下就行。

我的习惯:在设计基类时,我会把钩子方法写成 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 结构图:模板方法模式的核心逻辑

模板方法模式核心结构 基类:算法骨架 process() 模板方法 step1() 纯虚函数 step2() 纯虚函数 hook() 钩子方法 子类实现区 子类A 实现 step1, step2 子类B 实现 step1, step2 子类C 实现 step1, step2 算法骨架固定不变,子类只负责实现可变步骤,钩子方法提供可选扩展点

总结

模板方法模式在 C++ 里是个很实用的设计工具。它和 STL 的泛型思想并不冲突,反而可以互补。STL 用模板和函数对象实现了编译时的「骨架 + 策略」,而模板方法模式用虚函数实现了运行时的「骨架 + 步骤」。

我个人建议:

  • 如果算法流程非常稳定,只是某些步骤的实现不同,用模板方法模式
  • 如果整个算法都可能被替换,用策略模式
  • 如果需要在编译期就确定策略,用 STL 的泛型方式
  • 钩子方法不要设计成纯虚函数,给个默认实现更友好

嗯,差不多就这些。模板方法模式看着简单,但用好了能让代码结构清晰很多。下次你写类似「流程固定、细节可变」的代码时,不妨试试这个思路。

一个小技巧:在基类的模板方法里,可以用 final 关键字防止子类重写整个流程。这样能保证算法骨架不被破坏,子类只能修改你允许修改的部分。