模板方法模式:算法骨架,子类实现细节

模板方法模式,说白了就是「把不变的部分写死,把可变的部分留给子类」。我刚开始接触设计模式时,觉得这玩意儿太简单了——不就是虚函数加个基类吗?后来在项目中真正用起来,才发现它的威力远不止于此。

你想想看,框架设计里最头疼的是什么?是「让用户能定制,但又不能让他们把框架搞坏」。模板方法模式正好解决了这个问题:父类定义好流程,子类只能填空,不能改流程。

模式本质:好莱坞原则

「别打电话给我们,我们会打给你。」——这就是好莱坞原则,也是模板方法模式的核心思想。

父类控制整个算法的执行流程,子类只需要实现某些步骤。父类在合适的时机调用子类的方法,子类不能反过来控制父类的流程。

核心角色:
  • 抽象类(AbstractClass):定义算法骨架,包含模板方法和若干抽象/钩子方法
  • 具体类(ConcreteClass):实现抽象方法,可选地重写钩子方法
  • 模板方法(Template Method):定义算法步骤,通常声明为 final(C++中非虚函数)

一个真实的例子:数据导出框架

我记得之前做一个报表系统,需要支持导出成 CSV、Excel、PDF 三种格式。每种格式的导出步骤其实差不多:

  1. 打开数据源
  2. 读取数据
  3. 格式化数据
  4. 写入文件
  5. 关闭资源

你看,步骤 1、2、5 是通用的,步骤 3、4 才是差异点。用模板方法模式再合适不过了。

class DataExporter {
public:
    // 模板方法——定义算法骨架
    void exportData(const std::string& filePath) {
        openDataSource();           // 通用
        auto data = readData();     // 通用
        auto formatted = formatData(data);  // 子类实现
        writeToFile(formatted, filePath);   // 子类实现
        closeDataSource();          // 通用
    }

    virtual ~DataExporter() = default;

protected:
    // 抽象方法——子类必须实现
    virtual std::string formatData(const std::vector<Record>& data) = 0;
    virtual void writeToFile(const std::string& content, const std::string& path) = 0;

    // 钩子方法——子类可选重写
    virtual bool needHeader() const { return true; }

private:
    // 通用步骤——子类不可重写
    void openDataSource() {
        // 连接数据库、打开文件等
    }

    std::vector<Record> readData() {
        // 执行查询、读取数据
    }

    void closeDataSource() {
        // 释放资源、关闭连接
    }
};

这里有个细节:模板方法 exportData 我声明为普通成员函数,不是虚函数。为什么?因为我不希望子类重写整个流程,子类只能填空,不能改流程。

钩子方法:给子类留点余地

有时候,某些步骤不是必须的。比如 CSV 导出可能需要表头,但 PDF 导出不需要。这时候钩子方法就派上用场了。

class CsvExporter : public DataExporter {
protected:
    std::string formatData(const std::vector<Record>& data) override {
        std::string result;
        if (needHeader()) {
            result += "Name,Age,Email\n";
        }
        for (const auto& record : data) {
            result += record.name + "," + 
                      std::to_string(record.age) + "," + 
                      record.email + "\n";
        }
        return result;
    }

    void writeToFile(const std::string& content, const std::string& path) override {
        std::ofstream file(path);
        file << content;
    }
};

class PdfExporter : public DataExporter {
protected:
    std::string formatData(const std::vector<Record>& data) override {
        // PDF 有自己的格式化逻辑
    }

    void writeToFile(const std::string& content, const std::string& path) override {
        // 使用 PDF 库写入
    }

    bool needHeader() const override { return false; }  // PDF 不需要表头
};
我的习惯:钩子方法默认返回 true 或空操作,子类按需重写。这样既保持了灵活性,又不会强迫子类实现不需要的功能。

框架设计中的应用

模板方法模式在 C++ 框架中随处可见。我随便举几个例子:

框架/库 模板方法 子类实现
MFC CWinApp::Run() InitInstance(), ExitInstance()
Qt QWidget::paintEvent() paint() 中的绘制逻辑
STL std::sort 的比较器 operator< 或 lambda
单元测试框架 Test::run() setUp(), tearDown(), testMethod()

你看,这些框架的核心思想都一样:框架控制流程,用户提供实现。这就是模板方法模式的精髓。

避坑指南

我曾经踩过的坑:
  • 模板方法里调用虚函数:如果在构造函数或析构函数中调用虚函数,C++ 不会按你期望的方式分发。子类还没构造完呢!
  • 过度使用钩子方法:钩子方法太多,子类实现起来很痛苦。我一般控制在 2-3 个以内。
  • 模板方法太复杂:如果一个模板方法超过 20 行,说明你可能需要拆分了。算法骨架应该清晰易懂。

与策略模式的对比

很多人会把模板方法和策略模式搞混。我简单说一下区别:

  • 模板方法:继承实现,子类改写父类的某些步骤。适合「大部分相同,小部分不同」的场景。
  • 策略模式:组合实现,通过接口注入不同的算法。适合「算法完全可替换」的场景。

举个例子:做咖啡和茶,步骤差不多(烧水、冲泡、倒杯、加料),只是冲泡和加料不同——用模板方法。但如果你有多种支付方式(支付宝、微信、银行卡),每种支付方式完全独立——用策略模式。

SVG 结构图

模板方法模式结构图 AbstractClass + templateMethod() : void # primitiveOp1() : void # primitiveOp2() : void # hook() : bool templateMethod() { primitiveOp1(); if (hook()) primitiveOp2(); } ConcreteClass + primitiveOp1() : void { // 具体实现 } + primitiveOp2() : void { // 具体实现 } + hook() : bool { return false; } 继承 模板方法定义算法骨架,子类实现具体步骤。钩子方法提供可选扩展点。

总结

模板方法模式,说白了就是「框架思维」。你写的是框架,用户写的是插件。你控制流程,用户提供细节。

我个人觉得,这个模式最大的价值不在于代码复用,而在于「约定大于配置」。它明确告诉使用者:哪些地方你可以改,哪些地方你不能碰。这种清晰的边界,在大型项目中尤其重要。

嗯,最后说一句:别把模板方法搞得太复杂。一个好的模板方法,读起来应该像一篇清晰的菜谱——步骤明确,顺序固定,留给厨师的发挥空间恰到好处。

一句话记住:父类写剧本,子类演角色。剧本不能改,台词可以变。

公众号:蓝海资料掘金营,微信 deep3321