深入浅出设计模式【二十二、模板方法模式】
一、模板方法模式介绍
在软件开发中,经常会遇到一个场景:多个算法或流程在整体步骤上是相同的,但在某些具体的实现细节上有所不同。例如,数据导出流程可能都包含“准备数据”、“格式化数据”、“写入输出”等步骤,但导出到PDF和导出到CSV的“格式化数据”和“写入输出”步骤的实现完全不同。
如果为每种情况都单独编写一个完整的方法,会导致大量的代码重复,并且如果整体流程需要修改,必须在所有地方进行相同的更改,这违反了DRY(Don’t Repeat Yourself)原则,也使得维护变得困难。
模板方法模式通过将不变的算法骨架提升到父类(一个所谓的“模板方法”中),而将可能变化的具体步骤实现推迟到子类,完美地解决了这个问题。它通过继承来实现行为的扩展,是一种“反向控制”的体现,即父类调用子类的操作。
二、核心概念与意图
-
核心概念:
- 抽象类/模板类 (Abstract Class): 定义了一个模板方法,该方法给出了一个算法的顶级骨架。该模板方法由一系列步骤组成,这些步骤可以是抽象的,也可以有默认实现。
- 模板方法 (Template Method): 定义在抽象类中,它按特定顺序调用一系列步骤方法。该方法通常被声明为
final,以防止子类重写整个算法结构。 - 步骤方法 (Step Methods):
- 抽象步骤 (Abstract Steps): 在抽象类中声明为抽象方法,必须由子类实现。这是子类必须提供的变化部分。
- 具体步骤 (Concrete Steps): 在抽象类中已经实现的方法。子类可以继承它,也可以选择重写它(根据定义方式而定)。
- 钩子方法 (Hook Methods): 在抽象类中已提供默认实现(通常为空)的方法。子类可以选择性地重写它,以在算法的特定点进行干预,从而提供额外的灵活性。它不是必须被重写的。
-
意图:
- 定义一个操作中的算法骨架,而将一些步骤延迟到子类中。
- 使得子类可以不改变一个算法的结构即可重新定义该算法的某些特定步骤。
- 通过父类提供公共代码,避免代码重复,实现代码复用。
三、适用场景剖析
模板方法模式在以下场景中非常有效:
- 多个类包含几乎相同的算法,只有细微的差别时: 将公共的算法结构提取到父类的模板方法中,不同的实现细节留给子类。
- 需要控制子类扩展点时: 当希望子类只能扩展算法的特定部分,而不是整个算法时,使用模板方法模式可以精确控制允许子类重写哪些部分。
- 构建框架或基础库时: 框架通常定义了一个流程的骨架,而将具体的实现细节留给框架的使用者(通过继承并实现抽象方法)。这是模板方法模式最经典的应用。
- 重要且复杂的算法,希望将其分解为一系列可被重写的步骤时: 这提高了算法的可读性和可维护性。
四、UML 类图解析(Mermaid)
以下UML类图清晰地展示了模板方法模式的结构和角色间的关系:
AbstractClass(抽象类/模板类):- 定义了一个
final的templateMethod()。该方法实现了算法的骨架,由一系列对step1(),step2(),step3(),hook()的调用组成。 - 声明了若干抽象步骤方法(如
step1(),step3()),这些方法必须由子类实现。 - 提供了具体步骤方法(如
step2())的默认实现。子类可以继承它,也可以选择重写(如果未被定义为final)。 - 提供了钩子方法(如
hook()),通常是一个空实现或返回默认值的方法(例如return true;)。子类可以选择性地重写它以影响模板方法的逻辑。
- 定义了一个
ConcreteClassA和ConcreteClassB(具体子类):- 继承自
AbstractClass。 - 必须实现所有父类中定义的抽象步骤方法。
- 可以选择性地重写父类提供的具体步骤方法或钩子方法。
- 它们不能重写被声明为
final的templateMethod()。
- 继承自
- “好莱坞原则” (Hollywood Principle): 模板方法模式是“别调用我们,我们会调用你”这一原则的典型体现。父类(高层次组件)控制着流程,只在需要时调用子类(低层次组件)的实现。子类永远不会直接调用父类的模板方法。
五、各种实现方式及其优缺点
模板方法模式的实现相对固定,核心在于对步骤方法的类型划分。
1. 标准实现(抽象类 + 继承)
即上述UML所描述的方式,使用抽象类和继承机制。
// 1. Abstract Class
public abstract class DataExporter {
// This is the template method (final to prevent overriding)
public final void exportData() {
prepareData();
String formattedData = formatData(); // Abstract step
writeOutput(formattedData); // Concrete step
if (shouldNotify()) { // Hook method
sendNotification();
}
cleanup();
}
// Concrete step (has a default implementation, can be overridden)
protected void prepareData() {
System.out.println("Preparing data from database...");
// Common data preparation logic
}
// Abstract step (must be implemented by subclasses)
protected abstract String formatData();
// Concrete step (has a default implementation, can be overridden)
protected void writeOutput(String data) {
System.out.println("Writing data to standard output:");
System.out.println(data);
}
// Hook method (has a default implementation, can be overridden)
protected boolean shouldNotify() {
return false; // Default is no notification
}
// Hook method's helper (can be overridden if hook is overridden)
protected void sendNotification() {
System.out.println("Sending default email notification...");
}
// Concrete step
protected void cleanup() {
System.out.println("Cleaning up temporary resources...");
}
}
// 2. Concrete Subclass A
public class CsvExporter extends DataExporter {
@Override
protected String formatData() {
System.out.println("Formatting data as CSV...");
return "id,name,value\n1,Test,100"; // Simulated CSV data
}
}
// 3. Concrete Subclass B
public class PdfExporter extends DataExporter {
@Override
protected String formatData() {
System.out.println("Formatting data as PDF...");
return "%PDF-1.5... (simulated PDF data)"; // Simulated PDF data
}
@Override
protected void writeOutput(String data) {
System.out.println("Writing PDF data to file 'report.pdf'...");
// Specific logic to write PDF bytes to a file
}
@Override
protected boolean shouldNotify() { // Overriding the hook
return true; // PDF export always sends a notification
}
@Override
protected void sendNotification() {
System.out.println("Sending special notification for PDF export...");
}
}
// 4. Client
public class Client {
public static void main(String[] args) {
DataExporter exporter = new CsvExporter();
exporter.exportData();
System.out.println("---------------");
exporter = new PdfExporter();
exporter.exportData();
}
}
- 优点:
- 代码复用: 将公共的算法结构提升到父类,避免了代码重复。
- 易于维护: 修改算法骨架只需修改父类的模板方法。
- 符合开闭原则: 可以轻松增加新的具体子类来引入新的行为,而无需修改现有代码。
- 反向控制: 父类控制流程,子类只负责提供细节,结构清晰。
- 缺点:
- 对继承的强依赖: Java是单继承,使用模板方法模式会占用宝贵的继承机会。
- 可能违反里氏替换原则 (LSP): 如果子类对步骤方法的实现破坏了父类定义的契约(如改变步骤的语义),可能导致程序出错。
- 框架与实现的耦合: 子类与父类紧密耦合,父类的任何变化都可能影响子类。
2. 使用钩子方法提供扩展点
钩子方法(Hook Method)是模板方法模式中提供灵活性的关键。它通常是一个有默认空实现的方法,子类可以选择性地重写它,从而在算法的特定点插入自定义行为。
- 用途:
- 控制流程: 例如,
shouldProcess()钩子返回一个布尔值,模板方法根据返回值决定是否执行某个步骤。 - 提供额外操作: 例如,
beforeSave()或afterLoad()钩子,允许子类在特定步骤前后执行操作。 - 提供上下文信息: 例如,
getConfig()钩子,允许子类提供配置。
- 控制流程: 例如,
六、最佳实践
- 尽量减少需要重写的方法: 模板方法的目标是最大化代码复用。应尽量提供更多的具体步骤方法和有用的钩子方法,只将真正会变化的部分定义为抽象方法。
- 明智地使用
final:- 将模板方法声明为
final,防止子类重写整个算法骨架。 - 将不希望被子类改变的具体步骤方法也声明为
final,以保护核心逻辑。
- 将模板方法声明为
- 使用钩子方法而非抽象方法: 如果某个步骤是可选的,优先将其定义为钩子方法(有默认实现),而不是抽象方法。这减少了子类的实现负担。
- 明确命名: 为模板方法和步骤方法起一个能清晰描述其职责的名字。钩子方法的名字应能明显表明其可选性(如
preExecute(),canProceed())。 - 与策略模式区分:
- 模板方法模式: 基于继承。定义一个算法骨架,让子类实现部分步骤。强调算法步骤的固定和部分实现的变化。
- 策略模式: 基于组合。定义一系列完整的、可互换的算法,客户端决定使用哪个。强调整个算法的可替换性。
- 选择: 如果算法步骤稳定而部分实现易变,用模板方法。如果需要完全替换不同的算法,用策略模式。两者有时可结合使用。
七、在开发中的演变和应用
模板方法模式是构建框架和基础库的基石,其思想广泛应用于:
- Java Servlet 和 JSP: 从概念上讲,HttpServlet的
service方法根据HTTP方法(GET, POST)调用doGet,doPost等方法,这类似于一个模板方法。框架提供了请求/响应的处理骨架,开发者只需重写doGet等具体步骤方法。 - JUnit: JUnit 3的
TestCase类要求测试方法以test开头。TestRunner执行测试的流程是固定的(setUp->testXxx->tearDown),这正是一个模板方法模式的应用。JUnit 4/5虽然改用注解,但核心思想(生命周期回调)依然存在。 - Spring Framework:
- JdbcTemplate: 这是模板方法模式的典范。它定义了数据访问的流程:获取连接、创建语句、执行SQL、处理结果集、处理异常、释放资源。其中
execute()是模板方法,而处理结果集的mapRow(或RowMapper)则是需要开发者提供的可变步骤。Spring的JmsTemplate,HibernateTemplate,RestTemplate等都遵循这一模式。 - AbstractController: Spring MVC早期的
AbstractController类提供了处理Web请求的模板方法,子类可以重写特定方法。
- JdbcTemplate: 这是模板方法模式的典范。它定义了数据访问的流程:获取连接、创建语句、执行SQL、处理结果集、处理异常、释放资源。其中
- Java IO:
InputStream和OutputStream的read和write方法是非抽象的,它们调用了抽象的read和write方法,这也是一种简单的模板方法应用。
八、真实开发案例(Java语言内部、知名开源框架、工具)
-
Java Collections -
AbstractList,AbstractSet,AbstractMap:- Java集合框架中这些抽象类大量使用了模板方法模式。它们提供了集合操作的部分通用实现,并将核心方法留为抽象。
- 例如,
AbstractList提供了基于get(int index)和size()(抽象方法)的iterator(),indexOf(),contains()等方法的实现。开发者要创建一个不可修改的列表,只需继承AbstractList并实现get和size即可,无需重写其他方法。
-
Java HttpServlet:
HttpServlet类是一个典型的例子。其service(HttpServletRequest req, HttpServletResponse resp)方法根据请求的HTTP方法,分发到相应的doGet,doPost,doPut,doDelete等方法。- 开发者只需重写他们需要处理的HTTP方法对应的
doXxx方法。service方法充当了模板方法,定义了请求处理的总体流程。
-
Spring JdbcTemplate:
- 如前所述,
JdbcTemplate.execute(ConnectionCallback action)等方法封装了使用JDBC的所有样板代码(获取连接、处理异常、关闭连接)。 - 开发者需要提供的只是一个
ConnectionCallback(或PreparedStatementCreator,RowMapper),这相当于一个步骤方法的实现。JdbcTemplate完美地将变化的部分(SQL和结果处理)与不变的部分(资源管理)分离开来。
- 如前所述,
-
Hibernate 的
DefaultSaveOrUpdateEventListener等 (概念上):- Hibernate的事件监听器系统允许开发者重写特定的事件处理逻辑。其默认实现可以看作是提供了某种“模板”,开发者可以介入其中的特定步骤。
-
Java AWT 的
paint方法:- 在AWT中,组件绘制时首先调用
paint(模板方法),paint又会依次调用paintBackground,paintBorder,paintComponent。开发者通常重写paintComponent这个“步骤方法”来自定义绘制内容。
- 在AWT中,组件绘制时首先调用
九、总结
| 方面 | 总结 |
|---|---|
| 模式类型 | 行为型设计模式 |
| 核心意图 | 定义一个算法的骨架,允许子类为一个或多个步骤提供实现,而不改变算法结构。 |
| 关键角色 | 抽象类(AbstractClass),模板方法(Template Method),步骤方法(Step Methods)(抽象步骤、具体步骤、钩子方法) |
| 核心机制 | 1. 继承与反向控制: 父类定义骨架并调用子类实现。 2. 方法分解: 将算法分解为系列步骤。 3. 钩子方法: 提供可控的扩展点。 |
| 主要优点 | 1. 极大的代码复用,避免重复。 2. 良好的扩展性,符合开闭原则。 3. 反向控制结构清晰,父类控制流程。 4. 便于维护,修改骨架只需改一处。 |
| 主要缺点 | 1. 对继承的强依赖,占用继承树。 2. 父类与子类耦合度高。 3. 可能违反里氏替换原则(如果子类实现不当)。 |
| 适用场景 | 多个算法共享相同流程结构,只有部分实现不同;构建框架,定义流程骨架;重要算法需要分解和复用。 |
| 最佳实践 | 模板方法声明为 final;合理使用钩子方法;尽量减少抽象方法数量;与策略模式区分。 |
| 现代应用 | 框架设计的基石(Spring, JUnit),库设计(Java Collections),消除样板代码(JdbcTemplate)。 |
| 真实案例 | Java HttpServlet,Spring JdbcTemplate,Java AbstractList,JUnit 生命周期。 |
模板方法模式是代码复用和框架设计的利器。它通过巧妙地利用继承和反向控制,将公共的、稳定的算法骨架与易变的、具体的实现细节分离,使得系统更加结构化、可维护和可扩展。尽管对继承的依赖是其一个局限,但在构建基础库、框架以及处理具有固定流程的业务组件时,它依然是不可或缺的核心模式。掌握模板方法模式,意味着你掌握了构建可扩展、高复用软件组件的关键设计能力。
本文来自博客园,作者:NeoLshu,转载请注明原文链接:https://www.cnblogs.com/neolshu/p/19120771

浙公网安备 33010602011771号