03-设计模式之行为型模式 3

我们将学习剩余的 5 种行为型模式,它们分别是:

  • 观察者模式
  • 状态模式
  • 策略模式
  • 模板方法模式
  • 访问者模式(不常用)

1、观察者模式

观察者模式非常常见,近年来逐渐流行的响应式编程就是观察者模式的应用之一。观察者模式的思想就是一个对象发生一个事件后,逐一通知监听着这个对象的监听者,监听者可以对这个事件马上做出响应。生活中有很多观察者模式的例子,比如我们平时的开关灯。当我们打开灯的开关时,灯马上亮了;当我们关闭灯的开关时,灯马上熄了。这个过程中,灯就对我们控制开关的事件做出了响应,这就是一个最简单的一对一观察者模式。当力扣公众号发表一篇文章,所有关注了公众号的读者立即收到了文章,这个过程中所有关注了公众号的微信客户端就对公众号发表文章的事件做出了响应,这就是一个典型的一对多观察者模式。再举个例子,比如警察一直观察着张三的一举一动,只要张三有什么违法行为,警察马上行动,抓捕张三。这个过程中:

  • 警察称之为观察者(Observer)
  • 张三称之为被观察者(Observable,可观察的)
  • 警察观察张三的这个行为称之为订阅(subscribe),或者注册(register)
  • 张三违法后,警察抓捕张三的行动称之为响应(update)

众所周知,张三坏事做尽,是一个老法外狂徒了,所以不止一个警察会盯着张三,也就是说一个被观察者可以有多个观察者。当被观察者有事件发生时,所有观察者都能收到通知并响应。观察者模式主要处理的是一种一对多的依赖关系。它的定义如下:

观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

我们使用程序来模拟一下这个过程。

观察者的接口:

1
2
3
public interface Observer {
void update(String event);
}

接口中只有一个 update 方法,用于对被观察者发出的事件做出响应。

被观察者的父类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Observable {

private List<Observer> observers = new ArrayList<>();

public void addObserver(Observer observer) {
observers.add(observer);
}

public void removeObserver(Observer observer) {
observers.remove(observer);
}

public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}

被观察者中维护了一个观察者列表,提供了三个方法:

  • addObserver:将 observer 对象添加到观察者列表中
  • removeObserver:将 observer 对象从观察者列表中移除
  • notifyObservers:通知所有观察者有事件发生,具体实现是调用所有观察者的 update 方法

有了这两个基类,我们就可以定义出具体的罪犯与警察类。

警察属于观察者:

1
2
3
4
5
6
public class PoliceObserver implements Observer {
@Override
public void update(String event) {
System.out.println("警察收到消息,罪犯在" + event);
}
}

警察实现了观察者接口,当警察收到事件后,做出响应,这里的响应就是简单的打印了一条日志。

罪犯属于被观察者:

1
2
3
4
5
6
public class CriminalObservable extends Observable {
public void crime(String event) {
System.out.println("罪犯正在" + event);
notifyObservers(event);
}
}

罪犯继承自被观察者类,当罪犯有犯罪行为时,所有的观察者都会收到通知。

客户端测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
@Test
public void test() {
CriminalObservable zhangSan = new CriminalObservable();
PoliceObserver police1 = new PoliceObserver();
PoliceObserver police2 = new PoliceObserver();
PoliceObserver police3 = new PoliceObserver();
zhangSan.addObserver(police1);
zhangSan.addObserver(police2);
zhangSan.addObserver(police3);
zhangSan.crime("放狗咬人");
}
}

在客户端中,我们 new 了一个张三,为其添加了三个观察者:police1,police2,police3。

运行程序,输出如下:

1
2
3
4
罪犯正在放狗咬人
警察收到消息,罪犯在放狗咬人
警察收到消息,罪犯在放狗咬人
警察收到消息,罪犯在放狗咬人

可以看到,所有的观察者都被通知到了。当某个观察者不需要继续观察时,调用 removeObserver 即可。

这就是观察者模式,它并不复杂,由于生活中一对多的关系非常常见,所以观察者模式应用广泛。

使用场景

  • 关联行为场景。需要注意的是,关联行为是可拆分的,而不是“组合”关系。
  • 事件多级触发场景。
  • 跨系统的消息交换场景,如消息队列的处理机制。

