设计模式-模板方法模式

写在前面

Hello,我是易元,这篇文章是我学习设计模式时的笔记和心得体会。如果其中有错误,欢迎大家留言指正!


需求背景

以饮品店铺售卖的饮品制作流程为例,进行模板方法模式的学习,饮品种类较多,且部分制作流程都比较具有相似性,而在具体的部分步骤又不一样,可以清晰的理解出 "变" 与 "不变"。

每种饮品的制作流程大致都包含以下步骤:

  1. 准备原料
  2. 煮沸水或牛奶
  3. 冲泡主要成分(咖啡粉、茶叶等)
  4. 添加配料(糖、奶泡等)
  5. 装杯。

接下来,首先使用传统编码方式实现饮品的制作流程系统,然后分析其中的问题,最后应用模板方法模式进行重构,清晰的理解如何使用模板方法模式。

传统编码实现

为每种饮品都创建一个独立的类,每个类都包含完完整的制作流程。

AmericanCoffee
csharp 复制代码
public class AmericanCoffee {

    public void prepare() {

        this.boilWater();

        this.brewCoffeeGrounds();

        this.pourInCup();

        this.addSugarAndMilk();

        System.out.println("美式咖啡制作完成!\n");

    }

    private void boilWater() {
        System.out.println("将水煮沸至90-95摄氏度");
    }

    private void brewCoffeeGrounds() {
        System.out.println("用热水冲泡咖啡粉");
    }

    private void pourInCup() {
        System.out.println("将咖啡倒入杯中");
    }

    private void addSugarAndMilk() {
        System.out.println("根据顾客要求添加糖和牛奶");
    }
}
  • 该类代表美式咖啡的制作流程,perpate() 方法是主要的公共接口,其中按照顺序调用其他私有方法来完成制作流程
  • 私有方法 boilWater()brewCoffeeGrounds()pourInCup()addSugarAndMilk()分别代表制作的每个步骤。
LatteCoffee
csharp 复制代码
public class LatteCoffee {

    public void prepare() {

        this.boilWater();

        this.brewCoffeeGrounds();

        this.addSteamedMilk();

        this.addFoam();

        this.pourInCup();

        System.out.println("拿铁咖啡制作完成!\n");

    }

    private void boilWater() {
        System.out.println("将水煮沸至90-95摄氏度");
    }

    private void brewCoffeeGrounds() {
        System.out.println("用热水冲泡咖啡粉制作浓缩咖啡");
    }

    private void addSteamedMilk() {
        System.out.println("加入煮熟的牛奶");
    }

    private void addFoam() {
        System.out.println("在顶部加入奶泡");
    }

    private void pourInCup() {
        System.out.println("将混合物导入杯中");
    }
    
}
  • 该类代表拿铁咖啡的制作流程,与美式咖啡相比,步骤有所不同,增加了 addSteamedMilk()addFoam()
GreenTea
csharp 复制代码
public class GreenTea {

    public void prepare() {

        this.boilWater();

        this.steepTeaBag();

        this.pourInCup();

        this.addLemon();

        System.out.println("绿茶制作完成!\n");

    }

    private void boilWater() {
        System.out.println("将水煮沸至80摄氏度");
    }

    private void steepTeaBag() {
        System.out.println("浸泡绿茶茶包3-5分钟");
    }

    private void pourInCup() {
        System.out.println("将茶倒入杯中");
    }

    private void addLemon() {
        System.out.println("根据顾客要求添加柠檬");
    }

}
  • 该类代表绿茶的制作流程与咖啡相关类相比,绿茶的制作步骤有明显不同,例如使用steepTeaBag()而不是brewCoffeeGrounds(),即使是相似的步骤如 boilWater() 其具体实现页不同,及其添加的配料也不同。
测试类
csharp 复制代码
public class TemplateTest {

    @Test
    public void test_beverage() {

        // 制作美式咖啡
        AmericanCoffee americanCoffee = new AmericanCoffee();
        System.out.println("=== 制作美式咖啡 ===");
        americanCoffee.prepare();

        // 制作拿铁咖啡
        LatteCoffee latteCoffee = new LatteCoffee();
        System.out.println("=== 制作拿铁咖啡 ===");
        latteCoffee.prepare();

        // 制作绿茶
        GreenTea greenTea = new GreenTea();
        System.out.println("=== 制作绿茶 ===");
        greenTea.prepare();
    }
}
运行结果
diff 复制代码
=== 制作美式咖啡 ===
将水煮沸至90-95摄氏度
用热水冲泡咖啡粉
将咖啡倒入杯中
根据顾客要求添加糖和牛奶
美式咖啡制作完成!

