Java实现二十三种设计模式(六)—— 十一种行为型模式 (下)——观察者模式、状态模式、策略模式、模板方法模式、访问者模式
一、观察者模式
观察者模式非常常见,近年来逐渐流行的响应式编程就是观察者模式的应用之一。观察者模式的思想就是一个对象发生一个事件后,逐一通知监听着这个对象的监听者,监听者可以对这个事件马上做出响应。生活中有很多观察者模式的例子,比如我们平时的开关灯。当我们打开灯的开关时,灯马上亮了;当我们关闭灯的开关时,灯马上熄了。这个过程中,灯就对我们控制开关的事件做出了响应,这就是一个最简单的一对一观察者模式。当力扣公众号发表一篇文章,所有关注了公众号的读者立即收到了文章,这个过程中所有关注了公众号的微信客户端就对公众号发表文章的事件做出了响应,这就是一个典型的一对多观察者模式。再举个例子,比如警察一直观察着张三的一举一动,只要张三有什么违法行为,警察马上行动,抓捕张三。这个过程中:
- 警察称之为观察者(Observer)
- 张三称之为被观察者(Observable,可观察的)
- 警察观察张三的这个行为称之为订阅(subscribe),或者注册(register)
- 张三违法后,警察抓捕张三的行动称之为响应(update)
众所周知,张三坏事做尽,是一个老法外狂徒了,所以不止一个警察会盯着张三,也就是说一个被观察者可以有多个观察者。当被观察者有事件发生时,所有观察者都能收到通知并响应。观察者模式主要处理的是一种一对多的依赖关系。它的定义如下:
观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
我们使用程序来模拟一下这个过程。
观察者的接口:
接口中只有一个 update 方法,用于对被观察者发出的事件做出响应。
被观察者的父类:
被观察者中维护了一个观察者列表,提供了三个方法:
- addObserver:将 observer 对象添加到观察者列表中
- removeObserver:将 observer 对象从观察者列表中移除
- notifyObservers:通知所有观察者有事件发生,具体实现是调用所有观察者的 update 方法
有了这两个基类,我们就可以定义出具体的罪犯与警察类。
警察属于观察者:
警察实现了观察者接口,当警察收到事件后,做出响应,这里的响应就是简单的打印了一条日志。
罪犯属于被观察者:
罪犯继承自被观察者类,当罪犯有犯罪行为时,所有的观察者都会收到通知。
客户端测试:
在客户端中,我们 new 了一个张三,为其添加了三个观察者:police1,police2,police3。
运行程序,输出如下:
可以看到,所有的观察者都被通知到了。当某个观察者不需要继续观察时,调用 removeObserver 即可。
这就是观察者模式,它并不复杂,由于生活中一对多的关系非常常见,所以观察者模式应用广泛。
Java 源码中的观察者模式
实际上,Java 已经为我们提供了的 Observable 类和 Observer 类,我们在用到观察者模式时,无需自己创建这两个基类,我们来看一下 Java 中提供的源码:
java.util.Observer 类:
public interface Observer {
void update(Observable o, Object arg);
}
Observer 类和我们上例中的定义基本一致,都是只有一个 update 方法用于响应 Observable 的事件。区别有两点:
- update 方法将 Observable 对象也提供给了 Observer
- update 方法中的参数类型变成了 Object
这两点区别都是为了保证此 Observer 的适用范围更广。
java.util.Observable 类:
public class Observable {
private boolean changed = false;
private Vector<Observer> obs;
public Observable() {
obs = new Vector<>();
}
public synchronized void addObserver(java.util.Observer o) {
if (o == null)
throw new NullPointerException();
if (!obs.contains(o)) {
obs.addElement(o);
}
}
public synchronized void deleteObserver(java.util.Observer o) {
obs.removeElement(o);
}
public void notifyObservers() {
notifyObservers(null);
}
public void notifyObservers(Object arg) {
Object[] arrLocal;
synchronized (this) {
if (!hasChanged())
return;
arrLocal = obs.toArray();
clearChanged();
}
for (int i = arrLocal.length - 1; i >= 0; i--)
((Observer) arrLocal[i]).update(this, arg);
}
public synchronized void deleteObservers() {
obs.removeAllElements();
}
protected synchronized void setChanged() {
changed = true;
}
protected synchronized void clearChanged() {
changed = false;
}
public synchronized boolean hasChanged() {
return changed;
}
public synchronized int countObservers() {
return obs.size();
}
}
Observable 类和我们上例中的定义也是类似的,区别在于:
- 用于保存观察者列表的容器不是 ArrayList,而是 Vector
- 添加了一个 changed 字段,以及 setChanged 和 clearChanged 方法。分析可知,当 changed 字段为
true 时,才会通知所有观察者,否则不通知观察者。所以当我们使用此类时,想要触发 notifyObservers 方法,必须先调用
setChanged 方法。这个字段相当于在被观察者和观察者之间添加了一个可控制的阀门。 - 提供了 countObservers 方法,用于计算观察者数量
- 添加了一些 synchronized 关键字保证线程安全
这些区别仍然是为了让 Observable 的适用范围更广,核心思想与本文介绍的都是一致的。
二、状态模式
状态模式(State Pattern):当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。
通俗地说,状态模式就是一个关于多态的设计模式。
如果一个对象有多种状态,并且每种状态下的行为不同,一般的做法是在这个对象的各个行为中添加 if-else 或者 switch-case 语句。但更好的做法是为每种状态创建一个状态对象,使用状态对象替换掉这些条件判断语句,使得状态控制更加灵活,扩展性也更好。
举个例子,某网站的用户有两种状态:普通用户和PLUS会员。PLUS会员有非常多的专享功能,随便举个例子,假设是投票功能。
- 当普通用户点击投票功能时,提示用户:投票是 Plus 会员专享功能;
- 当 PLUS 会员点击投票功能时,开始投票。
先来看看不使用状态模式的写法,看出它的缺点后,我们再用状态模式来重构代码。
1、普通写法
首先定义一个用户状态枚举类:
NORMAL 代表普通用户状态,PLUS 代表 PLUS 会员状态。
用户的功能接口:
本例中我们只定义了一个模拟投票的方法,实际开发中这里可能会有许许多多的方法。
用户状态切换接口:
此接口中定义了两个方法:purchasePlus 方法表示购买 Plus 会员,用户状态变为 PLUS 会员状态,expire 方法表示会员过期,用户状态变为普通用户状态。
xx用户类:
用户类实现了 IUser 接口,IUser 接口中的每个功能都需要判断用户是否为 Plus 会员,也就是说每个方法中都有 if (state == State.PLUS) {} else {} 语句,如果状态不止两种,还需要用上 switch-case 语句来判断状态,这就是不使用状态模式的弊端:
- 判断用户状态会产生大量的分支判断语句,导致代码冗长;
- 当状态有增加或减少时,需要改动多个地方,违反开闭原则。
2、状态模式
在《代码整洁之道》、《重构》两本书中都提到:应使用多态取代条件表达式。接下来我们就利用多态特性重构这份代码。为每个状态新建一个状态类,普通用户:
PLUS 会员:
每个状态类都实现了 IUser 接口,在接口方法中实现自己特定的行为。
用户类:
可以看到,丑陋的状态判断语句消失了,无论 IUser 接口中有多少方法,User 类都只需要调用状态类的对应方法即可。
客户端测试:
可以看到,用户状态改变后,行为也随着改变了,这就是状态模式定义的由来,它的优点是:将与特定状态相关的行为封装到一个状态对象中,使用多态代替 if-else 或者 switch-case 状态判断。缺点是必然导致类增加,这也是使用多态不可避免的缺点。
三、策略模式
1、策略模式
策略模式用一个成语就可以概括 —— 殊途同归。当我们做同一件事有多种方法时,就可以将每种方法封装起来,在不同的场景选择不同的策略,调用不同的方法。
策略模式(Strategy Pattern):定义了一系列算法,并将每一个算法封装起来,而且使它们还可以相互替换。策略模式让算法独立于使用它的客户而独立变化。
我们以排序算法为例。排序算法有许多种,如冒泡排序、选择排序、插入排序,算法不同但目的相同,我们可以将其定义为不同的策略,让用户自由选择采用哪种策略完成排序。
首先定义排序算法接口(这里为了省事,就用加法乘法减法简单代替):
接口中只有一个 运算 方法,传入一个整型数组进行运算,所有的算法都实现此接口。
累加法:
累减法:
累乘积:
接下来我们需要创建一个环境类,将每种算法都作为一种策略封装起来,客户端将通过此环境类选择不同的算法完成排序:
在此类中,我们保存了一个 ISort 接口的实现对象,在构造方法中,将其初始值传递进来,排序时调用此对象的 sort 方法即可完成排序。
我们也可以为 ISort 对象设定一个默认值,客户端如果没有特殊需求,直接使用默认的算法策略即可。
setSort 方法就是用来选择不同的排序策略的,客户端调用如下:
这就是基本的策略模式,通过策略模式我们可以为同一个需求选择不同的算法,以应付不同的场景。比如我们知道冒泡排序和插入排序是稳定的,而选择排序是不稳定的,当我们需要保证排序的稳定性就可以采用冒泡排序和插入排序,不需要保证排序的稳定性时可以采用选择排序。
策略模式还可以应用在图片缓存中,当我们开发一个图片缓存框架时,可以通过提供不同的策略类,让用户根据需要选择缓存解码后的图片、缓存未经解码的数据或者不缓存任何内容。在一些开源的图片加载框架中,就采用了这种设计。
策略模式扩展性和灵活性都相当不错。当有新的策略时,只需要增加一个策略类;要修改某个策略时,只需要更改具体的策略类,其他地方的代码都无需做任何调整。
但现在这样的策略模式还有一个弊端,如本系列第一篇文章中的工厂模式所言:每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。
所以使用策略模式时,更好的做法是与工厂模式结合,将不同的策略对象封装到工厂类中,用户只需要传递不同的策略类型,然后从工厂中拿到对应的策略对象即可。接下来我们就来一起实现这种工厂模式与策略模式结合的混合模式。
2、策略模式 + 工厂模式
创建算法策略枚举类:
在 Sort 类中使用简单工厂模式:
利用简单工厂模式,我们将创建策略类的职责移到了 Sort 类中。如此一来,客户端只需要和 Sort 类打交道,通过 SortStrategy 选择不同的排序策略即可。
客户端:
通过简单工厂模式与策略模式的结合,我们最大化地减轻了客户端的压力。这是我们第一次用到混合模式,但实际开发中会遇到非常多的混合模式,学习设计模式的过程只能帮助我们各个击破,真正融会贯通还需要在实际开发中多加操练。
需要注意的是,策略模式与状态模式非常类似,甚至他们的 UML 类图都是一模一样的。两者都是采用一个变量来控制程序的行为。策略模式通过不同的策略执行不同的行为,状态模式通过不同的状态值执行不同的行为。两者的代码很类似,他们的区别主要在于程序的目的不同。
四、模板方法模式
模板方法模式(Template Method Pattern):定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
通俗地说,模板方法模式就是一个关于继承的设计模式。
每一个被继承的父类都可以认为是一个模板,它的某些步骤是稳定的,某些步骤被延迟到子类中实现。
这和我们平时生活中使用的模板也是一样的。比如我们请假时,通常会给我们一份请假条模板,内容是已经写好的,只需要填写自己的姓名和日期即可。
比如:
本人 ___ 因 ___ 需请假 ___ 天,望批准!
这个模板用代码表示如下:
在这份模板中,所有的其他步骤(固定字符串)都是稳定的,只有姓名、请假原因、请假时长是抽象的,需要延迟到子类去实现。
继承此模板,实现具体步骤的子类:
测试:
在使用模板方法模式时,我们可以为不同的模板方法设置不同的控制权限:
- 如果不希望子类覆写模板中的某个方法,使用 final 修饰此方法;
- 如果要求子类必须覆写模板中的某个方法,使用 abstract 修饰此方法;
- 如果没有特殊要求,可使用 protected 或 public 修饰此方法,子类可根据实际情况考虑是否覆写。
五、访问者模式
许多设计模式的书中都说访问者模式是最复杂的设计模式,实际上只要我们对它抽丝剥茧,就会发现访问者模式的核心思想并不复杂。
以我们去吃自助餐为例,每个人喜欢的食物是不一样的,比如 Aurora 喜欢吃龙虾和西瓜,Kevin 喜欢吃牛排和香蕉,餐厅不可能单独为某一位顾客专门准备食物。所以餐厅的做法是将所有的食物都准备好,顾客按照需求自由取用。此时,顾客和餐厅之间就形成了一种访问者与被访问者的关系。
准备好各种食物的餐厅:
在餐厅类中,我们提供了四种食物:龙虾、西瓜、牛排、香蕉。
为顾客提供的接口:
接口中提供了四个方法, 让顾客依次选择每种食物。
在餐厅中提供接收访问者的方法:
在 welcome 方法中,我们将食物依次传递给访问者对应的访问方法。这时候,顾客如果想要访问餐厅选择自己喜欢的食物,只需要实现 IVisitor 接口即可。
比如顾客 Aurora 类:
在此类中,顾客根据自己的喜好依次选择每种食物。
客户端测试:
运行程序,输出如下:
可以看到,Aurora 对每一种食物做出了自己的选择,这就是一个最简单的访问者模式,它已经体现出了访问者模式的核心思想:将数据的结构和对数据的操作分离。
访问者模式(Visitor Pattern):表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
本例中,顾客需要选择餐厅的食物,由于每个顾客对食物的选择是不一样的,如果在餐厅类中处理每位顾客的需求,必然导致餐厅类职责过多。所以我们并没有在餐厅类中处理顾客的需求,而是将所有的食物通过接口暴露出去,欢迎每位顾客来访问。顾客只要实现访问者接口就能访问到所有的食物,然后在接口方法中做出自己的选择。
相信这个例子还是非常简单直观的,看起来访问者模式也不是那么难理解。那么为什么很多书中说访问者模式是最复杂的设计模式呢?原因就在于《设计模式》一书中给访问者模式设计了一个“双重分派”的机制,而 Java 只支持单分派,用单分派语言强行模拟出双重分派才导致了访问者模式看起来比较复杂。要理解这一点,我们先来了解一下何谓单分派、何谓双重分派。
1、单分派与双重分派
先看一段代码:
Food 类:
在 Watermelon 类中,我们重写了name()方法。
客户端:
思考一下,在客户端中,我们 new 出了一个 Watermelon 对象,但他的声明类型是 Food,当我们调用此对象的 name 方法时,会输出 “food” 还是 “watermelon” 呢?
了解过 Java 多态特性的同学都知道,这里肯定是输出 “watermelon” ,因为 Java 调用重写方法时,会根据运行时的具体类型来确定调用哪个方法。
再来看一段测试代码:
在这段代码中,我们仍然 new 出了一个 Watermelon 对象,他的声明类型是 Food,在客户端中有eat(Food food)和eat(Watermelon watermelon)两个重载方法,这段代码会调用哪一个方法呢?
我们运行这段代码会发现输出的是:
eat food
这是由于 Java 在调用重载方法时,只会根据方法签名中声明的参数类型来判断调用哪个方法,不会去判断参数运行时的具体类型是什么。
从这两个例子中,我们可以看出 Java 对重写方法和重载方法的调用方式是不同的。
- 调用重写方法时,与对象的运行时类型有关;
- 调用重载方法时,只与方法签名中声明的参数类型有关,与对象运行时的具体类型无关。
了解了重写方法和重载方法调用方式的区别之后,我们将其综合起来就能理解何谓双重分派了。
测试代码:
在这段测试代码中,仍然是 new 出了一个 Watermelon 对象,它的声明类型为 Food。运行test()函数,输出如下:
eat food: watermelon
在面向对象的编程语言中,我们将方法调用称之为分派,这段测试代码运行时,经过了两次分派:
- 调用重载方法:选择调用eat(Food food)还是eat(Watermelon
watermelon)。虽然这里传入的这个参数实际类型是Watermelon,但这里会调用eat(Food
food),这是由于调用哪个重载方法是在编译期就确定了的,也称之为静态分派。 - 调用重写方法:选择调用Food的name方法还是Watermelon的name方法。这里会根据参数运行时的实际类型,调用Watermelon的name方法,称之为动态分派。
单分派、双重分派的定义如下:
方法的接收者和方法的参数统称为方法的宗量。 根据分派基于多少个宗量,可以将分派分为单分派和多分派。单分派是指根据一个宗量就可以知道应该调用哪个方法,多分派是指需要根据多个宗量才能确定调用目标。
这段定义可能不太好理解,通俗地讲,单分派和双重分派的区别就是:程序在选择重载方法和重写方法时,如果两种情况都是动态分派的,则称之为双重分派;如果其中一种情况是动态分派,另一种是静态分派,则称之为单分派。
说了这么多,这和我们的访问者模式有什么关系呢?首先我们要知道,架构的演进往往都是由复杂的业务驱动的,当程序需要更好的扩展性,更灵活的架构便诞生出来。
上例中的程序非常简单,但它无法处理某种食物有多个的情形。接下来我们就来修改一下程序,来应对每种食物有多个的场景。
2、自助餐程序 2.0 版
在上面的例子中,为了突出访问者模式的特点,我们将每种食物都简化为了 String 类型,实际开发中,每种食物都应该是一个单独的对象,统一继承自父类 Food:
继承自 Food 的四种食物:
龙虾:
西瓜:
牛排:
香蕉:
四个子类中分别重写了 name 方法,返回自己的食物名。
IVisitor 接口对应修改为:
每种食物都继承自 Food,所以我们将接口中的方法名都修改为了 chooseFood。
餐厅类修改如下:
餐厅类中新增了prepareFoods方法,在这个方法中,我们简单模拟了准备多个食物的过程,将每种食物添加了 10 份。在接收访问者的welcome方法中,遍历所有食物,分别提供给顾客。
看起来很美好,实际上,visitor.chooseFood(food)这一行是无法编译通过的,原因就在于上一节中提到的单分派机制。虽然每种食物都继承自 Food 类,但由于接口中没有chooseFood(Food food)这个重载方法,所以这一行会报错"Cannot resolve method chooseFood"。
试想,如果 Java 在调用重载方法时也采用动态分派,也就是根据参数的运行时类型选择对应的重载方法,这里遇到的问题就迎刃而解了,我们的访问者模式讲到这里也就可以结束了。
但由于 Java 是单分派语言,所以我们不得不想办法解决这个 bug,目的就是使用单分派的 Java 语言模拟出双分派的效果,能够根据运行时的具体类型调用对应的重载方法。
我们很容易想到一种解决方式,采用 instanceOf 判断对象的具体子类型,再将父类强制转换为具体子类型,调用对应的接口方法:
// 通过 instanceOf 判断具体子类型,再强制向下转型
if (food instanceof Lobster) visitor.chooseFood((Lobster) food);
else if (food instanceof Watermelon) visitor.chooseFood((Watermelon) food);
else if (food instanceof Steak) visitor.chooseFood((Steak) food);
else if (food instanceof Banana) visitor.chooseFood((Banana) food);
else throw new IllegalArgumentException("Unsupported type of food.");
的确可行,在某些开源代码中便是这么做的,但这种强制转型的方式既冗长又不符合开闭原则,所以《设计模式》一书中给我们推荐了另一种做法。
首先在 Food 类中添加 accept(Visitor visitor) 抽象方法:
在具体子类中,实现此方法:
经过这两步修改,餐厅类就可以将接收访问者的方法修改如下:
经过这三步修改,我们将访问者来访的代码由:
visitor.chooseFood(food);
改成了:
food.accept(visitor);
这样我们就将重载方法模拟成了动态分派。这里的实现非常巧妙,由于 Java 调用重写方法时是动态分派的,所以food.accept(visitor)会调用具体子类的 accept 方法,在具体子类的 accept 方法中,调用visitor.chooseFood(this),由于这个 accept 方法是属于具体子类的,所以这里的 this 一定是指具体的子类型,不会产生歧义。
再深入分析一下:之前的代码中,调用visitor.chooseFood(food)这行代码时,由于重载方法不知道 Food 的具体子类型导致了编译失败,但实际上这时我们是可以拿到 Food 的具体子类型的。利用重写方法会动态分派的特性,我们在子类的重写方法中去调用这些重载的方法,使得重载方法使用起来也像是动态分派的一样。
顾客 Aurora 类:
顾客 Kevin 类:
客户端测试:
这就是访问者模式,它的核心思想其实非常简单,就是第一小节中体现的将数据的结构与对数据的操作分离。之所以说它复杂,主要在于大多数语言都是单分派语言,所以不得不模拟出一个双重分派,也就是用重写方法的动态分派特性将重载方法也模拟成动态分派。
但模拟双重分派只是手段,不是目的。有的文章中说模拟双重分派是访问者模式的核心,还有的文章中说双分派语言不需要访问者模式,笔者认为这些说法都有点舍本逐末了。