模版方法模式

130 阅读6分钟

模板方法模式是一种只需使用继承就可以实现的非常简单的模式。

模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

1. 咖啡与茶是一个经典的例子

/*
先泡一杯咖啡:
(1) 把水煮沸
(2) 用沸水冲泡咖啡
(3) 把咖啡倒进杯子
(4) 加糖和牛奶
*/
var Coffee = function () {}
Coffee.prototype.boilWater = function () {
  console.log('把水煮沸')
}
Coffee.prototype.brewCoffeeGriends = function () {
  console.log('用沸水冲泡咖啡')
}
Coffee.prototype.pourInCup = function () {
  console.log('把咖啡倒进杯子')
}
Coffee.prototype.addSugarAndMilk = function () {
  console.log('加糖和牛奶')
}
Coffee.prototype.init = function () {
  this.boilWater()
  this.brewCoffeeGriends()
  this.pourInCup()
  this.addSugarAndMilk()
}
var coffee = new Coffee()
coffee.init()

/*
泡一壶茶:
(1) 把水煮沸
(2) 用沸水浸泡茶叶
(3) 把茶水倒进杯子
(4) 加柠檬
*/
var Tea = function () {}
Tea.prototype.boilWater = function () {
  console.log('把水煮沸')
}
Tea.prototype.steepTeaBag = function () {
  console.log('用沸水浸泡茶叶')
}
Tea.prototype.pourInCup = function () {
  console.log('把茶水倒进杯子')
}
Tea.prototype.addLemon = function () {
  console.log('加柠檬')
}
Tea.prototype.init = function () {
  this.boilWater()
  this.steepTeaBag()
  this.pourInCup()
  this.addLemon()
}
var tea = new Tea()
tea.init()

分离共同点

/*
(1) 把水煮沸
(2) 用沸水冲泡饮料
(3) 把饮料倒进杯子
(4) 加调料
*/

var Beverage = function () {}
Beverage.prototype.boilWater = function () {
  console.log('把水煮沸')
}
Beverage.prototype.brew = function () {} // 空方法,应该由子类重写
Beverage.prototype.pourInCup = function () {} // 空方法,应该由子类重写
Beverage.prototype.addCondiments = function () {} // 空方法,应该由子类重写
Beverage.prototype.init = function () {
  this.boilWater()
  this.brew()
  this.pourInCup()
  this.addCondiments()
}

//创建 Coffee 子类
var Coffee = function () {}
Coffee.prototype = new Beverage()
Coffee.prototype.brew = function () {
  console.log('用沸水冲泡咖啡')
}
Coffee.prototype.pourInCup = function () {
  console.log('把咖啡倒进杯子')
}
Coffee.prototype.addCondiments = function () {
  console.log('加糖和牛奶')
}
var Coffee = new Coffee()
Coffee.init()

// 创建 Tea 子类
var Tea = function () {}
Tea.prototype = new Beverage()
Tea.prototype.brew = function () {
  console.log('用沸水浸泡茶叶')
}
Tea.prototype.pourInCup = function () {
  console.log('把茶倒进杯子')
}
Tea.prototype.addCondiments = function () {
  console.log('加柠檬')
}
var tea = new Tea()
tea.init()

  • 上面的例子中,到底谁才是所谓的模板方法呢?答案是 Beverage.prototype.init。Beverage.prototype.init 被称为模板方法的原因是,该方法中封装了子类的算法框架,它作为一个算法的模板,指导子类以何种顺序去执行哪些方法。在 Beverage.prototype.init 方法中,算法内的每一个步骤都清楚地展示在我们眼前。

2. 抽象类

2.1 抽象类的作用

模板方法模式是一种严重依赖抽象类的设计模式。JavaScript 在语言层面并没有提供对抽象类的支持,我们也很难模拟抽象类的实现

在 Java 中,类分为两种,一种为具体类,另一种为抽象类。具体类可以被实例化,抽象类不能被实例化。我们可以把上面例子中的Beverage当作一个抽象类。抽象类的主要作用就是为它的子类定义这些公共接口。

