从零开始学习Java设计模式 | 行为型模式篇:访问者模式
Posted 李阿昀
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始学习Java设计模式 | 行为型模式篇:访问者模式相关的知识,希望对你有一定的参考价值。
在本讲,我们来学习一下行为型模式里面的第九个设计模式,即访问者模式。
概述
先来看一下访问者模式的概念。
封装一些作用于某种数据结构中的各元素的操作,它可以在不改变这个数据结构的前提下定义作用于这些元素的新的操作。
上述访问者模式的概念看完之后,相信大家都懵了,没关系,下面我给大家解释解释。
访问者模式是说封装一些作用于某种数据结构中的各元素的操作,这句话表示什么含义呢?其实就是说将数据结构和元素的操作进行了一个分离,分离之后,访问者模式就可以在不改变这个数据结构的前提下定义作用于这些元素的新的操作。
可能我这样解释了之后,大家可能还不是特别理解,但没关系,后面我会通过一个案例再来对访问者模式进行一个讲解,相信大家一定能明白访问者模式的概念。
知道了访问者模式的概念之后,接下来,我们就来看看访问者模式的结构,也就是它里面所拥有的角色。
结构
访问者模式包含以下主要角色:
-
抽象访问者(Visitor)角色:定义了对每一个元素(Element)访问的行为,它的参数就是可以访问的元素,它的方法个数理论上来讲与元素类个数(即Element抽象元素角色类的实现类的个数)是一样的,从这点不难看出,访问者模式要求元素类的个数不能改变。
看完抽象访问者角色的概念,哎,你会发现概念中提到了元素这一东东,这不禁让我们想到了集合,因为集合里面存储的就是元素,所以在这儿必定会有一个容器性质的对象,这个留待我们后续再说。
另外,我也不知道大家有没有看懂以上抽象访问者角色的概念,要是没看懂的话,我这里再来重新解释一遍。一般来说,我们会将抽象访问者角色定义成一个接口,然后在该接口中定义对每一个元素访问的方法,当然了,方法的参数就是可以访问的元素,所以有多少个具体元素角色类,那么该接口里面就要提供多少个方法。这也就是说,理论上讲,方法个数应与元素类个数(即Element抽象元素类的实现类的个数)是一样的,从这点不难看出,访问者模式要求元素类的个数不能改变,如果改变了的话,那么你就不能使用访问者模式了。所以,使用访问者模式的要求还是比较苛刻的,相信大家也不难看出访问者模式在实际开发中使用的其实并不是特别多!
-
具体访问者(Concrete Visitor)角色:给出对每一个元素类访问时所产生的具体行为。说到底其实就是对抽象访问者角色里面方法的一个实现。
-
抽象元素(Element)角色:定义了一个接受访问者的方法(即accept),其意义是指,每一个元素都要可以被访问者访问。注意了,该角色只是定义了一个规范,而该规范由谁来实现呢?由具体元素角色类来实现。
-
具体元素(Concrete Element)角色:提供接受访问方法的具体实现,而这个具体的实现,通常情况下是使用访问者提供的访问该元素类的方法。
-
对象结构(Object Structure)角色:定义当中所提到的对象结构(其实指的就是访问者模式概念里面所提到的数据结构),对象结构是一个抽象表述,具体点可以理解为一个具有容器性质或者复合对象特性的类,它会包含一组元素(Element),并且可以迭代这些元素,供访问者访问。
相信大家看完以上访问者模式所包含的角色之后,应该是一脸懵逼的😥,不过这没关系,下面我会通过一个具体的案例来让大家再深入认识一下以上访问者模式的五个角色。
访问者模式案例
接下来,按照惯例我们通过一个案例来让大家再去理解一下访问者模式的概念,以及它里面所包含的角色,而这个案例就是给宠物喂食。
分析
现在养宠物的人特别多,我们就以这个为例,当然宠物还分为狗、猫等,要给宠物喂食的话,不仅主人可以喂,其他人也是可以进行喂食的。
接下来,我们就来分析一下访问者模式里面的各个角色在该案例中分别是由谁来充当的。
- 抽象访问者角色:给宠物喂食的人。
- 具体访问者角色:主人、其他人。
- 抽象元素角色:动物抽象类。
- 具体元素角色:宠物狗、宠物猫。
- 结构对象角色:主人家。
分析完之后,我们就来看一下下面这张类图。
看完以上类图之后,接下来我们就要逐一分析以上类图中所涉及到的类和接口,以及类和类、类和接口之间的关系了。
首先,我们先来看一下以上类图的左侧部分,可以看到上面有一个接口,名称为Animal,很显然,该接口充当的就是访问者模式里面的抽象元素角色。而且,该接口里面还提供了一个accept方法,很明显,该方法就是用来接受访问者访问的。不过大家要注意了,该方法还须接收一个Person类型的参数,而Person就是一个接口,这在类图的右侧部分就能看到,后面我再来讲解该接口啊!
对于Animal接口来说,它有两个子实现类,一个是Dog(即宠物狗类),一个是Cat(即宠物猫类),显然它俩都得实现Animal接口并重写里面的accept抽象方法,注意,它俩充当的是访问者模式里面的具体元素角色。
然后,我们再来看一下以上类图的右侧部分,可以看到上面也有一个接口,名称为Person,很显然,该接口充当的就是访问者模式里面的抽象访问者角色。而且,该接口里面还提供了两个feed方法,之前我在给大家讲述抽象访问者角色时就说过,对于它里面的方法来说,方法的个数以及参数是有一定特点的,方法个数的话,是和抽象元素角色类的子实现类的个数保持一致的,很明显,现在有两个子实现类,所以在Person接口里面我们就得提供两个feed方法;方法参数的话,就是具体元素角色类,即Cat和Dog,从上图中你也能清晰地看到。我之所以给大家解释的这么详细,就是希望大家能记住抽象访问者角色里面方法的以上特点。
对于Person接口来说,它也有两个子实现类,一个是Owner(即主人类),一个是Someone(即其他人类),显然它俩都得实现Person接口并重写里面的feed抽象方法,注意,它俩充当的是访问者模式里面的具体访问者角色。
接着,我们再来看一下Home这个类,很显然,它充当的就是访问者模式里面的对象结构角色。可以看到,该类里面声明了一个List<Animal>
类型的成员变量,也就是说该成员变量就是一个List集合,而且List集合里面存储的元素类型是Animal,所以,大家可以把该类看作是一个容器。
此外,Home这个类里面还定义了两个方法,一个是add方法,它是往上述List集合里面去添加元素的,而且元素是Animal接口类型的;还有一个是action方法,它是来接受访问者访问的,访问谁呢?访问的就是List集合里面的元素。
最后,还有一个客户端类,该类就是用来做测试的,比较简单,所以这里我就不费多的笔墨去讲它了。
至此,以上类图我们就分析完了,接下来我们就要编写代码来实现以上给宠物喂食的案例了。
实现
首先,打开咱们的maven工程,并在com.meimeixia.pattern包下新建一个子包,即visitor,也即实现以上案例的具体代码我们是放在了该包下。
然后,创建抽象访问者接口,这里我们就命名为Person了。
package com.meimeixia.pattern.visitor;
/**
* 抽象访问者角色接口
* @author liayun
* @create 2021-09-18 12:17
*/
public interface Person {
// 喂食宠物猫
void feed(Cat cat);
// 喂食宠物狗
void feed(Dog dog);
}
以上抽象访问者接口创建完毕之后,你会发现具体元素类(即Dog和Cat)还没有创建出来,所以接下来我们来创建抽象元素接口和其对应的具体元素类。
先创建抽象元素接口,这里我们不妨就命名为Animal。
package com.meimeixia.pattern.visitor;
/**
* 抽象元素角色接口
* @author liayun
* @create 2021-09-18 12:21
*/
public interface Animal {
// 接受访问者访问的功能
void accept(Person person);
}
再创建具体元素类,一个是宠物猫类,即Cat。
package com.meimeixia.pattern.visitor;
/**
* 具体元素角色类(宠物猫)
* @author liayun
* @create 2021-09-18 12:24
*/
public class Cat implements Animal {
@Override
public void accept(Person person) {
/*
* 这儿是宠物猫接受具体访问者进行访问,访问是怎么访问呢?是不是就是喂食呀?
* 而调用feed方法进行喂食的话,就得把当前对象(即this)作为参数进行一个传
* 递,这表示的就是访问者给宠物猫进行喂食!
*/
person.feed(this);
System.out.println("好好吃,喵喵喵...");
}
}
还有一个是宠物狗类,即Dog。
package com.meimeixia.pattern.visitor;
/**
* 具体元素角色类(宠物狗)
* @author liayun
* @create 2021-09-18 12:24
*/
public class Dog implements Animal {
@Override
public void accept(Person person) {
/*
* 这儿是宠物狗接受具体访问者进行访问,访问是怎么访问呢?是不是就是喂食呀?
* 而调用feed方法进行喂食的话,就得把当前对象(即this)作为参数进行一个传
* 递,这表示的就是访问者给宠物狗进行喂食!
*/
person.feed(this);
System.out.println("好好吃,汪汪汪...");
}
}
可以看到,以上两个类都实现了Animal接口并重写了它里面的accept方法,值得注意的是,我们并没有在这两个类里面定义其他的方法,当然,在实际开发中可能就不会这么简单了,或许还有一些别的需求,让我们不得不再去定义一些其他方法。
接着,创建不同的具体访问者类,一个是主人类,即Owner。
package com.meimeixia.pattern.visitor;
/**
* 具体访问者角色类(主人自己)
* @author liayun
* @create 2021-09-18 12:34
*/
public class Owner implements Person {
@Override
public void feed(Cat cat) {
System.out.println("主人喂食猫");
}
@Override
public void feed(Dog dog) {
System.out.println("主人喂食狗");
}
}
还有一个是其他人类,即Someone。
package com.meimeixia.pattern.visitor;
/**
* 具体访问者角色类(其他人)
* @author liayun
* @create 2021-09-18 12:34
*/
public class Someone implements Person {
@Override
public void feed(Cat cat) {
System.out.println("其他人喂食猫");
}
@Override
public void feed(Dog dog) {
System.out.println("其他人喂食狗");
}
}
紧接着,创建对象结构类,在此案例中就是主人的家,所以这里我们不妨就命名为Home。
package com.meimeixia.pattern.visitor;
import java.util.ArrayList;
import java.util.List;
/**
* 对象结构类
* @author liayun
* @create 2021-09-18 12:47
*/
public class Home {
// 声明一个集合对象,用来存储元素对象
private List<Animal> nodeList = new ArrayList<Animal>();
// 添加元素功能
public void add(Animal animal) {
nodeList.add(animal);
}
public void action(Person person) {
// 遍历集合,获取每一个元素,并让访问者访问每一个元素
for (Animal animal : nodeList) {
animal.accept(person);
}
}
}
最后,创建一个客户端类用于测试。
package com.meimeixia.pattern.visitor;
/**
* @author liayun
* @create 2021-09-18 12:59
*/
public class Client {
public static void main(String[] args) {
// 创建Home对象
Home home = new Home();
// 添加元素到Home对象中,也就是给家里边买几个宠物
home.add(new Dog());
home.add(new Cat());
// 创建主人对象
Owner owner = new Owner();
// 让主人喂食所有的宠物
home.action(owner);
}
}
此时,运行以上客户端类的代码,打印结果如下图所示,可以看到主人喂食了家里面的所有宠物,而这确实是我们所想要的结果。
现在我们开动脑筋来想一下,假如我们现在要去逗宠物玩,那么应该如何去做呢?大家注意了啊,在Person接口里面,我们是统一将方法的名称定义成了feed,大家一看便知这是喂食的意思,其实,我们可以将方法的名称定义得更模糊一些,这样,我们在创建该Person接口的子实现类,实现它里面的方法时,就可以不再是喂食宠物,而是逗宠物玩了。我话讲完,大家现在该知道如何去做了吧!
访问者模式的优缺点以及使用场景
接下来,我们来看一下访问者模式的优缺点以及使用场景。
优缺点
优点
关于访问者模式的优点,这里我总结了三点。
-
扩展性好。
在不修改对象结构中的元素的情况下,可以为对象结构中的元素添加新的功能。
以上述案例来说,如果后期我们想在对对象结构中的元素进行访问时执行其他的操作,例如将上述案例中的喂食宠物给改成遛宠物,那么我们只需要再去定义一个Person接口的子实现类即可。当然了,上述案例中Person接口里面的方法命名并不是特别规范,因为通过方法的名字(即feed)我们就明白了它就是喂食的意思,所以,Person接口里面的方法我们可以命名得更通用一些!
-
复用性好。
通过访问者来定义整个对象结构通用的功能,从而提高复用程度。
还是看一下上述案例,Home类里面是不是提供了一个action方法啊,该方法就可以对集合里面的元素进行一些通用的操作,而且我们还是把这些通用的操作全部都封装到了具体访问者类里面,就是喂食宠物嘛!这样,就大大减少了Home类里面代码的一个书写。
-
分离无关行为。
通过访问者来分离无关的行为,把相关的行为封装在一起,构成一个访问者,这样每一个访问者的功能都比较单一。例如,我现在既可以创建一个子类,让该子类来喂食宠物,也可以再创建一个子类,让该子类来遛宠物。
缺点
关于访问者模式的缺点,这里我总结了两点。
-
对象结构变化很困难。
在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者类中增加相应的具体操作,这违背了"开闭原则"。
上面这句话啥意思啊?还是拿上述案例来说,假设现在我们又创建了一个Animal接口的子实现类,那么我们是不是又得在抽象访问者接口中再定义一个方法啊!并且该方法是用来操作新创建的具体元素类的对象的。这还不够,对于具体访问者类而言,它们还得实现新定义的方法。现在,你明白了对象结构变化很困难这句话的意思了吧!
-
违反了依赖倒置原则。
访问者模式依赖了具体类,而没有依赖抽象类。
还是回到我们上述案例来看一下,对于具体访问者类来说,它们的方法都是依赖的是具体而不是抽象,于是,这就违反了依赖倒转原则。
使用场景
只要出现如下几个场景,我们就可以去考虑一下能不能使用访问者模式了。
- 对象结构相对稳定,但其操作算法经常变化的程序。
- 对象结构中的对象需要提供多种不同且不相关的操作,而且要避免让这些操作的变化影响对象的结构。
扩展
接下来,我们来对访问者模式进行一个扩展,扩展一个叫双分派的技术,因为在访问者模式里面就用到了。
分派
既然要扩展双分派的技术,那么我们就得先知道什么是分派。下面,我们来看一看它的定义。
变量被声明时的类型叫做变量的静态类型,有些人又把静态类型叫做明显类型;而变量所引用的对象的真实类型又叫做变量的实际类型。例如,从下面这行代码中我们就能知道,map变量的静态类型是Map
,实际类型是HashMap
。
Map map = new HashMap();
根据对象的类型而对方法进行的选择(这时我们就要进行选择了,选择执行的是静态类型中的方法,还是实际类型中的方法呢?),就是分派(Dispatch),分派(Dispatch)又分为两种,即静态分派和动态分派。
接下来,我们来看一下什么是静态分派,什么又是动态分派。
静态分派(Static Dispatch) 发生在编译时期,分派根据静态类型信息发生。静态分派对于我们来说并不陌生,方法重载就是静态分派。
动态分派(Dynamic Dispatch) 发生在运行时期,动态分派动态地置换掉某个方法。Java可以通过方法的重写来支持动态分派。
下面我们通过具体的代码来分别演示一下静态分派和动态分派。
动态分派
在Java中,我们可以通过方法的重写来支持动态分派。
这里我们先创建一个Animal类,该类比较简单,里面只有一个execute方法,如下所示。
package com.meimeixia.pattern.visitor.demo1;
/**
* @author liayun
* @create 2021-10-07 10:22
*/
public class Animal {
public void execute() {
System.out.println("Animal");
}
}
然后,创建Animal类的两个子类,一个是Dog类,注意,该类重写了父类中的execute方法。
package com.meimeixia.pattern.visitor.demo1;
/**
* @author liayun
* @create 2021-10-07 10:22
*/
public class Dog extends Animal {
@Override
public void execute() {
System.out.println("Dog");
}
}
一个是Cat类,注意,该类也重写了父类中的execute方法。
package com.meimeixia.pattern.visitor.demo1;
/**
* @author liayun
* @create 2021-10-07 10:22
*/
public class Cat extends Animal {
@Override
public void execute() {
System.out.println("Cat");
}
}
最后,我们来创建一个测试类,如下所示。
package com.meimeixia.pattern.visitor.demo1;
/**
* @author liayun
* @create 2021-10-07 10:23
*/
public class Client {
public static void main(String[] args) {
Animal a = new Dog();
// 由于a变量的实际类型是Dog,所以调用其execute方法时,执行的是子类(即Dog)里面的execute方法
a.execute();
Animal a1 = new Cat();
// 由于a1变量的实际类型是Cat,所以调用其execute方法时,执行的是子类(即Cat)里面的execute方法
a1.execute();
}
}
上面代码的结果大家应该可以直接说出来,这不就是多态吗?遵循编译看左边,运行看右边的原则,可知最终执行的是子类中的方法。
这里我给大家总结一下:Java编译器在编译时期并不总是知道哪些代码会被执行,因为编译器仅仅知道对象的静态类型,而不知道对象的真实类型(或者实际类型);而方法的调用则是根据对象的真实类型(或者实际类型),而不是静态类型。
静态分派
在Java中,我们可以通过方法重载来支持静态分派。
同上,这里我们先创建一个Animal类,该类里面啥都没有,如下所示。
package com.meimeixia.pattern.visitor.demo2;
/**
* @author liayun
* @create 2021-10-07 10:56
*/
public class Animal {
}
然后,创建Animal类的两个子类,一个是Dog类,注意,该类里面没有具体的实现。
package com.meimeixia.pattern.visitor.demo2;
/**
* @author liayun
* @create 2021-10-07 10:57
*/
public class Dog extends Animal {
}
一个是Cat类,注意,该类里面也没有具体的实现。
package com.meimeixia.pattern.visitor.demo2;
/**
* @author liayun
* @create 2021-10-07 10:58
*/
public class Cat extends Animal {
}
接着,创建一个Execute类,该类里面有三个重载的execute方法,如下所示。
package com.meimeixia.pattern.visitor.demo2;
/**
* @author liayun
* @create 2021-10-07 10:59
*/
public class Execute {
public void execute(Animal a) {
System.out.println("Animal");
}
public void execute(Dog d) {
System.out.println("Dog");
}
public void execute(Cat c) {
System.out.println("Cat");
}
}
最后,我们来创建一个测试类,如下所示。
package com.meimeixia.pattern.visitor.demo2;
/**
* @author liayun
* @create 2021-10-07 11:03
*/
public class Client {
public static void main(String[] args) {
Animal a = new Animal();
Animal a1 = new Dog();
Animal a2 = new Cat();
Execute exe = new Execute();
exe.execute(a);
exe.execute(a1);
exe.execute(a2);
}
}
此时,运行以上测试类的代码,打印结果如下图所示,可以看到打印的都是Animal。
这个结果可能会出乎一些人的意料,为什么会这样呢?因为重载方法的分派是根据静态类型进行的,这个分派过程在编译时期就完成了。
知道静态分派和动态分派之后,接下来我们就得看一看双分派了,这个才是我们要讲的重点。
双分派
所谓双分派技术就是在选择一个方法的时候,不仅仅要根据消息接收者(receiver)的运行时区别,还要根据参数的运行时区别。
相信大家看完上面这句话之后,都懵了,这说的啥意思啊?没关系,下面我会通过一个例子来向大家解释一番。
同上,这里我们先创建一个Animal类,该类里面只有一个accept方法,而且该方法需要一个Execute类型的参数,至于Execute类,后面你就会看到。
package com.meimeixia.pattern.visitor.demo3;
/**
* @author liayun
* @create 2021-10-07 11:22
*/
public class Animal {
public void accept(Execute exe) {
exe.execute(this);
}
}
然后,创建Animal类的两个子类,一个是Dog类,注意,虽然该类重写了父类中的accept方法,但是它和父类中的accept方法是一模一样的。
package com.meimeixia.pattern.visitor.demo3;
/**
* @author liayun
* @create 2021-10-07 11:23
*/
public class Dog extends Animal {
@Override
public void accept(Execute exe) {
exe.execute(this);
}
}
一个是Cat类,同理,虽然该类也重写了父类中的accept方法,但是它和父类中的accept方法也是一模一样的。
package com.meimeixia.pattern.visitor.demo3;
/**
* @author liayun
* @create 2021-10-07 11:23
*/
public class Cat extends Animal {
@Override
public void accept(Execute exe) {
exe.execute(this);
}
}
其实,Dog和Cat这俩子类中的accept方法我们可以不写,不写的话,就是重用父类中的accept方法了,效果都是一样的。当然,我们在这里都给写出来了,因为这样的话,大家更好理解一些。
接着,创建Execute类,该类里面有三个重载的execute方法,如下所示。
package com.meimeixia.pattern.visitor.demo3;
/**
* @author liayun
* @create 2021-10-07 11:24
*/
public class Execute {
public void execute(Animal a) {
System.out.println("Animal");
}
public void execute(Dog d) {
System.out.println("Dog");
}
public void execute(Cat c) {
System.out.println("Cat");
}
}
最后,我们来创建一个测试类,如下所示。
package com.meimeixia.pattern.visitor.demo3;
/**
* @author liayun
* @create 2021-10-07 11:50
*/
public class Client {
public static void main(String[] args) {
Animal a = new Animal();
Animal d = new Dog();
Animal c = new Cat();
Execute exe = new Execute();
a.accept(exe);
d.accept(exe);
c.accept(exe);
}
}
现在就问大家,如果运行以上测试类的代码,那么打印结果会是什么?打印的会全部是Animal吗?因为我们刚才在讲静态分派的时候,你会发现打印的全部是Animal。
在上面代码中,客户端将Execute对象作为参数传递给了Animal类型的变量调用的方法,这里完成了第一次分派,由于这里是方法重写,所以是动态分派,也就是执行实际类型中的方法,拿c变量来举例,c.accept(exe)
是把Execute对象作为参数传递给了accept方法,那么此时程序执行的便是Cat类里面的accept方法。
同时,也将自己this
作为参数传递了进去,这里就完成了第二次分派。什么意思呢?还是拿c变量来说,c.accept(exe)
本质上执行的还是Cat类里面的accept方法,而Cat类里面的accept方法是像下面这
以上是关于从零开始学习Java设计模式 | 行为型模式篇:访问者模式的主要内容,如果未能解决你的问题,请参考以下文章