注意:

  • 广播链的问题:在一个观察者模式中最多出现一个对象既是观察者也是被观察者,也就是说消息最多转发一次(传递两次)。
  • 异步处理问题:观察者比较多,而且处理时间比较长,采用异步处理来考虑线程安全和队列的问题。

Java 源码中的观察者模式

实际上,Java 已经为我们提供了的 Observable 类和 Observer 类,我们在用到观察者模式时,无需自己创建这两个基类,我们来看一下 Java 中提供的源码:

java.util.Observer 接口:

1
2
3
public interface Observer {
void update(Observable o, Object arg);
}

Observer 接口和我们上例中的定义基本一致,都是只有一个 update 方法用于响应 Observable 的事件。区别有两点:

  • update 方法将 Observable 对象也提供给了 Observer
  • update 方法中的参数类型变成了 Object

这两点区别都是为了保证此 Observer 的适用范围更广。

java.util.Observable 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 的适用范围更广,核心思想与本文介绍的都是一致的。

2、状态模式

状态模式(State Pattern):当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。

通俗地说,状态模式就是一个关于多态的设计模式。

如果一个对象有多种状态,并且每种状态下的行为不同,一般的做法是在这个对象的各个行为中添加 if-else 或者 switch-case 语句。但更好的做法是为每种状态创建一个状态对象,使用状态对象替换掉这些条件判断语句,使得状态控制更加灵活,扩展性也更好。

举个例子,力扣的用户有两种状态:普通用户和 PLUS 会员。PLUS 会员有非常多的专享功能,其中“模拟面试”功能非常有特色,我们便以此为例。

  • 当普通用户点击模拟面试功能时,提示用户:模拟面试是 Plus 会员专享功能;
  • 当 PLUS 会员点击模拟面试功能时,开始一场模拟面试。

先来看看不使用状态模式的写法,看出它的缺点后,我们再用状态模式来重构代码。

首先定义一个用户状态枚举类:

1
2
3
public enum State {
NORMAL, PLUS
}

NORMAL 代表普通用户状态,PLUS 代表 PLUS 会员状态。

用户的功能接口:

1
2
3
public interface IUser {
void mockInterview();
}

本例中我们只定义了一个模拟面试的方法,实际开发中这里可能会有许许多多的方法。

用户状态切换接口:

1
2
3
4
5
public interface ISwitchState {
void purchasePlus();

void expire();
}

此接口中定义了两个方法:purchasePlus 方法表示购买 Plus 会员,用户状态变为 PLUS 会员状态,expire 方法表示会员过期,用户状态变为普通用户状态。

力扣用户类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class User implements IUser, ISwitchState {
private State state = State.NORMAL;

@Override
public void mockInterview() {
if (state == State.PLUS) {
System.out.println("开始模拟面试");
} else {
System.out.println("模拟面试是 Plus 会员专享功能");
}
}

@Override
public void purchasePlus() {
state = State.PLUS;
}

@Override
public void expire() {
state = State.NORMAL;
}
}

用户类实现了 IUser 接口,IUser 接口中的每个功能都需要判断用户是否为 Plus 会员,也就是说每个方法中都有 if (state == State.PLUS) {} else {} 语句,如果状态不止两种,还需要用上 switch-case 语句来判断状态,这就是不使用状态模式的弊端:

  • 判断用户状态会产生大量的分支判断语句,导致代码冗长;
  • 当状态有增加或减少时,需要改动多个地方,违反开闭原则。

在《代码整洁之道》、《重构》两本书中都提到:应使用多态取代条件表达式。接下来我们就利用多态特性重构这份代码。为每个状态新建一个状态类,普通用户:

1
2
3
4
5
6
7
class Normal implements IUser {

@Override
public void mockInterview() {
System.out.println("模拟面试是 Plus 会员专享功能");
}
}

PLUS 会员:

1
2
3
4
5
6
7
class Plus implements IUser {

@Override
public void mockInterview() {
System.out.println("开始模拟面试");
}
}

每个状态类都实现了 IUser 接口,在接口方法中实现自己特定的行为。

用户类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class User implements IUser, ISwitchState {

IUser state = new Normal();

@Override
public void mockInterview() {
state.mockInterview();
}

@Override
public void purchasePlus() {
state = new Plus();
}

@Override
public void expire() {
state = new Normal();
}
}