2.2 抽象方法和具体方法

抽象方法被声明在抽象类中,抽象方法并没有具体的实现过程,是一些“哑”方法。比如Beverage 类中的 brew 方法、pourInCup 方法和 addCondiments 方法,都被声明为抽象方法。当子类继承了这个抽象类时,必须重写父类的抽象方法。

2.3 JavaScript 没有抽象类的缺点和解决方案
  • JavaScript 并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于 JavaScript 是一门“类型模糊”的语言,所以隐藏对象的类型在 JavaScript 中并不重要。
  • 另一方面,当我们在 JavaScript 中使用原型继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没有办法保证子类会重写父类中的“抽象方法”。

思考:我们的 Coffee 类或者 Tea 类忘记实现这 4 个方法中的一个呢?拿 brew 方法举例,如果我们忘记编写 Coffee.prototype.brew 方法,那么当请求 coffee 对象的 brew 时,请求会顺着原型链找到 Beverage“父类”对应的 Beverage.prototype.brew 方法,而 Beverage.prototype.brew 方法到目前为止是一个空方法,这显然是不能符合我们需要的。下面提供两种变通的解决方案:

  • 第 1 种方案是用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求我们在业务代码中添加一些跟业务逻辑无关的代码。

  • 第 2 种方案是让 Beverage.prototype.brew 等方法直接抛出一个异常,如果因为粗心忘记编写 Coffee.prototype.brew 方法,那么至少我们会在程序运行时得到一个错误:(优点是实现简单,付出的额外代价很少;缺点是我们得到错误信息的时间点太靠后。)

 Beverage.prototype.brew = function () {
   throw new Error('子类必须重写 brew 方法')
 }
 Beverage.prototype.pourInCup = function () {
   throw new Error('子类必须重写 pourInCup 方法')
 }
 Beverage.prototype.addCondiments = function () {
   throw new Error('子类必须重写 addCondiments 方法')
 }
 

3. 钩子方法

通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适用于大多数子类的,但如果有一些特别“个性”的子类呢?比如我们在饮料类 Beverage 中封装了饮料的冲泡顺序:

(1) 把水煮沸 (2) 用沸水冲泡饮料 (3) 把饮料倒进杯子 (4) 加调料

这 4 个冲泡饮料的步骤适用于咖啡和茶,在我们的饮料店里,根据这 4 个步骤制作出来的咖啡和茶,一直顺利地提供给绝大部分客人享用。但有一些客人喝咖啡是不加调料(糖和牛奶)的。既然 Beverage 作为父类,已经规定好了冲泡饮料的 4 个步骤,那么有什么办法可以让子类不受这个约束呢?

钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能。

var Beverage = function () {}
Beverage.prototype.boilWater = function () {
  console.log('把水煮沸')
}
Beverage.prototype.brew = function () {
  throw new Error('子类必须重写 brew 方法')
}
Beverage.prototype.pourInCup = function () {
  throw new Error('子类必须重写 pourInCup 方法')
}
Beverage.prototype.addCondiments = function () {
  throw new Error('子类必须重写 addCondiments 方法')
}
Beverage.prototype.customerWantsCondiments = function () {
  return true // 默认需要调料
}
Beverage.prototype.init = function () {
  this.boilWater()
  this.brew()
  this.pourInCup()
  if (this.customerWantsCondiments()) {
    // 如果挂钩返回 true,则需要调料
    this.addCondiments()
  }
}
var CoffeeWithHook = function () {}
CoffeeWithHook.prototype = new Beverage()
CoffeeWithHook.prototype.brew = function () {
  console.log('用沸水冲泡咖啡')
}
CoffeeWithHook.prototype.pourInCup = function () {
  console.log('把咖啡倒进杯子')
}
CoffeeWithHook.prototype.addCondiments = function () {
  console.log('加糖和牛奶')
}
CoffeeWithHook.prototype.customerWantsCondiments = function () {
  return window.confirm('请问需要调料吗?')
}
var coffeeWithHook = new CoffeeWithHook()
coffeeWithHook.init()