=== 制作拿铁咖啡 ===
将水煮沸至90-95摄氏度
用热水冲泡咖啡粉制作浓缩咖啡
加入煮熟的牛奶
在顶部加入奶泡
将混合物导入杯中
拿铁咖啡制作完成!

=== 制作绿茶 ===
将水煮沸至80摄氏度
浸泡绿茶茶包3-5分钟
将茶倒入杯中
根据顾客要求添加柠檬
绿茶制作完成!


Process finished with exit code 0

传统编码方式的问题分析

1.代码重复

不难发现三个饮品类中有许多相似甚至完全相同的代码:

  • 所有类都有一个 prepare() 方法,用于协调整个制作流程。
  • 每个类都遵循相似的制作步骤顺序:准备原料、加热、冲泡/浸泡、倒入杯中、添加辅料
2.维护困难

当需要修改共同的步骤时,必须修改所有相关类,例如,如果需要在饮品的制作流程中添加 质量检查 步骤,则需要修改三个类的 prepare() 方法:

增加 qualityCheck() 方法
csharp 复制代码
private void qualityCheck() {
    System.out.println("进行质量检查");
}
修改 prepare() 方法
kotlin 复制代码
public void prepare() {

    this.boilWater();

    this.brewCoffeeGrounds();

    this.pourInCup();

    this.addSugarAndMilk();

    this.qualityCheck();

    System.out.println("美式咖啡制作完成!\n");

}
  • 当需要在所有饮品中添加一个新步骤时,必须修改每个类的 perpate() 方法。
3.扩展性差

当需要添加新的饮品种类时,必须创建一个全新的类,并重新实现所有步骤

模板方法模式

定义

模板方法模式定义了一个算法的骨架,将一些步骤的实现延迟到子类中,模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。 模板方法模式就像是一个食谱:它规定了制作一道菜的基本步骤和顺序,但允许厨师根据自己的喜好调整某些步骤的具体做法(如调料的用量,火候的控制等)。

结构

  1. 抽象类:定义了一个模板方法,该方法包含算法的骨架,声明算法各步骤的抽象方法,由子类实现,可以包含一些具体方法和钩子方法。
  2. 具体类:实现抽象类中的抽象方法,为算法的特定步骤提供具体实现。

关键特性

  1. 算法骨架:模板方法定义了算法的基本结构和步骤顺序,这部分通常被声明为 final,防止子类修改。
  2. 抽象步骤:算法中的某些步骤被声明为抽象方法,必须由子类实现,这些步骤通常是算法中变化的步骤。
  3. 具体步骤:算法中的某些步骤在抽象类中已有具体实现,所有子类共享这些实现。
  4. 钩子方法:这些是在抽象类中已有默认实现的方法,子类可以选择性的重写它们,钩子方法为子类提供了额外的扩展点,使得子类可以在算法的特定点插入自定义行为。

重构饮品制作系统

重构思路

  1. 创建一个抽象的 Beverage 类,定义 prepareBeverage() 模板方法,该方法包含制作饮品的通用步骤顺序。
  2. Beverage 类中声明一些抽象方法(如 brew()addCondiments()),这些方法代表不同饮品之间变化的步骤。
  3. Beverage 类中实现一些具体方法(如 boilWater()pourInCup()),这些方法代表所有饮品共享的步骤。
  4. 创建具体的饮品类(如 CoffeeTea),继承 Beverage 类并实现抽象方法。
  5. 可以添加钩子方法(如 customerWantsCondiments()),允许子类决定是否执行某些可选步骤。

重构实现

创建抽象饮品类
csharp 复制代码
public abstract class Beverage {

    public final void prepareBeverage() {

        this.boiWater();

        this.brew();

        this.pourInCup();

        if (this.customerWantsCondiments()) {
            addCondiments();
        }

        System.out.println(this.getBeverageName() + "制作完成!\n");

    }

