GoF设计模式(二十):Visitor Pattern 访问者模式

Visitor Pattern 访问者模式,可以算是行为型设计模式中最复杂、最难理解的一种设计模式

abstract.jpeg

楔子

众所周知,男人和女人在逛商店买东西时关注的点是不一样的。比如男人在买衣服时一般比较关注价格,而买电脑这种电子产品时更多的则是关注外观颜色什么的;而女人则恰恰相反,买衣服时更多的是看外观颜色,而对于电脑这种东西,一般更多的是价格。换句话说,对于不同类型的访问者(男人、女人)而言,其对于同一个商品的关注点是不一样的。如果让大家通过代码来实现上面的场景,其实还是很简单的,现在我们就先来演示通过Java来实现上面这个例子

根据基于接口编程的原则,我们先定义一个商品Goods的接口,虽然该接口只是一个空接口。然后分别实现两个不同的商品——衣服Clothes、电脑Pc类

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
/**
* 商品
*/
public interface Goods {
}
...
/**
* 衣服
*/
@AllArgsConstructor // 提供全参构造器
@NoArgsConstructor // 提供无参构造器
@Data // 提供getter、setter方法
public class Clothes implements Goods {
/**
* 颜色
*/
private String color;

/**
* 价格
*/
private Double price;
}
...
/**
* 电脑
*/
@AllArgsConstructor // 提供全参构造器
@NoArgsConstructor // 提供无参构造器
@Data // 提供getter、setter方法
public class Pc implements Goods {
/**
* 颜色
*/
private String color;

/**
* 价格
*/
private Double price;
}

类似地,我们定义一个人People的接口,并分别提供男人Man、女人Woman这两个具体的访问者。可以看到,二者对不同类型的商品的关注点是不同的

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
/**
* 人
*/
public interface People {
/**
* 访问商品
* @param goods
*/
void visit(Goods goods);
}
...
/**
* 男人
*/
public class Man implements People {
@Override
public void visit(Goods goods) {
if( goods instanceof Clothes ) {
Clothes clothes = (Clothes)goods;
System.out.println("男人看衣服关注价格: " + clothes.getPrice());
}else if( goods instanceof Pc ) {
Pc pc = (Pc)goods;
System.out.println("男人看电脑关注外观: " + pc.getColor());
}
}
}
...
/**
* 女人
*/
public class Woman implements People {
@Override
public void visit(Goods goods) {
if( goods instanceof Clothes ) {
Clothes clothes = (Clothes)goods;
System.out.println("女人看衣服关注外观: " + clothes.getColor());
}else if( goods instanceof Pc ) {
Pc pc = (Pc)goods;
System.out.println("女人看电脑关注价格: " + pc.getPrice());
}
}
}

至此,我们就可以写个测试用例来验证下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Demo 1: 不使用 Visitor Pattern 访问者模式
*/
public class Demo1 {
public static void main(String[] args) {
Goods clothes = new Clothes("Green", 22.3);
Goods pc = new Pc("Black", 3333.33);

System.out.println("Demo1: 不使用 Visitor Pattern 访问者模式");
System.out.println("\n-------------- Test 1 --------------");
People man = new Man();
man.visit(clothes);
man.visit(pc);

System.out.println("\n-------------- Test 2 --------------");
People woman = new Woman();
woman.visit(clothes);
woman.visit(pc);
}
}

测试结果如下,符合预期

figure 1.png

Resolution 解析 与 Dispatch 分派

Resolution 解析

Overload重载,这个概念相信大家都很熟悉。即允许一个类中存在多个同名的方法,但这些同名方法的参数类型或参数数量是不同的。而对于这些重载的方法而言,究竟使用哪个版本的方法则是在编译期就可以被确定下来,这个过程就被称之为Resolution(重载)解析。具体地,方法版本的确定则是依据方法的接收者和参数的类型来共同确定的。其中这里的类型均指的是声明类型,而非真实类型。关于这一点也很好理解,因为该过程是在编译期发生的

下面我们通过代码来实际验证下。这里我们先定义了两个具有继承关系的类——Fruit水果、Banana香蕉类

1
2
3
4
5
6
7
8
9
10
11
/**
* 水果
*/
public class Fruit {
}
...
/**
* 香蕉
*/
public class Banana extends Fruit{
}

然后在我们的动物Animal类中定义eat方法,并重载它。实现如下

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 动物
*/
public class Animal {
public void eat(Fruit fruit) {
System.out.println("动物在吃水果");
}

public void eat(Banana banana) {
System.out.println("动物在吃香蕉");
}
}

好,现在让我们来测试下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Overload重载测试
*/
public class OverloadTest {
public static void main(String[] args) {
Animal animal = new Animal();

System.out.println("Overload重载测试");
System.out.println("\n--------------- Test 1 ---------------");
Fruit fruit = new Fruit();
animal.eat(fruit);

System.out.println("\n--------------- Test 2 ---------------");
Fruit banana1 = new Banana();
animal.eat(banana1);

System.out.println("\n--------------- Test 3 ---------------");
Banana banana2 = new Banana();
animal.eat(banana2);
}
}