可以看到,丑陋的状态判断语句消失了,无论 IUser 接口中有多少方法,User 类都只需要调用状态类的对应方法即可。

客户端测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {

@Test
public void test() {
// 用户初始状态为普通用户
User user = new User();
// 输出:模拟面试是 Plus 会员专享功能
user.mockInterview();

// 用户购买 Plus 会员,状态改变
user.purchasePlus();
// 输出:开始模拟面试
user.mockInterview();

// Plus 会员过期,变成普通用户,状态改变
user.expire();
// 输出:模拟面试是 Plus 会员专享功能
user.mockInterview();
}
}

可以看到,用户状态改变后,行为也随着改变了,这就是状态模式定义的由来,它的优点是:将与特定状态相关的行为封装到一个状态对象中,使用多态代替 if-else 或者 switch-case 状态判断。缺点是必然导致类增加,这也是使用多态不可避免的缺点。

使用场景

  • 行为随状态改变而改变的场景

    这也是状态模式的根本出发点,例如权限设计,人员的状态不同即使执行相同的行为结果也会不同,在这种情况下需要考虑使用状态模式。

  • 条件、分支判断语句的替代者

注意:状态模式适用于当某个对象在它的状态发生改变时,它的行为也随着发生比较大的变化,也就是说在行为受状态约束的情况下可以使用状态模式,而且使用时对象的状态最好不要超过5个。

3、策略模式

策略模式用一个成语就可以概括 —— 殊途同归。当我们做同一件事有多种方法时,就可以将每种方法封装起来,在不同的场景选择不同的策略,调用不同的方法。

策略模式(Strategy Pattern):定义了一系列算法,并将每一个算法封装起来,而且使它们还可以相互替换。策略模式让算法独立于使用它的客户而独立变化。

我们以排序算法为例。排序算法有许多种,如冒泡排序、选择排序、插入排序,算法不同但目的相同,我们可以将其定义为不同的策略,让用户自由选择采用哪种策略完成排序。

首先定义排序算法接口:

1
2
3
interface ISort {
void sort(int[] arr);
}

接口中只有一个 sort 方法,传入一个整型数组进行排序,所有的排序算法都实现此接口。

冒泡排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BubbleSort implements ISort{
@Override
public void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 如果左边的数大于右边的数,则交换,保证右边的数字最大
arr[j + 1] = arr[j + 1] + arr[j];
arr[j] = arr[j + 1] - arr[j];
arr[j + 1] = arr[j + 1] - arr[j];
}
}
}
}
}

选择排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SelectionSort implements ISort {
@Override
public void sort(int[] arr) {
int minIndex;
for (int i = 0; i < arr.length - 1; i++) {
minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[minIndex] > arr[j]) {
// 记录最小值的下标
minIndex = j;
}
}
// 将最小元素交换至首位
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}

插入排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class InsertSort implements ISort {
@Override
public void sort(int[] arr) {
// 从第二个数开始,往前插入数字
for (int i = 1; i < arr.length; i++) {
int currentNumber = arr[i];
int j = i - 1;
// 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
while (j >= 0 && currentNumber < arr[j]) {
arr[j + 1] = arr[j];
j--;
}
// 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
// 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
arr[j + 1] = currentNumber;
}
}
}

这三种都是基本的排序算法,就不再详细介绍了。接下来我们需要创建一个环境类,将每种算法都作为一种策略封装起来,客户端将通过此环境类选择不同的算法完成排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Sort implements ISort {

private ISort sort;

Sort(ISort sort) {
this.sort = sort;
}

@Override
public void sort(int[] arr) {
sort.sort(arr);
}

// 客户端通过此方法设置不同的策略
public void setSort(ISort sort) {
this.sort = sort;
}
}

在此类中,我们保存了一个 ISort 接口的实现对象,在构造方法中,将其初始值传递进来,排序时调用此对象的 sort 方法即可完成排序。

我们也可以为 ISort 对象设定一个默认值,客户端如果没有特殊需求,直接使用默认的排序策略即可。

setSort 方法就是用来选择不同的排序策略的,客户端调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
@Test
public void test() {
int[] arr = new int[]{6, 1, 2, 3, 5, 4};
Sort sort = new Sort(new BubbleSort());
// 这里可以选择不同的策略完成排序
// sort.setSort(new InsertSort());
// sort.setSort(new SelectionSort());
sort.sort(arr);
// 输出 [1, 2, 3, 4, 5, 6]
System.out.println(Arrays.toString(arr));
}
}