    /**
     * 煮水 所有饮品通用步骤
     */
    protected void boiWater() {
        System.out.println("将水煮沸");
    }

    /**
     * 冲泡 子类实现
     */
    protected abstract void brew();

    /**
     * 倒入杯中 所有饮品通用步骤
     */
    protected void pourInCup() {
        System.out.println("将饮品倒入杯中");
    }

    /**
     * 添加调料
     */
    protected abstract void addCondiments();

    /**
     * 获取饮品名称
     */
    protected abstract String getBeverageName();

    /**
     * 钩子方法,决定是否添加调料
     * 默认返回 true,子类可以重写
     */
    protected boolean customerWantsCondiments() {
        return true;
    }
}
  • Beverage是一个抽象类,定义了饮品制作的基本流程
  • prepareBeverage() 是模板方法,声明为 final 防止子类重写,定义了制作饮品的算法骨架
  • boiWater()pourInCup() 是具体方法,提供了所有饮品共享的默认实现
  • brew()addCondiments()getBeverageName() 是抽象方法,必须由子类实现
  • customerWantsCondiments() 是钩子方法,提供了默认实现,子类可选择性的重写。
创建具体咖啡类
csharp 复制代码
public abstract class Coffee extends Beverage {

    /**
     * 实现 brew 方法,定义咖啡的冲泡过程
     */
    @Override
    protected void brew() {
        System.out.println("用热水冲泡咖啡粉");
    }

}
  • Coffee 继承自 Beverage,是所有咖啡类型的父类,实现了 brew() 方法,定义了咖啡的通用冲泡过程,但没有实现 Beverage 中的所有抽象方法。
创建具体茶类
csharp 复制代码
public abstract class Tea extends Beverage {

    /**
     * 实现brew方法,定义茶的冲泡过程
     */
    @Override
    protected void brew() {
        System.out.println("浸泡茶包");
    }

}
  • Tea 继承自 Beverage,是所有茶类型的父类,同样只实现了 brew() 方法,定义了茶的通用冲泡过程,但并未实现所有抽象方法。
创建具体饮品类
美式咖啡类
typescript 复制代码
public class AmericanCoffee extends Coffee {

    @Override
    protected void addCondiments() {
        System.out.println("添加糖和牛奶");
    }

    @Override
    protected String getBeverageName() {
        return "美式咖啡";
    }
}
  • AmericanCoffee 继承自 Coffee,是一个具体的咖啡类型,实现了 addCondiments() 方法,定义了美式咖啡特有的调料,实现了 getBeverageName() 方法 返回饮品名称。
拿铁咖啡类
typescript 复制代码
public class LatteCoffee extends Coffee {

    @Override
    protected void addCondiments() {
        System.out.println("添加蒸煮的牛奶和奶泡");
    }

    @Override
    protected String getBeverageName() {
        return "拿铁咖啡";
    }

    @Override
    protected void boiWater() {
        System.out.println("将水煮沸至85-90摄氏度");
    }
}
  • LatteCoffee 继承自 Coffee,实现了 addCondiments()getBeverageName() 方法,并且重写了 boilWater() 方法,该实例展示了模板方法模式的灵活性。
绿茶类
typescript 复制代码
public class GreenTea extends Tea {

    @Override
    protected void addCondiments() {
        System.out.println("添加柠檬");
    }

    @Override
    protected String getBeverageName() {
        return "绿茶";
    }

    @Override
    protected void boiWater() {
        System.out.println("将水煮沸至80摄氏度");
    }
}
  • GreenTea 继承自 Tea,是一个具体的茶类型,实现了必要抽象方法,并重写了 boilWater() 方法,适应绿茶的特殊需求。
无糖绿茶类
typescript 复制代码
public class SugarLessGreenTea extends GreenTea {

    @Override
    protected String getBeverageName() {
        return "无糖绿茶";
    }

    @Override
    protected boolean customerWantsCondiments() {
        return false;
    }
}
  • SugarLessGreenTea 继承自 GreenTea,是一个绿茶的变种,重写了 customerWantsCondiments() 钩子方法,返回 false 表示不需要添加调料,重写了 getBeverageName() 方法,展示了钩子方法的用户,允许子类控制算法中某些步骤的执行。
测试类
csharp 复制代码
public class TemplateTest {

