简介
访问者模式(Visitor Pattern)是一种将数据结构与数据操作分离的设计模式。是指封装一些作用于某种数据结构中的各元素的操作,他可以在不用改变数据机构的前提下定义作用于这些元素的新的操作。属于行为型模式。
访问者模式被称为最复杂的设计模式,并且使用率不高,设计模式的作者也评价为:大多数情况下,你不需要使用访问者模式,但是一旦需要使用它时,那就真的需要使用了。访问者模式的基本思想是,针对系统中拥有固定类型数的对象结构(元素),在其内提供了一个accpect()
方法用来接收访问者对象的访问。不同的访问者对同一元素的访问内容不同,使得相同的元素集合可以产生不同的数据结果。accpect()
方法可以接收不同的访问者对象,然后再内部将自己(元素)转发到接收到的访问者对象的visit()方法内。访问者内部对应类型的visit()方法就会得到回调执行,对元素进行操作。也就是通过两次动态分发(第一次是对访问者的分发accpect()
方法,第二次是对元素的分发visit()方法)。才最终将一个具体的元素传递到一个具体的访问者。如此一来,就解耦了数据结构与操作,且数据操作不会改变元素状态。
访问者模式的核心是,解耦数据结构与数据操作,使得对元素的操作具备优秀的扩展性。可以通过扩展不同的数据操作类型(访问者)实现对相同元素集的不同的操作。
访问者模式在生活中的应用场景
1、每年年底的KPI考核(KPI考核标准是相对稳定的,但是参与KPI考核的员工可能每年都发生变化,那么员工就是访问者)
2、餐厅就餐人员
当系统中存在类型数目稳定(固定)的一类数据结构时,可以通过访问者模式方便的实现对该类型所有数据结构的不同操作,而又不会数据产生任何副作用(脏数据)。
简而言之,就是对集合中的不同类型数据(类型数量稳定)进行多种操作,则使用访问者模式。
访问者模式的适用场景:
1、数据结构稳定,作用于数据结构的操作经常变化的场景;
2、需要数据结构与数据操作分离的场景;
3、需要对不同数据类型(元素)进行操作,而不使用分支判断具体类型的场景。
访问者模式的通用UML类图:
从UML类图中,我们可以看到,访问者模式主要包含五种角色:
抽象访问者(Visitor):接口或抽象类,该类提供了对每一个具体元素(Element)的访问行为visit()方法,其参数就是具体的元素(Element)对象。理论上来说,Visitor的方法个数与元素(Element)个数经常变动,会导致Visitor的方法也要进行变动,此时,该情形并不适合用访问者模式;
具体访问者(ConcreteVisitor):实现对具体元素的操作;
抽象元素(Element):接口或抽象类,定义了一个接受访问者访问的方法accpect(),表示所有元素类型都支持被访问者访问;
具体元素(Concrete Element):具体元素类型,提供接受访问者的具体实现。通常的实现都是:visitor.visit(this)
;
结构对象(ObjectStructure):该类内部维护了元素集合,并提供方法接受访问者对该集合所有元素进行操作。
访问者模式最大的优点就是增加访问者非常容易,我们从代码中可以看到,如果要增加一个访问者,只要新实现一个访问者接口的类,从而达到数据对象与数据操作相分离的效果。如果不使用访问者模式,而又不想对不同的元素进行不同的操作,那么必定需要使用if-else和类型转换,这使得代码难以维护。
使用代码是优化我们的代码,而不是使我们的代码变得更加复杂。
从静态分配到动态分派
变量被声明时的类型叫做变量的静态类型(Static Type),有些人又把静态类型叫做明显类型(Apparent Type);而变量所引用的对象的真实类型又叫做变量的实际类型(Actual Type)。比如:
List list = null;
list = new ArrayList();
声明了一个变量list,它的静态类型(也叫做明显类型)是List,而它的实际类型是ArrayList
。根据对象的类型而对方法进行的选择,就是分派(Dispatch)。分派又分为两种,即静态分派和动态分派。
2、静态分派
静态分派(Static Dispatch)就是按照遍历的静态类型进行分派,从而确定方法的执行版本,静态分派在编译时期就可以确定方法的版本。而静态分派最经典的应用就是方法重载。
public class Main{
public void test(String string){
System.out.println("string");
}
public void test(Integer integer){
System.out.println("integer");
}
public static void main(String[] args){
String string = "1";
Integer integer = 1;
Main main = new Main();
main.test(string);
main.test(integer);
}
}
在静态分派判断的时候,我们根据多个判断依据(即参数类型和个数)判断出了方法的版本,那么这个基石多分派的概念,因为我们有一个以上的考量标准。所以JAVA是静态多分派的语言。
2、动态分派
对于动态分派,与静态相反,它不是在编译期确定的方法版本,而是在运行时才能确定。而动态分派最典型的应用就是多态的特性。
public class Main {
public static void main(String[] args) {
Person man = new Man();
Person woman = new Woman();
man.test();
woman.test();
}
}
interface Person{
void test();
}
class Man implements Person{
@Override
public void test() {
System.out.println("男人");
}
}
class Woman implements Person{
@Override
public void test() {
System.out.println("女人");
}
}
显然,产生的输出结果,就是因为test()方法的版本是在运行时判断的,这就是动态分派。
动态分派判断的方法是运行时获取到Man和Woman的实际引用类型,再确定方法的版本,而由于此时判断的依据只是实际引用类型,只有一个判断依据,所以这就是单分派的概念,这时我们的考量标准只有一个,即变量的实际引用类型。相应的,这说明Java就是动态单分派的语言。
访问者模式中的伪动态双分派
通过前面案例,Java是静态多分派、动态单分派的语言。Java底层不支持动态的双分派。但是通过设计模式,也可以在Java语言里实现伪动态双分派。在访问者模式中使用的就是伪动态双分派。所谓动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。
前面的KPI考核业务场景中,BusinessReport
类中的showReport()
方法:
public void showReport(IVisitor visitor){
for(Employee employee : employees){
employee.accpect(visitor);
}
}
这里就是依据Employee
和IVisitor
两个实际类型决定了showReport()
方法的执行结果,从而决定了accpect()
方法的动作。
accpect()方法的调用过程
1、当调用accept()方法时,根据Employee的实际类型决定是调用Engineer还是Manager的accept()方法。
2、这时accept()方法的版本已经确定,假如是Engineer,它的accept()方法是调用下面这行代码。
public void accept(IVisitor visitor){
visitor.visit(this);
}
此时的this是Engineer类型,所以对应的是IVisitor
接口的visit(Engineer engineer)方法,此时需要再根据访问者的实际类型确定visit()方法的版本,这样就完成了动态双分派的过程。
上面的过程就是通过两次动态双分派,第一次对accept()方法进行动态分派,第二次对访问者的visit()方法进行动态分派,从而达到根据两个实际类型确定一个方法的行为的效果。
原本我们的做法,通常传入一个接口,直接使用该接口的方法,此为动态单分派,就像策略模式一样。在这里,showReport()
方法传入的访问者接口并不是直接调用自己的visit()方法,而是通过Employee的实际类型先动态分派一次,然后在分派后确定的方法版本里再进行自己的动态分派。
注意:这里确定accept(IVisitor visitor)方法是静态分派决定的,所以这个并不在此次动态双分派的范畴内,而且静态分派是在编译期就完成的,所以accept(IVisitor visitor)方法的静态分派与访问者模式的动态双分派并没有任何关系。动态双分派说到底还是动态分派,是在运行时发生的,他与静态分派有着本质上的区别,不可以说一次动态分派加一次静态分派就是动态双分派。
访问者模式在源码中的应用
1、JDK的NIO模块的FileVisitor
public enum FileVisitResult {
//表示当前的遍历过程将会继续
CONTINUE,
//表示当前的遍历过程将会停止
TERMINATE,
//表示当前的遍历过程将会继续,但是要忽略当前目录下的所有节点
SKIP_SUBTREE,
//表示当前的遍历过程将会继续,但是要忽略当前文件、目录的兄弟节点
SKIP_SIBLINGS;
}
public interface FileVisitor<T> {
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException;
FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException;
FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException;
FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException;
}
通过它去遍历文件树很方便:
public class SimpleFileVisitor<T> implements FileVisitor<T> {
protected SimpleFileVisitor() {
}
@Override
public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(dir);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(file);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException
{
Objects.requireNonNull(file);
throw exc;
}
@Override
public FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException
{
Objects.requireNonNull(dir);
if (exc != null)
throw exc;
return FileVisitResult.CONTINUE;
}
}
2、Spring IOC中的BeanDefinitionVisitor
类
public class BeanDefinitionVisitor {
@Nullable
private StringValueResolver valueResolver;
public BeanDefinitionVisitor(StringValueResolver valueResolver) {
Assert.notNull(valueResolver, "StringValueResolver must not be null");
this.valueResolver = valueResolver;
}
protected BeanDefinitionVisitor() {
}
public void visitBeanDefinition(BeanDefinition beanDefinition) {
visitParentName(beanDefinition);
visitBeanClassName(beanDefinition);
visitFactoryBeanName(beanDefinition);
visitFactoryMethodName(beanDefinition);
visitScope(beanDefinition);
if (beanDefinition.hasPropertyValues()) {
visitPropertyValues(beanDefinition.getPropertyValues());
}
if (beanDefinition.hasConstructorArgumentValues()) {
ConstructorArgumentValues cas = beanDefinition.getConstructorArgumentValues();
visitIndexedArgumentValues(cas.getIndexedArgumentValues());
visitGenericArgumentValues(cas.getGenericArgumentValues());
}
}
...
}
在visitBeanDefinition()
方法中,分别访问了其他的数据,比如父类的名字、自己的类名、在IOC容器中的名称等信息。
访问者模式的优缺点
优点:
1、解耦了数据结构与数据操作,使得操作集合可以独立变化;
2、扩展性好:可以通过扩展访问者角色,实现对数据集的不同操作;
3、元素具体类型并非单一,访问者均可操作;
4、各角色职责分离,符合单一职责原则。
缺点:
1、无法增加元素类型:若系统数据结构易于变化,经常有新的数据对象增加进来,则访问者类必须增加对应元素类型的操作,违背了开闭原则;
2、具体元素变得更困难:具体元素增加属性,删除属性等操作会导致对应的访问者类需要进行相应的修改,尤其当有大量访问者类时,修改范围太大;
3、违背依赖倒置原则:为了达到“区别对待“,访问者依赖的是具体元素类型,而不是抽象。