1. 模版方法模式的定义和组成
模版方法模式是一种只需要使用继承就可以实现的非常简单的模式
模版方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法
假如我们有一些平行的子类,各个子类之间有一些相同的行为,也有一些不同的行为。如果相同和不同的行为都混合在各个子类的实现中,说明这些相同的行为会在各个子类中重复出现。但实际上,相同的行为可以被搬移到另一个单一的地方,模版方法模式就是为解决这个问题而生的。在模版方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留给子类来实现。很好地体现了泛化的思想
2. 例子--Coffee or Tea
咖啡与茶是一个经典的例子,经常用来讲解模版方法模式。
2.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();
2.2 泡一壶茶
步骤如下:
- 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();
2.3 分离出共同点
- 原料不同。一个是咖啡,一个是茶,把它们都抽象为“饮料”
- 泡的方式不同。 咖啡是冲泡,茶叶是浸泡,把它们都抽象为“泡”
- 加入的调料不同。一个是糖和牛奶,一个是柠檬,把它们都抽象为“调料”
整理为下面四步
- 1.把水煮沸
- 2.用沸水冲泡饮料
- 3.把饮料倒进杯子
- 4.加调料
所以,不管是冲泡还是浸泡,都能给它一个新的方法名称,比如说brew(). 同理,不管是加糖和牛奶,还是加柠檬,都可以称之为addCondiments()
创建一个抽象父类来表示泡一杯饮料的整个过程。不论是Coffee, 还是 Tea, 都用Beverage来表示
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()
}
2.4 创建Coffee子类和Tea子类
饮料在这里只是一个抽象的存在,创建一个 Beverage 类的对象没有意义。
创建咖啡类和茶类,并继承饮料类
var Coffee = function(){};
Coffee.prototype = new Beverage();
// 重写抽象父类中的一些方法,只有“把水煮沸”这个行为可以直接使用父类Beverage中的boilWater方法,其他方法都需要在Coffee子类中重写
Coffee.prototype.brew = function(){
console.log('用沸水冲泡咖啡')
}
Coffee.prototype.pourInCup = function(){
console.log('把咖啡倒进杯子')
}
Coffee.prototype.addCondiments = function(){
console.log('加糖和牛奶')
}
var coffee = new Coffee()
coffee.init();
当调用cofeee对象的init方法时,由于coffee对象和Coffee构造器的原型prototype上都没有对应的init方法,所以该请求会顺着原型链,被委托给Coffee的“父类”Beverage原型上的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 方法中,算法内的每一步骤都清楚地展示在我们眼前
3. 抽象类
JavaScript并没有从语法层面提供对抽象类的支持。抽象类的第一个作用时隐藏对象的具体类型,由于JavaScript是一门“类型模糊”的语言,所以隐藏对象的类型在JavaScript中并不重要
另一方面,当我们在JavaScript中使用原型链继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没有办法保证子类会重写父类的“抽象方法”。
我们知道,Beverage.prototype.init方法作为模版方法,已经规定了子类的算法框架,代码如下:
Beverage.prototype.init = function(){
this.boilWater();
this.brew();
this.pourInCup();
this.addCondiments()
}
如果我们的Coffee类或者Tea类忘记实现这4个方法中的一个呢? 拿brew方法举例,如果我们忘记编写Coffee.prototype.brew方法,那么当请求coffee对象的brew时,请求会顺着原型链找到 Beverage "父类"对应的Beverage.prototype.brew方法,而 Beverage.prototype.brew 方法目前为止是一个空方法,这显然是不能符合我们需要的。
在Java中编译器会保证子类会重写父类中的抽象方法,但在JavaScript中却没有进行这些检查工作。在编写代码的时候得不到任何形式的警告,完全寄托于程序员的记忆力和自觉性是很危险的,特别是当我们使用模版方法模式这种完全依赖继承来实现的设计模式时。
两种变通的解决方案
-
- 用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求我们在业务代码中添加一些跟业务逻辑无关的代码
-
- 让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方法')
}
第2种方案的优点是实现简单,付出的额外代价很少;缺点是我们得到错误信息的时间点太靠后
4.钩子方法
通过模版方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适用于大多数子类的,但如果有一些特别的“个性”的子类呢?比如我们在饮料类Beverage中封装了饮料的冲泡顺序:
- 1.把水煮沸
- 2.用沸水冲泡饮料
- 3.把饮料倒进杯子
- 4.加调料
如果有一些客人喝咖啡是不加调料(糖和牛奶)的,既然Beverage作为父类,已经规定好了冲泡饮料的4个步骤,那么有什么办法可以让子类不受这个约束呢?
钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模版方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能
在这个例子里,我们把钩子的名字定为 customerWantsCondiments,接下来将挂钩放入Beverage类,看看我们如何得到一杯不需要糖和牛奶的咖啡
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();
5.好莱坞原则
引入一个新的设计原则--著名的“好莱坞原则”
许多新人演员在好莱坞把简历递给演艺公司之后就只有回家等待电话。有时候该演员等得不耐烦了,给演艺公司打电话询问情况,演艺公司往往这样回答:“不要来找我,我会给你打电话”
在设计中,这样的规则就称为好莱坞原则。在这一原则的指导下,我们允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候、以何种方式去使用这些底层组件,高层组件对待底层组件的方式,跟演艺公司对待新人演员一样,都是“别调用我们,我们会调用你”。
模版方法模式是好莱坞原则的一个典型使用场景,它与好莱坞原则的联系非常明显,当我们用模版方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节
此外,好莱坞原则还常常应用于其他模式和场景,例如发布-订阅模式和回调函数
6. 真的需要”继承“吗
模版方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序,子类继承父类后,父类通知子类执行这些方法,好莱坞原则很好的诠释了这种设计技巧,即高层组件调用底层组件
模版方法模式是为数不多的基于继承的设计模式,但JavaScript实际上没有提供真正的类式继承,继承是通过对象与对象之间的委托来实现的。也就是说,在形式上借鉴了提供类式继承的语言,但这里的模版方式模式并不十分正宗。而且在JavaScript这种灵活的语言中,实现这样一个例子,是否真的需要继承这种重武器呢?
在好莱坞原则的指导下,下面这段代码可以达到和继承一样的效果
var Beverage = function(param){
var boilWater = function(){
console.log('把水煮沸')
}
var brew = param.brew || function(){
throw new Error('必须传递brew方法')
}
var pourInCup = param.pourInCup || function(){
throw new Error('必须传递pourInCup方法')
}
var addCondiments = param.addCondiments || function(){
throw new Error('必须传递addCondiments方法')
}
var F = function(){}
F.prototype.init = funciton(){
boilWater();
brew();
pourInCup();
addCondiments();
}
return F;
}
var Coffee = Beverage({
brew: function(){
console.log('用沸水冲泡咖啡')
},
pourInCup: function(){
console.log('把咖啡倒进杯子')
},
addCondiments: function(){
console.log('加糖和牛奶')
},
})
var Tea = Beverage({
brew: function(){
console.log('用沸水浸泡茶叶')
},
pourInCup: function(){
console.log('把茶叶倒进杯子')
},
addCondiments: function(){
console.log('加柠檬')
},
})
var coffee = new Coffee();
coffee.init();
var tea = new Tea();
tea.init();
在这段代码中,把brew、pourInCup、addCondiments这些方法依次传入Beverage函数,Beverage函数被调用之后返回构造器F.F类中包含了“模版方法”F.prototype.init.跟继承得到的效果一样,该“模版方法”里依然封装了饮料子类的算法框架
7.小结
模版方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模版方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分的逻辑抽象到父类的模版方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分变化的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合将开放-封闭原则的
在JavaScript中,很多时候都不需要去实现一个模版方法模式,高阶函数是更好的选择
注:本文引用“JavaScript设计模式与开发实战”一书