这就是基本的策略模式,通过策略模式我们可以为同一个需求选择不同的算法,以应付不同的场景。比如我们知道冒泡排序和插入排序是稳定的,而选择排序是不稳定的,当我们需要保证排序的稳定性就可以采用冒泡排序和插入排序,不需要保证排序的稳定性时可以采用选择排序。

策略模式还可以应用在图片缓存中,当我们开发一个图片缓存框架时,可以通过提供不同的策略类,让用户根据需要选择缓存解码后的图片、缓存未经解码的数据或者不缓存任何内容。在一些开源的图片加载框架中,就采用了这种设计。

策略模式扩展性和灵活性都相当不错。当有新的策略时,只需要增加一个策略类;要修改某个策略时,只需要更改具体的策略类,其他地方的代码都无需做任何调整。

但现在这样的策略模式还有一个弊端,如本系列第一篇文章中的工厂模式所言:每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。

所以使用策略模式时,更好的做法是与工厂模式结合,将不同的策略对象封装到工厂类中,用户只需要传递不同的策略类型,然后从工厂中拿到对应的策略对象即可。接下来我们就来一起实现这种工厂模式与策略模式结合的混合模式。

创建排序策略枚举类:

1
2
3
4
5
enum SortStrategy {
BUBBLE_SORT,
SELECTION_SORT,
INSERT_SORT
}

在 Sort 类中使用简单工厂模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Sort implements ISort {

private ISort sort;

Sort(SortStrategy strategy) {
setStrategy(strategy);
}

@Override
public void sort(int[] arr) {
sort.sort(arr);
}

// 客户端通过此方法设置不同的策略
public void setStrategy(SortStrategy strategy) {
switch (strategy) {
case BUBBLE_SORT:
sort = new BubbleSort();
break;
case SELECTION_SORT:
sort = new SelectionSort();
break;
case INSERT_SORT:
sort = new InsertSort();
break;
default:
throw new IllegalArgumentException("There's no such strategy yet.");
}
}
}

利用简单工厂模式,我们将创建策略类的职责移到了 Sort 类中。如此一来,客户端只需要和 Sort 类打交道,通过 SortStrategy 选择不同的排序策略即可。

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
@Test
public void test() {
int[] arr = new int[]{6, 1, 2, 3, 5, 4};
Sort sort = new Sort(SortStrategy.BUBBLE_SORT);
// 可以通过选择不同的策略完成排序
// sort.setStrategy(SortStrategy.SELECTION_SORT);
// sort.setStrategy(SortStrategy.INSERT_SORT);
sort.sort(arr);
// 输出 [1, 2, 3, 4, 5, 6]
System.out.println(Arrays.toString(arr));
}
}

通过简单工厂模式与策略模式的结合,我们最大化地减轻了客户端的压力。这是我们第一次用到混合模式,但实际开发中会遇到非常多的混合模式,学习设计模式的过程只能帮助我们各个击破,真正融会贯通还需要在实际开发中多加操练。

需要注意的是,策略模式与状态模式非常类似,甚至他们的 UML 类图都是一模一样的。两者都是采用一个变量来控制程序的行为。策略模式通过不同的策略执行不同的行为,状态模式通过不同的状态值执行不同的行为。两者的代码很类似,他们的区别主要在于程序的目的不同。

策略模式和状态模式区别

  • 使用策略模式时,程序只需选择一种策略就可以完成某件事。也就是说每个策略类都是完整的,都能独立完成这件事情,如上文所言,强调的是 殊途同归
  • 使用状态模式时,程序需要在不同的状态下不断切换才能完成某件事,每个状态类只能完成这件事的一部分,需要所有的状态类组合起来才能完整的完成这件事,强调的是 随势而动

使用场景

  • 多个类只有在算法或行为上稍有不同的场景。
  • 算法需要自由切换的场景。
  • 需要屏蔽算法规则的场景。

4、模板方法模式(Template Method Pattern)

模板方法模式:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

通俗地说,模板方法模式就是一个关于继承的设计模式。

每一个被继承的父类 都可以认为是一个模板,它的某些步骤是稳定的,某些步骤被延迟到子类中实现。