    @Test
    public void test_beverage() {

        System.out.println("=== 制作美式咖啡 ===");
        Beverage americanCoffee = new AmericanCoffee();
        americanCoffee.prepareBeverage();

        System.out.println("=== 制作拿铁咖啡 ===");
        Beverage latteCoffee = new LatteCoffee();
        latteCoffee.prepareBeverage();

        System.out.println("=== 制作绿茶 ===");
        Beverage greenTea = new GreenTea();
        greenTea.prepareBeverage();

        System.out.println("=== 制作无糖绿茶 ===");
        Beverage sugarLessGreenTea = new SugarLessGreenTea();
        sugarLessGreenTea.prepareBeverage();

    }

}
运行结果
diff 复制代码
=== 制作美式咖啡 ===
将水煮沸
用热水冲泡咖啡粉
将饮品倒入杯中
添加糖和牛奶
美式咖啡制作完成!

=== 制作拿铁咖啡 ===
将水煮沸至85-90摄氏度
用热水冲泡咖啡粉
将饮品倒入杯中
添加蒸煮的牛奶和奶泡
拿铁咖啡制作完成!

=== 制作绿茶 ===
将水煮沸至80摄氏度
浸泡茶包
将饮品倒入杯中
添加柠檬
绿茶制作完成!

=== 制作无糖绿茶 ===
将水煮沸至80摄氏度
浸泡茶包
将饮品倒入杯中
无糖绿茶制作完成!


Process finished with exit code 0

重构后优势

  1. 重复代码减少 在传统实现中,相同或相似的方法(如 boilWater()pourInCup())在多个类中重复出现,重构后,这些共同的方法被提取到抽象类 Beverage 中,通过引入中间抽象类(如 CoffeeTea),进一步减少了代码重复。
  2. 提高可维护性 在传统实现中,修改共同步骤需要修改所有相关类,重构后,当需要修改算法结构或添加新步骤时,只需要修改抽象类中的模板方法,所有子类都会自动继承这些变化。
  3. 增强扩展性 在传统实现中,添加新饮品需要创建全新的类并复制大量代码,重构后,只需要创建一个新的子类并实现特定的抽象方法,添加新饮品变得非常简单,只需要实现几个特定方法,而不需要重新实现整个制作流程。
  4. 逻辑变得清晰 模板方法清晰地定义了饮品制作的整体流程和步骤顺序,使得代码更易于理解和维护,任何人查看时都能立即理解饮品制作的基本流程,而不需要查看多个类。

长话短说

核心思想

模板方法模式将算法分为两部分:

  • 不变的部分:算法的整体结构和步骤顺序,由抽象类中的模板方法定义。
  • 变化的部分:算法中特定步骤的具体实现,由子类通过重写抽象方法提供。 这种分离使得算法的结构保持稳定,同时允许不同的实现方式。

实施步骤

  1. 分析算法,识别结构和变化点 首先,分析目标算法,识别其中的固定结构和可能变化的部分(哪些步骤对所有实现都是相同的?哪些步骤在不同实现中有所不同?哪些步骤是可选的,可能在某些实现中被跳过?) 例如:在饮品制作流程中,固定步骤:煮水、倒入杯中,变化步骤:冲泡方式、添加的调料,可选步骤:添加调料(某些饮品可能不需要)。
  2. 创建抽象类,定义模板方法 创建一个抽象类,在其中定义模板方法,该方法应该被声明为 final,防止子类重写,包含算法的完整步骤序列,调用抽象算法、具体方法和钩子方法。
scss 复制代码
public abstract class AbstractClass{
 // 模板方法,定义算法结构
 public final void templateMethod(){
  //算法步骤序列
  step1();
  step2();
  if(hook()){
   step3();
  }
  step4();
 
 }
 
 // 其他方法....
}
  1. 声明抽象方法和钩子方法 在抽象类中声明必要的抽象方法和钩子方法: 抽象方法:必须由子类实现的方法,代表算法中变化的部分。 钩子方法:有默认实现但可被子类重写的方法,通常用于控制算法的可选部分。
csharp 复制代码
public abstract class AbstractClass{
 // 模板方法...
 
 // 抽象方法,必须由子类实现
 protected abstract void step2();
 
