1.引入
本节将要深入封装算法块,好让子类可以在任何时候都可以将自己挂接进运算里。
1.1 场景引入
茶和咖啡的冲泡方式非常相似:
快速搞定几个咖啡和茶的类:
咖啡代码:
public class Coffee {
//准备工作
void prepareRecipe() {
boilWater();
brewCoffeeGrinds();
pourInCup();
addSugarAndMilk();
}
public void boilWater() {
System.out.println("Boiling water");
}
public void brewCoffeeGrinds(){
System.out.println("Dripping Coffee through filter");
}
public void pourInCup(){
System.out.println("Pouring into cup");
}
public void addSugarAndMilk(){
System.out.println("Adding Sugar and Milk");
}
}
接下来是茶类:
public class Tea {
//准备工作
void prepareRecipe() {
boilWater();
steepTeaBag();
pourInCup();
addLemon();
}
public void boilWater(){
}
public void steepTeaBag(){
}
public void pourInCup(){
}
public void addLemon(){
}
}
发现了重复的代码,这表示我们需要清理一下没计了。在这里,既然茶和咖啡是如此地相似,似乎我们应该将共同的部分抽取出来,放进一个基类中。
注意两份冲泡法都采用了相同的算法:
注意步骤2和步骤4的执行逻辑是一样的,只是应用在不同的饮料上。
第一版的设计:
1.2 抽象prepareRecipe()
对第一版继续进行改进,将prepareRecipe()方法进行抽象:
从每一个子类(茶和咖啡)中逐步抽象prepareRecipe().
1.遇到的第一个问题,是咖啡使用brewCoffeeGrinds()和addSugerAndMilk()方法,而茶使用steepTeaBag()和addLemon()方法。
让我们来思考这一点:浸泡(steep)和冲泡(brew)差异其实不大。所以我们给它一个新的方法名称,比方说brew(),然后不管是泡茶或冲泡咖啡我们都用这个名称。类似地,加糖和牛奶也和加柠檬很相似:都是在饮料中加入调料。让我们也给它一个新的方法名称来解决这个向题,就叫做addCondiments()好了。这样一来,新的prepareRecipe()方法看起来就像这样:
2.现在有了新的prepareRecipe()方法,但是需要让他能够符合代码。那就要先设计CaffeineBeverage(咖啡因饮料)超类开始:
public abstract class CaffeineBeverage {
final void prepareRecipe() {
boilWater();
brew();
pourInCup();
addCondiments();
}
abstract void brew();
abstract void addCondiments();
void boilWater() {
System.out.println("Boiling water");
}
void pourInCup() {
System.out.println("Pouring into cup");
}
}
对代码中的几个点进行分析:
- 用一个prepareRecipe()方法来处理茶和咖啡。prepareRecipe()被声明为final,因为不希望子类覆盖这个方法,同时将步骤2和步骤4泛化为brew()和addCondiments()。
- 因为咖啡和茶处理这些方法的做法不同,因此这两个方法必须被声明为抽象,剩余的东西留给子类去操心
3.最后,来处理咖啡和茶类。这两个类是依赖超类来处理冲泡法,因此只需要自行处理冲泡和添加调料部分:
public class Coffee extends CaffeineBeverage {
@Override
public void brew() {
System.out.println("Dripping Coffee through filter");
}
@Override
public void addCondiments() {
System.out.println("Adding Sugar and Milk");
}
}
public class Tea extends CaffeineBeverage {
@Override
public void brew() {
System.out.println("Steeping the tea");
}
@Override
public void addCondiments() {
System.out.println("Adding Lemon");
}
}
通过对咖啡和茶 这两类的制作步骤,就已经将模板方法体现出来。
1.3 认识模板方法
来看看咖啡因饮料类的结构,其中就包含了实际的“模板方法”。
prepareRecipe()是我们的模板方法,原因:
1.它是一个方法
2.用作一个算法的模板,在这个例子中,算法是用来制作咖啡因饮料的
在这个模板中,算法内的每一个步骤都被一个方法代表了。
某些方法是由这个类(超类)处理的。
模板方法定义了一个算法的步骤,并允许子类为一个或多个步骤提供实现。
1.4 追踪模板方法的工作方式
通过逐步地泡茶,追踪这个模板方法是如何工作的。将会得知在算法内的某些地方,该模板方法控制了算法。让子类能够提供某些步骤的实现:
模板方法有利于,将代码的复用最大化。
2.模板方法
2.1 定义模板方法模式
模板方法模式:在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
这个模式是用来创建一个算法的模板。什么是模板?如你所见的,模板就是一个方法。更具体地说,这个方法将算法定义成一组步骤,其中的任何步骤都可以是抽象的,由子类负责实现。这可以确保算法的结构保持不变,同时由子类提供部分实现。
2.2 模板方法类图与相关代码
抽象类被声明成 抽象的,用来作为基类,其子类必须实现其操作。
模板方法templateMethod(),被声明为final,以免子类改变这个算法的顺序。
模板方法定义了一连串的步骤,每个步骤由一个方法代表。
在这个范例中,有两个原语操作,具体子类必须实现它们。
2.3 钩子方法的定义与使用
钩子是一种被声明在抽象类中的方法,但只有空的或默认的实现。钩子的存在,可以让子类有能力对算法的不同点进行挂钩。要不要挂钩,由子类自行决定。
有了钩子,开发者就能够觉得要不要覆盖方法。如果子类不提供自己的方法,抽象类也会提供一个默认的实现。
public abstract class CaffeineBeverageWithHook {
final void prepareRecipe() {
boilWater();
brew();
pourInCup();
//加上一个小小的条件语句,而该条件是否成立,是由一个具体方法customerWantsCondiments()决定的
//如果客户“想要”调料,只有这时我们才调用addCondiments()
if (customerWantsCondiments()) {
addCondiments();
}
}
abstract void brew();
abstract void addCondiments();
void boilWater() {
System.out.println("Boiling water");
}
void pourInCup() {
System.out.println("Pouring into cup");
}
//定义了一个方法,(通常)是空的缺省实现,这个方法只会返回true,不做别的事
//这就是一个钩子,子类可以覆盖这个方法,但是不是一定要这么做
boolean customerWantsCondiments() {
return true;
}
}
使用钩子:
为了使用钩子,在子类中覆盖它。此处,钩子控制了咖啡因饮料是否执行某部分算法,即饮料中是否需要加进饮料。
在子类中覆盖这个钩子,来获取顾客是否想要调料。
public class CoffeeWithHook extends CaffeineBeverageWithHook {
@Override
public void brew() {
System.out.println("Dripping Coffee through filter");
}
@Override
public void addCondiments() {
System.out.println("Adding Sugar and Milk");
}
@Override
public boolean customerWantsCondiments() {
String answer = getUserInput();
if (answer.toLowerCase().startsWith("y")) {
return true;
} else {
return false;
}
}
private String getUserInput() {
String answer = null;
System.out.print("Would you like milk and sugar with your coffee (y/n)? ");
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
try {
answer = in.readLine();
} catch (IOException ioe) {
System.err.println("IO error trying to read your answer");
}
if (answer == null) {
return "no";
}
return answer;
}
}
执行测试代码:
来制造热茶和热咖啡:
public class BeverageTestDrive {
public static void main(String[] args) {
Tea tea = new Tea();
Coffee coffee = new Coffee();
System.out.println("\nMaking tea...");
tea.prepareRecipe();
System.out.println("\nMaking coffee...");
coffee.prepareRecipe();
TeaWithHook teaHook = new TeaWithHook();
CoffeeWithHook coffeeHook = new CoffeeWithHook();
System.out.println("\nMaking tea...");
teaHook.prepareRecipe();
System.out.println("\nMaking coffee...");
coffeeHook.prepareRecipe();
}
}
测试结果:
在这个例子中,钩子能够作为条件控制,影响抽象类中的算法流程。
问:当我创建一个模板方法时,怎么才能知道什么时候该使用抽象方法,什么时候使用钩子呢?
答:当你的子类“必须”提供算法中某个方法或步骤的实现时,就使用抽象方法。如果算法的这个部分是可选的,就用钩子。如果是钩子的话,子类可以选择实现这个钩子,但并不强制这么做。
问:使用钩子真正的目的是什么?
答:钩子有几种用法:
1.钩子可以让子类实现算法中可选的部分,或者在钩子对子类的实现并不重要的时候,子类可以对此钩子置之不理。
2.让子类能够有机会对模板方法汇总某些即将发生的(或刚刚发生的)步骤做出反应。
2.4 好莱坞原则
好莱坞原则:别调用我们,我们会调用你。
好莱坞原则可以给我们一种防止“依赖腐败”的方法。当高层组件依赖低层组件,而低层组件又依赖高层组件,而高层组件又依赖边侧组件,而边侧组件又依赖低层组件时,依赖腐败就发生了。在这种情况下,没有人可以轻易地搞懂系统是如何设计的。
在好莱坞原则之下,我们允许低层组件将自己挂钩到系统上,但是高层组件会决定什么时候和怎样使用这些低层组件。换句话说,高层组件对待低层组件的方式是“别调用我们,我们会调用你”。
2.5 好莱坞原则和模板方法
好菜坞原则和模板方法之间的连接其实还算明显:当我们设计模板方法模式时,我们告诉子类,“不要调用我们,我们会调用你”。怎样才能办到呢?让我们再看一次咖啡因饮料的设计:
当需要底层组件Tea或者Coffee时,高层组件CaffeineBeverage才会去调用Coffee或者Tea这俩子类的具体方法。
问:好莱坞原则和依赖倒置原则(第四章)之间的关系如何?
答:依赖倒置原则教我们尽量避免使用具体类,而多使用抽象。而好莱坞原则是用在创建框架或组件上的一种技巧,好让低层组件能够被挂钩进计算中,而且又不会让高层组件依赖低层组件。两者的目标都在于解耦,但是依赖倒置原则更注重如何在设计中避免依赖。
好莱坞原则教我们一个技巧,创建有弹性的设计,允许低层结构能够互相操作,而又放置其他类跳过依赖他们。
3.模板方法模式练习
这个模式很常见是因为对创建框架来说,这个模式简直棒极了。由框架控制如何做事情,而由你(使用这个框架的人)指定框架算法中每个步骤的细节。
3.1 用模板方法排序
需要对数组进行排序,Java数组类的设计者就提供了一个方便的模板方法用来排序。
下面的sort()方法,是Arrays.sort()方法
3.2 对鸭子进行排序
假如我们有一个鸭子的数组需要排序,你要怎么做?数组的排序模板方法已经提供了算法,但是你必须让这个模板方法知道如何比较鸭子。你所要做的事情就是,实现一个compareTo()方法。
sort()的设计者希望这个方法能使用于所有的数组,所以他们把sort()变成是静态的方法,这样一来,任何数组都可以使用这个方法。但是没关系,它使用起来和它被定义在超类中是一样的。现在,还有一个细节要告诉你:因为sort()并不是真正定义在超类中,所以sort()方法需要知道你已经实现了这个compartTo()方法,否则就无法进行排序。
要达到这一点,设计者利用了Comparable接口。你须实现这个接口,提供这个接口所声明的方法,也就是compareTo()。
这个compareTo()方法将比较两个对象,然后返回其中一个是大于、等于还是小于另一个。sort()只要能够知道两个对象的大小,当然就可以进行排序。
比较鸭子:
如果要排序鸭子,就必须实现compareTo()方法,然后,数组就可以被正常排序了。
Duck类为:
public class Duck implements Comparable<Duck> {
String name;
int weight;
public Duck(String name, int weight) {
this.name = name;
this.weight = weight;
}
@Override
public String toString() {
return name + " weighs " + weight;
}
@Override
public int compareTo(Duck otherDuck) {
if (this.weight < otherDuck.weight) {
return -1;
} else if (this.weight == otherDuck.weight) {
return 0;
} else { // this.weight > otherDuck.weight
return 1;
}
}
}
测试一些鸭子:
public class DuckSortTestDrive {
public static void main(String[] args) {
Duck[] ducks = {
new Duck("Daffy", 8),
new Duck("Dewey", 2),
new Duck("Howard", 7),
new Duck("Louie", 2),
new Duck("Donald", 10),
new Duck("Huey", 2)
};
System.out.println("Before sorting:");
display(ducks);
Arrays.sort(ducks);
System.out.println("\nAfter sorting:");
display(ducks);
}
public static void display(Duck[] ducks) {
for (Duck d : ducks) {
System.out.println(d);
}
}
}
测试输出:
3.3 总结
问:这真的是一个模板方法模式吗?还是你的想象力太丰富了
答:这个模式的重点在于提供一个算法,并让子类实现某些步骤而数组的排序做法很明显地并非如此!但是,我们都知道,荒野中的模式并非总是如同教科书例子一般地中规中矩、为了符合当前的环境和实现的约束,它们总是要被适当地修改。这个Array类sort()方法的设计者受到一些约束。通常我们无法设计一个类继承Java数组,而sort()方法希望能够适用于所有的数组(每个数组都是不同的类)。所以它们定义了一个静态方法,而由被排序的对象内的每个元素自行提供比较大小的算法部分。所以、这虽然不是教科书上的模板方法,但它的实现仍然符合模板方法模式的精神。再者,由于不需要继承数组就可以使用这个算法,这样使得排序变得更有弹性、更有用。
问:在Java API中,还有其他模板方法的例子吗?
答:是的,你可以在一些地方看到它们。比方说,java.io的InputStream类有一个read()方法,是由子类实现的,而这个方法又会被read(byte b[], int off,int len)模板方法使用。
4.总结
- “模板方法”定义了算法的步骤,把这些步骤的实现延迟到子类。
- 模板方法模式为我们提供了一种代码复用的重要技巧。
- 模板方法的抽象类可以定义具体方法、抽象方法和钩子。
- 抽象方法由子类实现。钩子是一种方法,它在抽象类中不做事,或者只做默认的事情,子类可以选择要不要去覆盖它。
- 为了防止子类改变模板方法中的算法,可以将模板方法声明为final。
- 好莱坞原则告诉我们,将决策权放在高层模块中,以便决定如何以及何时调用低层模块。
- 你将在真实世界代码中看到模板方法模式的许多变体,不要期待它们全都是一眼就可以被你认出的。
- 策略模式和模板方法模式都封装算法,一个用组合,一个用继承。
- 工厂方法是模板方法的一种特殊版本。