这和我们平时生活中使用的模板也是一样的。比如我们请假时,通常会给我们一份请假条模板,内容是已经写好的,只需要填写自己的姓名和日期即可。

本人 ___ 因 ___ 需请假 ___ 天,望批准!

这个模板用代码表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class LeaveRequest {

void request() {
System.out.print("本人");
System.out.print(name());
System.out.print("因");
System.out.print(reason());
System.out.print("需请假");
System.out.print(duration());
System.out.print("天,望批准");
}

abstract String name();

abstract String reason();

abstract String duration();
}

在这份模板中,所有的其他步骤(固定字符串)都是稳定的,只有姓名、请假原因、请假时长是抽象的,需要延迟到子类去实现。

继承此模板,实现具体步骤的子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyLeaveRequest extends LeaveRequest {
@Override
String name() {
return "lp";
}

@Override
String reason() {
return "参加力扣周赛";
}

@Override
String duration() {
return "0.5";
}
}

测试:

1
2
// 输出:本人lp因参加力扣周赛需请假0.5天,望批准
new MyLeaveRequest().request();

在使用模板方法模式时,我们可以为不同的模板方法设置不同的控制权限:

  • 如果不希望子类覆写模板中的某个方法,使用 final 修饰此方法;
  • 如果要求子类必须覆写模板中的某个方法,使用 abstract 修饰此方法;
  • 如果没有特殊要求,可使用 protected 或 public 修饰此方法,子类可根据实际情况考虑是否覆写。

结构

  • 抽象父类(AbstractClass):实现了模板方法,定义了算法的骨架。
  • 具体类(ConcreteClass):实现抽象类中的抽象方法,即不同的对象的具体实现细节。

注意:为了防止恶意的操作,一般模板方法都加上 final 关键字,不允许被覆写。

使用场景

  • 多个子类有公有的方法,并且逻辑基本相同时。
  • 重要、复杂的算法,可以把核心算法设计为模板方法,周边的相关细节功能则由各个子类实现。
  • 重构时,模板方法模式是一个经常使用的模式,把相同的代码抽取到父类中,然后通过钩子函数(见“模板方法模式的扩展”)约束其行为。

5、访问者模式

许多设计模式的书中都说访问者模式是最复杂的设计模式,实际上只要我们对它抽丝剥茧,就会发现访问者模式的核心思想并不复杂。

以我们去吃自助餐为例,每个人喜欢的食物是不一样的,比如 Aurora 喜欢吃龙虾和西瓜,Kevin 喜欢吃牛排和香蕉,餐厅不可能单独为某一位顾客专门准备食物。所以餐厅的做法是将所有的食物都准备好,顾客按照需求自由取用。此时,顾客和餐厅之间就形成了一种访问者与被访问者的关系。

准备好各种食物的餐厅:

1
2
3
4
5
6
class Restaurant {
private String lobster = "lobster";
private String watermelon = "watermelon";
private String steak = "steak";
private String banana = "banana";
}

在餐厅类中,我们提供了四种食物:龙虾、西瓜、牛排、香蕉。

为顾客提供的接口:

1
2
3
4
5
6
7
8
9
public interface IVisitor {
void chooseLobster(String lobster);

void chooseWatermelon(String watermelon);

void chooseSteak(String steak);

void chooseBanana(String banana);
}

接口中提供了四个方法, 让顾客依次选择每种食物。

在餐厅中提供接收访问者的方法:

1
2
3
4
5
6
7
8
9
10
class Restaurant {
...

public void welcome(IVisitor visitor) {
visitor.chooseLobster(lobster);
visitor.chooseWatermelon(watermelon);
visitor.chooseSteak(steak);
visitor.chooseBanana(banana);
}
}

在 welcome 方法中,我们将食物依次传递给访问者对应的访问方法。这时候,顾客如果想要访问餐厅选择自己喜欢的食物,只需要实现 IVisitor 接口即可。

比如顾客 Aurora 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Aurora implements IVisitor {
@Override
public void chooseLobster(String lobster) {
System.out.println("Aurora gets a " + lobster);
}

@Override
public void chooseWatermelon(String watermelon) {
System.out.println("Aurora gets a " + watermelon);
}

@Override
public void chooseSteak(String steak) {
System.out.println("Aurora doesn't like " + steak);
}

@Override
public void chooseBanana(String banana) {
System.out.println("Aurora doesn't like " + banana);
}
}

