在维护一个老项目时我接手了报表导出模块。表面上功能很正常CSV、JSON、Excel三种格式都有人用但打开代码后我差点没绷住三个导出器内部几乎一模一样都是查数据、转格式、写文件区别只在中间一两步。前任开发用复制粘贴的方式写了三遍某次需求要求在导出末尾追加文件校验码改到第三份时漏了一处校验结果线上报表对账失败。这件事让我重新把C中的模板方法模式捡起来用继承骨架替换复制粘贴把公共流程彻底锁死只让每个格式实现自己的差异步骤。这篇就把我的完整做法、踩坑过程和代码都整理出来给同样被重复流程代码折磨的人一个可参考的方案。1. 从三段复制粘贴的导出代码说起模板方法模式解决的到底是哪个问题1.1 业务背景流程固定、步骤各异的真实困局先还原一下当时的场景。我手上的导出模块需要支持三种格式但无论最终输出成什么流程骨架永远是校验参数、从数据库取数据、把数据按固定规则渲染成文本、写入目标文件。这四个步骤中前两个基本一样第三个和第四个在不同格式下实现完全不同。CSV要把记录拼成逗号分隔的行JSON要处理嵌套和转义Excel则要生成特定表格结构。这种整体流程稳定、局部步骤多变的现象几乎存在于所有业务系统里。数据迁移要经历抽取、清洗、转换、装载订单处理要经历校验、扣库存、支付、通知报表系统要经历取数、计算、格式化、输出。一开始你可能会觉得不就是几行重复代码吗复制快得很但当需求一变再变、格式一加再加复制出来的代码就会变成维护者的噩梦。我接手时这份代码的丑陋之处在于三个导出类各自实现了完整的导出流程没有公共基类没有抽象接口。校验参数那一段在三个类里出现了三次其中两次用的还是不同的校验逻辑。数据获取部分基本一样只是查询参数不同。真正有差异的渲染和写入反而淹没在大片重复代码里阅读时根本分不清哪儿是公共逻辑、哪儿是格式差异。1.2 三种常规做法和它们的代价面对这种局面开发者的本能反应通常是三种我一个个说它们的问题。第一种是继续加参数分支。在导出函数里加一个格式枚举然后用if-else或者switch把不同格式的渲染逻辑塞进去。这种做法刚开始看起来还挺干净但每新增一种格式就要侵入已有的公共函数改动公共代码就意味着可能会影响老格式风险极高。更别说函数会越来越长C编译器看到这种动辄几百行、层层嵌套的分支函数也会降低优化意愿。第二种是复制粘贴再修改。上一任就是典型反面教材。每次新增格式都复用一份现有代码把能用的部分留下来不能用的改掉。这种做法的直接后果就是代码膨胀、重复逻辑失修——公共逻辑一旦要改必须找到所有副本同步修改漏一个就会出线上事故。所谓工作正常只是没触发那个被漏改的条件而已。第三种才是正确的方向把公共流程抽出来作为不可变的骨架把每个格式特有的步骤留在子类里子类只负责实现差异部分。这就是C中的模板方法模式Template Method Pattern要做的事。它属于行为型设计模式核心在于定义一个操作中的算法骨架将某些步骤延迟到子类实现。骨架本身是固定的、甚至可以用final锁死子类只能替换其中的可变步骤不能改变整体执行顺序。1.3 好莱坞原则别打电话给我们我们会打给你理解模板方法模式最关键的一句话是不要调用我们我们会调用你。英文社区管这叫Hollywood Principle。放到上面的报表场景里这句话的意思很明白。子类不需要自己决定先取数还是先渲染也不需要关心导出流程何时开始、何时结束。基类的骨架方法会主动调用子类实现的各个步骤。子类要做的事情只有一件把步骤函数写好等着骨架在正确的时机、正确的顺序来调用你。这种反转的控制流让算法骨架具备了很强的稳定性。因为流程控制权集中在基类里任何格式的导出顺序都是一样的校验、取数、渲染、写入不会有某个子类突发奇想先写文件再取数。如果某种新格式确实需要完全不同的流程那不是模板方法模式该管的事应该重新审视设计而不是强行往骨架上塞。2. C中实现模板方法模式的三种姿势虚函数、final与NVI惯用法2.1 最正宗的骨架写法公开非虚接口 受保护虚函数直接用C来写模板方法模式最标准的结构是这样的基类提供一个public的、非虚的骨架方法骨架内部的步骤设计为protected虚函数部分步骤可以是纯虚函数部分可以带默认实现。我用报表导出器做一个简化版示意#include string #include vector #include fstream #include stdexcept struct Record { std::string name; int score; }; class Exporter { public: virtual ~Exporter() default; // 骨架用 final 锁死子类不允许改写整体流程 void exportTo(const std::string path) final { if (!onPreCheck(path)) { throw std::runtime_error(export precondition failed); } std::vectorRecord records onLoad(); std::string content onRender(records); onWrite(path, content); onFinished(path); } protected: // 钩子可选步骤默认返回 true virtual bool onPreCheck(const std::string path) { (void)path; return true; } // 抽象步骤一加载数据必须实现 virtual std::vectorRecord onLoad() 0; // 抽象步骤二渲染数据必须实现 virtual std::string onRender(const std::vectorRecord records) 0; // 抽象步骤三写入文件必须实现 virtual void onWrite(const std::string path, const std::string content) 0; // 钩子导出完成后的动作默认什么都不做 virtual void onFinished(const std::string path) { (void)path; } };这段代码有几个关键设计决策逐一解释。第一骨架方法exportTo是public的、非虚的、还加了final。为什么非虚因为调用者通过基类指针调用导出流程时他需要的是按照标准流程导出而不是让某个子类自定义导出流程。如果骨架本身就是虚函数子类一旦覆写它就等于跳过了所有骨架约束。加上final更是从语言层面杜绝了这种覆写。第二步骤函数全部是protected。这样可以避免外部代码绕过骨架直接调用某个步骤。设想一下如果onWrite是public的调用方完全可以不校验、不取数直接往文件写一段手拼的内容。这会让骨架形同虚设。protected限定之后步骤只能被基类骨架调用子类也只能覆写它天然形成了接口封闭、扩展开放的结构。第三步骤里既有纯虚函数也有带默认实现的钩子。纯虚函数onLoad、onRender、onWrite是必须由子类提供的否则子类无法实例化。钩子onPreCheck、onFinished则不是必须的子类按需覆写。这个区分让接口足够严格又不至于过于苛刻后面第5章会专门展开讲。2.2 为什么骨架方法上要补一个 final很多讲模板方法模式的文章并不会强调final但我个人强烈建议在骨架方法上写上。原因很现实C的虚函数机制允许子类覆写任意虚函数如果骨架方法本身是虚的某个继承层级较深的子类完全可以在override里改进流程。看起来是灵活性实际上是失控的入口。举一个我实际见过的例子。某团队的基类里骨架方法没有加final后续某个子类为了多输出一行调试日志直接覆写了整个骨架。它在骨架开头调用了std::cout打印日志然后调用基类骨架接着又补了一段特殊处理。表面上看只是多了几行日志但三个月后另一个开发在这个覆写后的骨架上又加了一个步骤导致同样的导出操作在不同子类里行为不一致排查了很久才发现是有子类覆写了骨架。用final锁死骨架就是在代码层面告诉后来者这个函数的执行顺序是不可变契约你要扩展只能在受保护的步骤和钩子上做文章。对于被多人维护的类库来说这个约束价值非常大。2.3 NVI惯用法模板方法模式在C里的完全体如果你去翻C编程实践方面的经典书籍会看到一个叫NVINon-Virtual Interface的惯用法它的核心思想是所有public函数一律非虚虚函数全部放到protected或private区域。这种做法保证了基类对接口行为有完全的控制权正因如此公开非虚接口 受保护虚函数其实就是模板方法模式在C中最完整、最地道的落地方式。NVI惯用法比单纯讲模式多了一层洞察暴露给调用者的public方法本质上应该是策略入口而不是实现细节虚函数是给继承者用的扩展点不应该暴露给普通调用方。用NVI做出来的基类一眼就能看出哪些方法是你可以调用的哪些方法是你可以覆写的。接口的可读性和安全性同时得到保障。如果步骤需要被private限定C也是支持的。子类依然可以覆写private虚函数只是不能在自己的代码里直接调用父类的private版本。对于纯粹的抽象步骤我认为protected更合适因为它允许子类在覆写后如果需要还可以调用基类提供的通用实现来做叠加。钩子类函数如果只是空实现则两种权限都无所谓。我的习惯是统一用protected降低规则复杂度也让新手更容易理解。3. 编译期变体CRTP如何把模板方法模式搬到元编程领域3.1 CRTP骨架的基本写法如果项目对性能极其敏感连虚函数的间接调用开销都不想承担或者类型的层次结构在编译期就能完全确定模板方法模式还有另一个C特有的形态用CRTPCuriously Recurring Template Pattern奇异递归模板模式把多态从运行期搬到编译期。CRTP的核心手法是让派生类作为基类模板参数出现基类通过static_cast把自己的this转成派生类引用直接调用派生类的方法。用它实现模板方法模式的骨架长这样template typename Derived class ExporterBase { public: void exportTo(const std::string path) { Derived self static_castDerived(*this); if (!self.onPreCheck(path)) { throw std::runtime_error(export precondition failed); } auto records self.onLoad(); auto content self.onRender(records); self.onWrite(path, content); self.onFinished(path); } protected: // 此时没有虚函数这些只是普通成员函数名字与派生类方法一致 bool onPreCheck(const std::string) { return true; } void onFinished(const std::string) {} }; class CsvExporter : public ExporterBaseCsvExporter { public: std::vectorRecord onLoad() { /* ... */ } std::string onRender(const std::vectorRecord records) { /* ... */ } void onWrite(const std::string path, const std::string content) { std::ofstream f(path); f content; } };基类骨架里的self.onLoad()会通过static_cast转成对CsvExporter::onLoad的直接调用编译器在编译期就能解析清楚不需要虚函数表也没有运行期分派。对性能要求苛刻的代码路径比如每秒钟执行几万次的小型数据管道这种写法能明显减少开销。细心的读者会发现我特意让CRTP版本的步骤函数没有加virtual。在CRTP体系里不需要虚函数依靠模板参数确定具体类型。加不加virtual反而不重要保持普通成员函数即可因为基类里根本没有虚函数表运行时多态并不参与。3.2 动态多态与静态多态的取舍边界很多读者会问既然CRTP性能更好那我是不是应该全面改用CRTP答案是不能无脑替换。两种方案各有不可替代的适用场景我用一张表对照一下维度虚函数版模板方法模式CRTP编译期版运行期开销每次调用虚函数有一次间接跳转无虚函数表开销二进制灵活性子类可藏进动态库运行时加载全部编译期绑定改子类要重编基类相关代码异构集合可用std::vectorstd::unique_ptrExporter统一管理不同类型无法放进同质容器代码复杂度容易理解平铺直叙模板语法对新手有门槛编译时间较轻模板实例化会拖慢编译举个例子。你需要一个导出器集合里面同时挂着CSV、JSON、Excel三种实例调用方统一通过基类指针调用exportTo这种场景就该用虚函数版。反过来如果你的代码中没有导出器集合这个概念每次导出时类型在编译期已经明确那么CRTP就是更好的选择跑得更快还能借助编译器完成更多优化。我在项目里的经验是优先用虚函数版把逻辑理清在性能分析工具明确指出这段代码是热点、并且类型集合固定时才考虑迁移到CRTP。盲目追求编译期多态会让代码过早复杂化。3.3 现代C的组合思路std::function注入步骤除了继承取向的两种方案遇到步骤本身不多、算法骨架里只有一两个位置需要变化的场景还可以用组合方式代替继承。把某个可变步骤抽象成std::function或命令接口骨架方法在运行时调用注入的函数。一个简单的例子class DataPipeline { public: using Loader std::functionstd::vectorRecord(); using Renderer std::functionstd::string(const std::vectorRecord); void setLoader(Loader fn) { load_ std::move(fn); } void setRenderer(Renderer fn) { render_ std::move(fn); } void run() { auto recs load_(); auto text render_(recs); // 输出处理... } private: Loader load_; Renderer render_; };这种写法和策略模式的距离很近本质上是把步骤作为策略对象注入骨架。好处是类层级扁平、自由度大坏处是骨架对流程顺序的约束力变弱没那么强制了。什么时候选它当算法步骤不超过两三个、可变点清晰、且你不想为每一种组合写新子类时组合方案往往更省事。当步骤多、顺序严格、每种格式是稳定组合时模板方法模式依然是更好的选择。4. 实战拆解一个报表导出器的骨架设计与扩展过程4.1 需求边界与骨架定义回到我接手报表导出模块的场景。需求方提了一个明确目标后续还会有多种导出格式接入但流程必须保持一致不允许新来的格式自己另搞一套。基于这个需求我把骨架定义成五个步骤前置检查验证导出路径是否合法、文件是否已存在、磁盘空间是否够加载数据从数据源查询原始记录这是各格式差异很小的步骤但不同报表的查询参数不同渲染内容根据格式把记录转成特定文本结构这是差异最大的步骤写入文件把渲染结果写到目标路径不同格式的写入方式基本一致但有的格式需要控制编码完成回调导出结束后执行的动作通常是记录审计日志、发送通知属于可选钩子再和前文的Exporter类对应起来。前置检查和完成回调设计为钩子加载、渲染、写入设计为纯虚函数。这样设计有一个精妙之处加载数据虽然是必须实现的步骤但衍生的报表导出器可能数据源格式完全不同有的从数据库读取有的从外部接口拉取纯虚函数逼着子类明确实现而不是偷懒继承基类的默认实现。4.2 一个可运行的C实现有了骨架类我给出的CSV和JSON两个导出器实现如下。#include fstream #include sstream #include iostream #include filesystem class CsvExporter : public Exporter { protected: bool onPreCheck(const std::string path) override { if (path.empty()) return false; std::filesystem::path p(path); auto parent p.parent_path(); return parent.empty() || std::filesystem::exists(parent); } std::vectorRecord onLoad() override { // 演示数据实际项目中这里会执行 SQL 查询 return { {Zhang San, 91}, {Li Si, 77} }; } std::string onRender(const std::vectorRecord records) override { std::ostringstream ss; for (const auto rec : records) { ss rec.name , rec.score \n; } return ss.str(); } void onWrite(const std::string path, const std::string content) override { std::ofstream f(path, std::ios::binary); f content; } void onFinished(const std::string path) override { std::cout csv exported to path \n; } }; class JsonExporter : public Exporter { protected: std::vectorRecord onLoad() override { // 演示数据实际项目中这里会调用 HTTP 接口 return { {Alice, 88}, {Bob, 64} }; } std::string onRender(const std::vectorRecord records) override { std::ostringstream ss; ss [; for (size_t i 0; i records.size(); i) { if (i) ss ,; ss {\name\:\ records[i].name \,\score\: records[i].score }; } ss ]; return ss.str(); } void onWrite(const std::string path, const std::string content) override { std::ofstream f(path, std::ios::binary); f content; } };调用端的代码非常干净因为导出器的公共接口一致void exportAllReports(const std::string baseDir) { std::vectorstd::unique_ptrExporter exporters; exporters.emplace_back(std::make_uniqueCsvExporter()); exporters.emplace_back(std::make_uniqueJsonExporter()); for (auto exporter : exporters) { exporter-exportTo(baseDir /report); } }这段代码跑起来之后CSV、JSON导出的顺序完全一致但中间渲染逻辑各走各的路径。维护者看到exporters容器里挂着的都是Exporter基类的时候心里非常有底无论容器里有多少种导出器它们都遵守同一条导出流程。4.3 新增一种导出格式时的完整扩展流程模板方法模式最大的红利是在扩展期。我现在要给系统增加Markdown表格格式按照模式的设计只需要新建一个子类实现三个抽象步骤覆写一个钩子即可class MarkdownExporter : public Exporter { protected: std::vectorRecord onLoad() override { // 从本地文件中读取原始记录 return loadFromLocalFile(history.dat); } std::string onRender(const std::vectorRecord records) override { std::ostringstream ss; ss | name | score |\n; ss | ---- | ----- |\n; for (const auto rec : records) { ss | rec.name | rec.score |\n; } return ss.str(); } void onWrite(const std::string path, const std::string content) override { std::ofstream f(path, std::ios::binary); f content; } };整个扩展没有改动基类一行代码也没有触碰其他导出器。之前那种复制-粘贴-修改三份的操作在这里被压缩成了只写一份新代码。更关键的是需求方后续如果再要求所有格式导出结束时统一追加校验码我只改基类骨架一个地方三个、三十个导出器都会同步生效这正是公共骨架锁死流程的威力。5. 钩子设计的艺术扩展点应该开多大、默认实现给不给5.1 钩子与抽象方法的区别可跳过与必须实现在模板方法模式里步骤其实分成两类很多初学者容易混为一谈。一类是抽象步骤对应C里的纯虚函数。子类要想被实例化必须提供实现。这类步骤通常承载了算法核心比如数据渲染不同格式差异巨大没有默认实现的意义。另一类是钩子对应C里有默认实现的虚函数。子类可以选择覆写也可以选择忽略。钩子一般放在骨架的边界处作为增值动作存在比如前置检查、完成通知、日志记录。两者本质区别在于语义强度。抽象步骤是你必须做的钩子是你可以在合适时机插一脚的。设计骨架时务必要分清。如果把不该做成钩子的步骤做成钩子就会出现某个子类忘记覆写、走了不合理默认逻辑的问题。拿前置检查举例我当时把它设计成钩子默认返回true。这个选择背后有考虑如果某个导出器压根不需要前置检查它没必要被迫实现一个什么都不做的函数。同理完成回调默认空实现不给纯虚否则每个新导出器都要写一个空的onFinished纯属噪音。这个原则叫默认实现优先于强制抽象。5.2 空实现优先于纯虚函数减少子类负担很多刚接触设计模式的人容易陷入一种误区认为接口越抽象越好所有步骤都应该做成纯虚函数。实际维护时你会发现强制子类实现一堆它不关心的函数只会在子类里留下一堆空的、让人看不懂的占位代码。我见过这样的基类六个纯虚函数其中两三个在大多数子类里都是空函数体注释写着not needed。这些空实现浪费了阅读者的注意力也模糊了真正需要关注的扩展点。正确的做法是只有那些算法正常运行所必需、且各实现之间差异显著、没有合理默认值的步骤才做成纯虚函数其余步骤全部提供空实现或默认实现让子类按需覆写。这一点也和前文的钩子设计一脉相承。钩子不是越多越好而是应该精简到通常不需要动需要动时语义清晰的程度。5.3 钩子粒度过粗过细会踩什么坑钩子的粒度和数量是模板方法模式落地中最需要拿捏的地方。钩子粒度过粗体现在步骤划分太笼统。假设我只把骨架分成准备、执行、收尾三个钩子子类想要在渲染后、写文件前插入一个步骤就只能覆写收尾钩子在收尾里判断如果已经在写文件之后了先做某步再做下一步逻辑反而绕远。钩子粒度过细则完全是另一种灾难。我曾经见过一个骨架类里面放了十几个onXxx钩子每个钩子之间只隔一两行代码。表面看扩展点丰富实际操作时子类根本不知道应该覆写哪个钩子得逐行读骨架代码。更糟的是基类为了维护这么多钩子逻辑被切碎骨架的流程反而不清晰了。我的经验值5个左右步骤的骨架钩子数量控制在2到3个是合理区间。命名上统一用on前缀如onPreCheck、onFinished或should前缀如shouldWriteHeader并提供注释说明每个钩子被调用的时机和默认行为。这样既留住了灵活性又不会让继承者迷失在过密的扩展点里。钩子数量与骨架复杂度的对照可以看这个经验表骨架步骤数建议钩子数调整策略3个以内0到1个通常不需要钩子直接放抽象步骤4到6个2到3个在骨架首尾或关键阶段边界放钩子7个以上3到5个同时考虑是否应拆成多个类钩子不宜过多6. 多年实战中踩过的坑模板方法模式的边界条件与演化路线6.1 构造函数中调用虚函数C最经典的一课模板方法模式中骨架方法通常由外部调用而不是在构造函数里调用。但有些开发图省事想让子类构造完就自动执行流程于是写出了这种代码class BadExporter : public Exporter { public: BadExporter() { exportTo(auto_report.csv); // 危险行为 } };或者更隐蔽地在基类构造函数里调用钩子class BadBase { public: BadBase() { init(); // 如果 init 是虚函数此时调用的是 BadBase::init不是子类版本 } virtual void init() 0; };C对象模型明确规定在构造函数执行期间对象的动态类型是当前正在构造的类派生类部分还没有初始化虚函数分派不会走到派生类实现。也就是说基类构造函数里调用虚函数得到的是基类版本的实现如果这个虚函数是纯虚函数行为是未定义的大概率会链接失败或崩溃。正确做法是把骨架方法留给外部显式调用或者在构造完成后通过单独的init方法触发。如果真的需要在初始化时执行骨架流程那应该让子类构造完成后再调用骨架或者把骨架调用从构造函数里挪到另一个工厂函数中。这个教训在C社区里反复被强调但在模板方法模式落地时依然频繁踩中值得单拎出来说。6.2 脆弱的基类改一行骨架引发的连锁反应模板方法模式的另一个深坑叫脆弱基类问题。基类骨架的任何改动都可能影响所有继承者。我经历过一次令人印象深刻的教训。当时我在骨架方法exportTo里新增了一个步骤渲染前统一对数据进行一次合法性校验。我认为这是公共逻辑放在基类很合理。结果发现某个子类之前为了绕过不在基类里做校验在自己的onLoad里实现了变通的空数据处理逻辑。基类新增校验后该子类抛出了一堆本来可以避免的运行时异常。这件事给我的启发是修改骨架时绝不能只看基类源码还要查看所有继承者的实现确认新增的公共步骤不会破坏它们对默认行为的假设。特别是钩子的默认行为发生变化时影响面往往超出预期。为了避免这类问题团队里可以把骨架方法视为接口契约的一部分修改前先列一个影响清单逐个子类核对。6.3 继承层级过高时的逃亡路线组合替换与策略模式模板方法模式的另一个风险是继承树越滚越深。最初的基类是Exporter后来为了支持带页眉的文档有人做了一个HeaderExporter再后来为了支持加密输出又有人做了EncryptedHeaderExporter层级一多组合爆炸就来了。这种时候要清醒认识到模板方法模式擅长处理骨架稳定、少量步骤变化的问题。如果变化的维度超过两个继承就撑不住了。比如渲染方式是一个维度加密与否是另一个维度两者交叉用继承会产生大量的子类组合。此时应该改用组合方式把步骤中的算法用策略对象抽出去。用一个简化例子说明。把渲染逻辑拆成独立的策略接口class RenderStrategy { public: virtual ~RenderStrategy() default; virtual std::string render(const std::vectorRecord records) 0; }; class CsvRenderStrategy : public RenderStrategy { public: std::string render(const std::vectorRecord records) override { // CSV 渲染 } }; class JsonRenderStrategy : public RenderStrategy { public: std::string render(const std::vectorRecord records) override { // JSON 渲染 } };导出器不再强调自己是哪种格式而是拥有哪种渲染策略。需要加密导出时给导出器包一层加密装饰器即可。组合方案牺牲了一点流程完全可控的稳定性换来了更灵活的多维扩展。当一个类里的虚函数超过四五个、或者两个变化维度的乘积让子类数量失控时就该考虑这条逃亡路线了。模式不是目的可维护性才是。6.4 可测试性补救默认钩子与注入技巧最后聊一个很容易被忽视的话题骨架代码的单元测试。模板方法模式把大部分逻辑放在基类骨架里但基类又有纯虚函数测试骨架时不能直接实例化基类。我的做法是在测试代码里写一个专门用于测试的桩子类class ExporterForTest : public Exporter { public: std::vectorRecord records; bool writeCalled false; protected: std::vectorRecord onLoad() override { return records; } std::string onRender(const std::vectorRecord recs) override { return rendered: std::to_string(recs.size()); } void onWrite(const std::string, const std::string) override { writeCalled true; } }; TEST(ExporterTest, SkeletonCallsStepsInOrder) { ExporterForTest e; e.records {{A, 10}}; e.exportTo(/tmp/out.txt); EXPECT_TRUE(e.writeCalled); }用这个桩子类骨架的调用顺序、钩子是否被触发、异常是否被正确抛出都可以被稳定地测试到。如果钩子的默认实现没法满足测试需求也可以在桩子类里覆写钩子把默认行为覆盖成可控的测试替身。这属于模板方法模式的特色测试技巧普通文档里很少写实际工作中非常管用。至于重构时机我的个人判断标准很简单当你第三次对同一段流程复制粘贴时就应该停下来抽骨架当你发现修改基类骨架需要改动三个以上子类时就要考虑是否拆解组合当新功能必须递推覆写骨架方法时说明模式已经用歪了回到第6.3节重新做设计决策。模板方法模式是工具不是镣铐它的价值在于减少重复、收敛流程一旦它开始制造新的重复和混乱就要敢于用组合替换掉它。最后再分享一个我在实际维护中形成的小习惯每次新建一个模板方法模式骨架类时我会在骨架方法的注释里写明各步骤的调用顺序、哪些子类已经继承、以及修改时需要重点核对的事项。这份骨架契约文档看起来不起眼但在团队协作里帮我规避了好几次脆弱的基类事故。设计模式的知识人人都能背真正让代码长期保持健康的往往就是这些文档、边界检查和及时的重构判断。