虽然banana1的声明类型为Fruit,但其真正的实际类型为Banana。从下面Test 2的测试结果我们可以看到,其使用的是eat(Fruit fruit)版本的方法。这也进一步佐证了Resolution(重载)解析是发生在编译期的。至于Test 1、Test 3的测试结果,相信大家应该都能明白,这里就不再赘述了

figure 2.png

Note

  1. 方法的接收者,即所谓方法的调用者。例如在a.method(b) 中,a即为方法的接收者、调用者

Dispatch 分派

所谓Override重写(又被称之为覆盖),是指在子类中提供了一个与父类方法的签名一致方法。具体在运行过程中究竟是调用父类该方法的版本还是调用子类的版本,仅取决于方法接收者的真实类型而非声明类型。这一在运行期选择调用方法版本的过程则被称之为Dispatch分派。聪明的朋友可能已经看出来了,Java正是通过Dispatch分派机制来实现所谓的多态

下面我们通过代码来实际验证下。为此,我们复用上面例子中的代码并提供了一个Animal的子类——狗Dog类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 狗
*/
public class Dog extends Animal {
@Override
public void eat(Fruit fruit) {
System.out.println("狗在吃水果");
}

@Override
public void eat(Banana banana) {
System.out.println("狗在吃香蕉");
}
}

好,现在让我们来测试下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Override 重写(覆盖)测试
*/
public class OverrideTest {
public static void main(String[] args) {
Animal animal = new Animal();
Animal dog = new Dog();

System.out.println("Override 重写(覆盖)测试");
System.out.println("\n--------------- Test 1 ---------------");
Banana banana1 = new Banana();
animal.eat( banana1 );
dog.eat( banana1 );

System.out.println("\n--------------- Test 2 ---------------");
Fruit banana2 = new Banana();
animal.eat( banana2 );
dog.eat( banana2 );
}
}

从下面的测试结果可以看出。一方面,运行期实际调用的方法,仅仅取决于animal、dog的真实类型,而非声明类型Animal;另一方面,我们从Test 2中也可以看出,分派过程与参数的真实类型并无关系。即其只会选择 签名为eat(Fruit fruit) 的方法,而这则是由编译期的重载过程确定的

figure 3.png

单分派、多分派

前面我们提到,方法的分派是在运行期的。而根据分派时依据的不同,具体又可分为:单分派、多分派

  • Single Dispatch 单分派:其仅依据 方法接收者(即方法调用者) 的真实类型来进行分派
  • Multiple Dispatch 多分派:其通过 方法接收者(即方法调用者)的真实类型方法参数的真实类型 来完成分派

显然对于Java而言,其是一门单分派的语言。典型地单分派的语言还有C++;而多分派的语言有:Julia、Common Lisp

Visitor Pattern 访问者模式

在楔子部分中,我们实现了一个男人、女人访问商品的小例子。虽然能用但是有一个弊端。无论是在男人Man还是女人Woman中,对于visit方法而言,它们都需要通过instanceof判断商品的真实类型并结合分支语句(if-else)实现对不同商品的访问逻辑。一旦商品类型过多,会显得visit方法臃肿不够优雅。而造成这一尴尬境地的根据原因就在于Java是单分派的,其无法在运行期通过参数的真实类型来选择不同版本的方法。那么有没有办法呢?答案是有的,通过this即可巧妙地实现两次分发。关于这一点将会在后面对Visitor Pattern访问者模式的实现中体现出来。这里我们先来简单地了解下所谓的访问者模式,其有以下几个角色

  • 抽象元素角色:其定义了一个被访问角色的接口。通常其会定义一个accept方法,用于接受一个访问者来访问该元素。即下面实例代码的商品Goods类
  • 具体元素角色:其是一个具体的被访问角色。即下面实例代码的衣服Clothes、电脑Pc类
  • 抽象访问者角色:其作为访问者角色的公共接口,通过重载的形式来声明若干个访问不同具体元素角色的方法,即visit方法。即下面实例代码的人People类
  • 具体访问者角色:其是对抽象访问者角色的具体实现。其通过重载实现多个visit方法来将对不同元素的访问操作进行分离。即下面实例代码的男人Man、女人Woman类
  • 对象结构角色:该角色可以理解为一个管理若干个元素角色对象的容器。其是可选的,即实际应用中该角色可以是没有的。即下面实例代码的商店Shop类

模式实现

现在让我们通过Visitor Pattern重新实现楔子部分中的例子,来看看该模式是如何实现所谓的两次分发。首先,我们定义一个抽象访问者角色——即People接口。可以看到这里通过重载声明了两个visit方法用于分别访问两种不同类型的商品

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 抽象访问者角色: 人
*/
public interface People {
/**
* 访问商品衣服
* @param clothes
*/
void visit(Clothes clothes);

/**
* 访问商品电脑
* @param pc
*/
void visit(Pc pc);
}