在此类中,顾客根据自己的喜好依次选择每种食物。

客户端测试:

1
2
3
4
5
6
7
8
public class Client {
@Test
public void test() {
Restaurant restaurant = new Restaurant();
IVisitor Aurora = new Aurora();
restaurant.welcome(Aurora);
}
}

运行程序,输出如下:

1
2
3
4
Aurora gets a lobster
Aurora gets a watermelon
Aurora doesn't like steak
Aurora doesn't like banana

可以看到,Aurora 对每一种食物做出了自己的选择,这就是一个最简单的访问者模式,它已经体现出了访问者模式的核心思想:将数据的结构对数据的操作分离。

访问者模式(Visitor Pattern):表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

本例中,顾客需要选择餐厅的食物,由于每个顾客对食物的选择是不一样的,如果在餐厅类中处理每位顾客的需求,必然导致餐厅类职责过多。所以我们并没有在餐厅类中处理顾客的需求,而是将所有的食物通过接口暴露出去,欢迎每位顾客来访问。顾客只要实现访问者接口就能访问到所有的食物,然后在接口方法中做出自己的选择。

相信这个例子还是非常简单直观的,看起来访问者模式也不是那么难理解。那么为什么很多书中说访问者模式是最复杂的设计模式呢?原因就在于《设计模式》一书中给访问者模式设计了一个“双重分派”的机制,而 Java 只支持单分派,用单分派语言强行模拟出双重分派才导致了访问者模式看起来比较复杂。要理解这一点,我们先来了解一下何谓单分派、何谓双重分派。

单分派与双重分派

先看一段代码:

Food 类:

1
2
3
4
5
public class Food {
public String name() {
return "food";
}
}

Watermelon 类,继承自 Food 类:

1
2
3
4
5
6
public class Watermelon extends Food {
@Override
public String name() {
return "watermelon";
}
}

在 Watermelon 类中,我们重写了name()方法。

客户端:

1
2
3
4
5
6
7
public class Client {
@Test
public void test() {
Food food = new Watermelon();
System.out.println(food.name());
}
}

思考一下,在客户端中,我们 new 出了一个 Watermelon 对象,但他的声明类型是 Food,当我们调用此对象的 name 方法时,会输出 “food” 还是 “watermelon” 呢?

了解过 Java 多态特性的同学都知道,这里肯定是输出 “watermelon” ,因为 Java 调用重写方法时,会根据运行时的具体类型来确定调用哪个方法。

再来看一段测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
@Test
public void test() {
Food food = new Watermelon();
eat(food);
}

public void eat(Food food) {
System.out.println("eat food");
}

public void eat(Watermelon watermelon) {
System.out.println("eat watermelon");
}
}

在这段代码中,我们仍然 new 出了一个 Watermelon 对象,他的声明类型是 Food,在客户端中有eat(Food food)eat(Watermelon watermelon)两个重载方法,这段代码会调用哪一个方法呢?

我们运行这段代码会发现输出的是:

1
eat food

这是由于 Java 在调用重载方法时,只会根据方法签名中声明的参数类型来判断调用哪个方法,不会去判断参数运行时的具体类型是什么。

从这两个例子中,我们可以看出 Java 对重写方法和重载方法的调用方式是不同的。

  • 调用重写方法时,与对象的运行时类型有关;
  • 调用重载方法时,只与方法签名中声明的参数类型有关,与对象运行时的具体类型无关。

了解了重写方法和重载方法调用方式的区别之后,我们将其综合起来就能理解何谓双重分派了。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
@Test
public void test() {
Food food = new Watermelon();
eat(food);
}

public void eat(Food food) {
System.out.println("eat food: " + food.name());
}

public void eat(Watermelon watermelon) {
System.out.println("eat watermelon" + watermelon.name());
}
}

在这段测试代码中,仍然是 new 出了一个 Watermelon 对象,它的声明类型为 Food。运行test()函数,输出如下:

1
eat food: watermelon

在面向对象的编程语言中,我们将方法调用称之为分派,这段测试代码运行时,经过了两次分派:

  • 调用重载方法:选择调用eat(Food food)还是eat(Watermelon watermelon)。虽然这里传入的这个参数实际类型是Watermelon,但这里会调用eat(Food food),这是由于调用哪个重载方法是在编译期就确定了的,也称之为静态分派
  • 调用重写方法:选择调用Foodname方法还是Watermelonname方法。这里会根据参数运行时的实际类型,调用Watermelonname方法,称之为动态分派