 // 具体方法,所有子类共享
 protected void step1(){
  // 默认实现
 }
 
 protected void step4(){
  // 默认实现
 }
 
 // 钩子方法,子类可选择性重写
 protected boolean hook(){
  return true;
 }
}
  1. 实现具体子类 创建具体子类,继承抽象类并实现所有抽象方法,根据需要,子类也可以重写钩子方法来控制算法的可选部分。
less 复制代码
public class ConcreteClass extends AbstractClass{
 @Override
 protected void step2(){
  // 具体实现
 }
 
 @Override
 protected boolean hook(){
  //重写钩子方法
  return false;
 }
}
  1. 考虑添加中间抽象类 如果有多个相似的子类,考虑添加中间抽象类来进一步减少代码重复,中间抽象类可以实现部分抽象方法,为特定的子类提供共同实现。

在实例中,我们添加了 CoffeeTea 中间抽象类,它们实现了 brew() 方法,为各自类别的饮品提供了共同实现。

注意事项

在使用模板方法时,应注意以下问题:

  1. 保持模板方法简洁明了 模板方法应该清晰的表达算法的整体结构,避免过于复杂的逻辑,如果模板方法变得复杂,应考虑将其分解为多个更小的方法。
合理机构
scss 复制代码
public final void templateMethod(){
 step1();
 step2();
 if(hook()){
  step3();
 }
 step4();
}
避免如下结构
scss 复制代码
public final void templateMethod(){

 step1();
 if(condition1()){
  step2a();
  if(condition2()){
   step2b();
  } else {
   step2c();
  }
  
 } else {
  step2d();
 }
 // 更多复杂的逻辑... 
}
  1. 合理使用钩子方法 钩子方法是模板方法模式的强大特性,但应该合理使用,只为真正需要子类控制的点提供钩子方法,为钩子方法提供合理的默认实现,清晰的命名钩子方法,表明其用途。
合理方法
typescript 复制代码
protected boolean shouldLogExecution(){
 return false;
}
避免模糊的命名
typescript 复制代码
protected boolean hook1(){
 return true;
}
  1. 避免过度抽象 不是所有的方法都需要抽象,只将真正需要子类定制的步骤声明为抽象方法,将共同的实现放在具体方法中。
csharp 复制代码
// 合理结构
protected void commonOperation(){
 //所有子类共享的实现
}

protected abstract void varyingOperation();

// 避免不必要的抽象
protected abstract void opertaion1();
protected abstract void opertaion2();
protected abstract void opertaion3();
protected abstract void opertaion4();
protected abstract void opertaion5();
  1. 考虑使用组合代替继承 虽然模板方法模式基于继承,但在某些情况下,使用组合可能更灵活,例如,可以将变化的步骤实现为策略对象,通过组合而非继承来定制算法。
csharp 复制代码
public class TemplateWithStrategy(){
 private Strategy strategy;
 
 public TemplateWithStrategy(Strategy strategy){
  this.strategy = strategy;
 }
 
 public final void templateMethod(){
  step1();
  // 使用策略对象替代抽象方法
  strategy.execute(); 
  step3();
 }
}
  1. 遵循单一职责原则 确保抽象类和具体类都遵循单一职责原则,抽象类应该只关注算法的结构,而具体类应该只关注特定步骤的实现。
相关推荐
paopaokaka_luck18 分钟前
智能推荐社交分享小程序(websocket即时通讯、协同过滤算法、时间衰减因子模型、热度得分算法)
数据库·vue.js·spring boot·后端·websocket·小程序
程序员NEO33 分钟前
Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!
人工智能·后端
用户214118326360234 分钟前
惊爆!国内轻松白嫖 Claude Code,编程效率狂飙
后端
iccb101339 分钟前
我是如何实现在线客服系统的极致稳定性与安全性的
前端·javascript·后端
M1A11 小时前
Java 面试系列第一弹:基础问题大盘点
java·后端·mysql
夕颜1111 小时前
关于 Cursor 小插曲记录
后端
考虑考虑1 小时前
go中的Map
后端·程序员·go
邓不利东2 小时前
Spring中过滤器和拦截器的区别及具体实现
java·后端·spring
头发那是一根不剩了2 小时前
Spring Boot 多数据源切换:AbstractRoutingDataSource
数据库·spring boot·后端