好了,现在让我们继续实现具体访问者角色,即男人Man、女人Woman类

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
/**
* 具体访问者角色: 男人
*/
public class Man implements People{
/**
* 访问商品衣服
* @param clothes
*/
@Override
public void visit(Clothes clothes) {
System.out.println("男人看衣服关注价格: " + clothes.getPrice());
}

/**
* 访问商品电脑
* @param clothes
*/
@Override
public void visit(Pc pc) {
System.out.println("男人看电脑关注外观: " + pc.getColor());
}
}
...
/**
* 具体访问者角色: 女人
*/
public class Woman implements People{
/**
* 访问商品衣服
* @param clothes
*/
@Override
public void visit(Clothes clothes) {
System.out.println("女人看衣服关注颜色: " + clothes.getColor());
}

/**
* 访问商品电脑
* @param clothes
*/
@Override
public void visit(Pc pc) {
System.out.println("女人看电脑关注价格: " + pc.getPrice());
}
}

现在我们来实现我们的被访问者。首先是抽象元素角色——即商品Goods类

1
2
3
4
5
6
7
8
9
10
11
/**
* 抽象元素角色: 商品
*/
public interface Goods {

/**
* 接受一个访问者以对该元素进行访问
* @param people
*/
void accept(People people);
}

然后我们来实现具体元素角色——即两种不同的商品,衣服Clothes、电脑Pc类,代码如下所示。在楔子部分,我们是通过访问者来直接访问商品。比如像man.visit(clothes)这样。而在这里调用关系则反过来了。例如像clothes.accept(man)这样,此时clothes的声明类型即使是商品Goods类型的话,也会通过多态特性来保证调用的是衣服Clothes类中的accept方法。这一点相信大多数朋友都没问题。好了高能时刻来了,在accept方法内部我们可以看到,其是通过回调的方式people.visit(this)来调用某个访问者的visit方法。一方面,方法的接收者people通过多态可以保证调用的是man中的visit方法,另一方面,将this作为参数传入,通过重载可以保证调用的是签名为visit(Clothes clothes)版本的方法

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
/**
* 具体元素角色: 衣服
*/
@AllArgsConstructor // 提供全参构造器
@NoArgsConstructor // 提供无参构造器
@Data // 提供getter、setter方法
public class Clothes implements Goods {
/**
* 颜色
*/
private String color;

/**
* 价格
*/
private Double price;

/**
* 接受一个访问者以对该元素进行访问
* @param people
*/
@Override
public void accept(People people) {
people.visit(this);
}
}
...
/**
* 具体元素角色: 电脑
*/
@AllArgsConstructor // 提供全参构造器
@NoArgsConstructor // 提供无参构造器
@Data // 提供getter、setter方法
public class Pc implements Goods {
/**
* 颜色
*/
private String color;

/**
* 价格
*/
private Double price;

/**
* 接受一个访问者以对该元素进行访问
* @param people
*/
@Override
public void accept(People people) {
people.visit(this);
}
}

这里出于完整性的考虑,也实现了一个对象结构角色——即商店Shop类。用于管理若干个商品对象。这里结合实际需要,提供了一个viewAllGoods方法来让访问者遍历其所管理的所有商品

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
/**
* 对象结构角色(该角色是可选的, 用于管理具体元素对象): 商店
*/
public class Shop {
private List<Goods> list = new LinkedList<>();

/**
* 添加商品
* @param goods
*/
public void add(Goods goods) {
list.add(goods);
}

/**
* 接受一个访问者以对其所管理的元素依次遍历访问
* @param people
*/
public void viewAllGoods(People people) {
for(Goods goods : list ) {
goods.accept(people);
}
}

}

好了,现在让我们来测试下

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
/**
* Demo 2: 使用 Visitor Pattern 访问者模式
*/
public class Demo2 {
public static void main(String[] args) {
Shop shop = new Shop();

Goods clothes = new Clothes("Red", 124.5);
Goods pc = new Pc("White", 9999.99);

shop.add(clothes);
shop.add(pc);

System.out.println("Demo 2: 使用 Visitor Pattern 访问者模式");
System.out.println("\n-------------- Test 1 --------------");
People man = new Man();
clothes.accept(man);
pc.accept(man);

System.out.println("\n-------------- Test 2 --------------");
People woman = new Woman();
clothes.accept(woman);
pc.accept(woman);

System.out.println("\n-------------- Test 3 --------------");
shop.viewAllGoods(man);
}
}

测试结果如下,符合预期

figure 4.png

Note

  1. 之所以可以使用this作为重载方法的参数,是因为this的声明类型在编译期就可被确定。即其所在的类即为其声明的类型

参考文献

  1. Head First 设计模式 弗里曼著
  2. 深入理解Java虚拟机·第2版 周志明著
  3. 深入理解Java虚拟机·第3版 周志明著
0%