单分派、双重分派的定义如下:

方法的接收者和方法的参数统称为方法的宗量。 根据分派基于多少个宗量,可以将分派分为单分派和多分派。单分派是指根据一个宗量就可以知道应该调用哪个方法,多分派是指需要根据多个宗量才能确定调用目标。

这段定义可能不太好理解,通俗地讲,单分派和双重分派的区别就是:程序在选择重载方法和重写方法时,如果两种情况都是动态分派的,则称之为双重分派;如果其中一种情况是动态分派,另一种是静态分派,则称之为单分派。

说了这么多,这和我们的访问者模式有什么关系呢?首先我们要知道,架构的演进往往都是由复杂的业务驱动的,当程序需要更好的扩展性,更灵活的架构便诞生出来。

上例中的程序非常简单,但它无法处理某种食物有多个的情形。接下来我们就来修改一下程序,来应对每种食物有多个的场景。

自助餐程序 2.0 版

在上面的例子中,为了突出访问者模式的特点,我们将每种食物都简化为了 String 类型,实际开发中,每种食物都应该是一个单独的对象,统一继承自父类 Food:

1
2
3
public abstract class Food {
public abstract String name();
}

继承自 Food 的四种食物:

龙虾:

1
2
3
4
5
6
public class Lobster extends Food {
@Override
public String name() {
return "lobster";
}
}

西瓜:

1
2
3
4
5
6
public class Watermelon extends Food {
@Override
public String name() {
return "watermelon";
}
}

牛排:

1
2
3
4
5
6
public class Steak extends Food {
@Override
public String name() {
return "steak";
}
}

香蕉:

1
2
3
4
5
6
public class Banana extends Food {
@Override
public String name() {
return "banana";
}
}

四个子类中分别重写了 name 方法,返回自己的食物名。

IVisitor 接口对应修改为:

1
2
3
4
5
6
7
8
9
public interface IVisitor {
void chooseFood(Lobster lobster);

void chooseFood(Watermelon watermelon);

void chooseFood(Steak steak);

void chooseFood(Banana banana);
}

每种食物都继承自 Food,所以我们将接口中的方法名都修改为了 chooseFood。

餐厅类修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Restaurant {

// 准备当天的食物
private List<Food> prepareFoods() {
List<Food> foods = new ArrayList<>();
// 简单模拟,每种食物添加 10 份
for (int i = 0; i < 10; i++) {
foods.add(new Lobster());
foods.add(new Watermelon());
foods.add(new Steak());
foods.add(new Banana());
}
return foods;
}

// 欢迎顾客来访
public void welcome(IVisitor visitor) {
// 获取当天的食物
List<Food> foods = prepareFoods();
// 将食物依次提供给顾客选择
for (Food food : foods) {
// 由于单分派机制,此处无法编译通过
visitor.chooseFood(food);
}
}
}

餐厅类中新增了prepareFoods方法,在这个方法中,我们简单模拟了准备多个食物的过程,将每种食物添加了 10 份。在接收访问者的welcome方法中,遍历所有食物,分别提供给顾客。

看起来很美好,实际上,visitor.chooseFood(food)这一行是无法编译通过的,原因就在于上一节中提到的单分派机制。虽然每种食物都继承自 Food 类,但由于接口中没有chooseFood(Food food)这个重载方法,所以这一行会报错”Cannot resolve method chooseFood”。

试想,如果 Java 在调用重载方法时也采用动态分派,也就是根据参数的运行时类型选择对应的重载方法,这里遇到的问题就迎刃而解了,我们的访问者模式讲到这里也就可以结束了。

但由于 Java 是单分派语言,所以我们不得不想办法解决这个 bug,目的就是使用单分派的 Java 语言模拟出双分派的效果,能够根据运行时的具体类型调用对应的重载方法

我们很容易想到一种解决方式,采用 instanceOf 判断对象的具体子类型,再将父类强制转换为具体子类型,调用对应的接口方法:

1
2
3
4
5
6
// 通过 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) 抽象方法:

1
2
3
4
5
6
public abstract class Food {
public abstract String name();

// Food 中添加 accept 方法,接收访问者
public abstract void accept(IVisitor visitor);
}

在具体子类中,实现此方法:

1
2
3
4
5
6
7
8
9
10
11
public class Lobster extends Food {
@Override
public String name() {
return "lobster";
}

@Override
public void accept(IVisitor visitor) {
visitor.chooseFood(this);
}
}

经过这两步修改,餐厅类就可以将接收访问者的方法修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Restaurant {

// 准备当天的食物
private List<Food> prepareFoods() {
List<Food> foods = new ArrayList<>();
// 简单模拟,每种食物添加 10 份
for (int i = 0; i < 10; i++) {
foods.add(new Lobster());
foods.add(new Watermelon());
foods.add(new Steak());
foods.add(new Banana());
}
return foods;
}

// 欢迎顾客来访
public void welcome(IVisitor visitor) {
// 获取当天的食物
List<Food> foods = prepareFoods();
// 将食物依次提供给顾客选择
for (Food food : foods) {
// 由于重写方法是动态分派的,所以这里会调用具体子类的 accept 方法,
food.accept(visitor);
}
}
}

经过这三步修改,我们将访问者来访的代码由:

1
visitor.chooseFood(food);

改成了

1
food.accept(visitor);

这样我们就将重载方法模拟成了动态分派。这里的实现非常巧妙,由于 Java 调用重写方法时是动态分派的,所以food.accept(visitor)会调用具体子类的 accept 方法,在具体子类的 accept 方法中,调用visitor.chooseFood(this),由于这个 accept 方法是属于具体子类的,所以这里的 this 一定是指具体的子类型,不会产生歧义。

再深入分析一下:之前的代码中,调用visitor.chooseFood(food)这行代码时,由于重载方法不知道 Food 的具体子类型导致了编译失败,但实际上这时我们是可以拿到 Food 的具体子类型的。利用重写方法会动态分派的特性,我们在子类的重写方法中去调用这些重载的方法,使得重载方法使用起来也像是动态分派的一样。

顾客 Aurora 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Aurora implements IVisitor {

@Override
public void chooseFood(Lobster lobster) {
System.out.println("Aurora gets a " + lobster.name());
}

@Override
public void chooseFood(Watermelon watermelon) {
System.out.println("Aurora gets a " + watermelon.name());
}

@Override
public void chooseFood(Steak steak) {
System.out.println("Aurora doesn't like " + steak.name());
}

@Override
public void chooseFood(Banana banana) {
System.out.println("Aurora doesn't like " + banana.name());
}
}

顾客 Kevin 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Kevin implements IVisitor {

@Override
public void chooseFood(Lobster lobster) {
System.out.println("Kevin doesn't like " + lobster.name());
}

@Override
public void chooseFood(Watermelon watermelon) {
System.out.println("Kevin doesn't like " + watermelon.name());
}

@Override
public void chooseFood(Steak steak) {
System.out.println("Kevin gets a " + steak.name());
}

@Override
public void chooseFood(Banana banana) {
System.out.println("Kevin gets a " + banana.name());
}
}

客户端测试:

1
2
3
4
5
6
7
8
9
10
public class Client {
@Test
public void test() {
Restaurant restaurant = new Restaurant();
IVisitor Aurora = new Aurora();
IVisitor Kevin = new Kevin();
restaurant.welcome(Aurora);
restaurant.welcome(Kevin);
}
}

运行程序,输出如下:

1
2
3
4
5
6
7
8
9
10
Aurora gets a lobster
Aurora gets a watermelon
Aurora doesn't like steak
Aurora doesn't like banana
... 输出 10 遍
Kevin doesn't like lobster
Kevin doesn't like watermelon
Kevin gets a steak
Kevin gets a banana
... 输出 10 遍

这就是访问者模式,它的核心思想其实非常简单,就是第一小节中体现的将数据的结构对数据的操作分离。之所以说它复杂,主要在于大多数语言都是单分派语言,所以不得不模拟出一个双重分派,也就是用重写方法的动态分派特性将重载方法也模拟成动态分派

但模拟双重分派只是手段,不是目的。有的文章中说模拟双重分派是访问者模式的核心,还有的文章中说双分派语言不需要访问者模式,笔者认为这些说法都有点舍本逐末了。


03-设计模式之行为型模式 3
https://flepeng.github.io/设计模式-03-设计模式之行为型模式-3/
作者
Lepeng
发布于
2021年3月8日
许可协议