设计模式之访问者模式
访问者模式:
访问者模式(Visitor Pattern) 是一种将数据结构与数据操作分离的设计模式。是指封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元索的新的操作。属于行为型模式。
访问者模式被称为最复杂的设计模式,并且使用频率不高,设计模式的作者也评价为:大多情况下,你不需要使用访问者模式,但是一旦需要使用它时,那就真的需要使用了。访问者模式的基本思想是, 针对系统中拥有固定类型数的对象结构(元素) , 在其内提供一个accept()方法用来接受访问者对象的访问。不同的访问者对同一元素的访问内容不同,使得相同的元素集合可以产生不同的数据结果。accept() 方法可以接收不同的访问者对象然后在内部将自己(元素) 转发到接收到的访问者对象的visit() 方法内。访问者内部对应类型的visit() 方法就会得到回调执行, 对元素进行操作。也就是通过两次动态分发(第一次是对访问者的分发accept() 方法,第二次是对元索的分发visit() 方法) , 才最终将一个具体的元素传递到一个具体的访问者。如此一来,就解耦了数据结构与操作,且数据操作不会改变元素状态。访问者模式的核心是,解耦数据结构与数据操作,使得对元索的操作具备优秀的扩展性。可以通过扩展不同的数据操作类型(访问者)实现对相同元索集的不同的操作。
访问者模式的应用场景:
访问者模式在生活场景中也是非常当多的, 例如每年年底的KPI考核, KPI考核标准是相对稳定的, 但是参与KPI考核的员工可能每年都会发生变化, 那么员工就是访问者。我们平时去食堂或者餐厅吃饭,餐厅的菜单和就餐方式是相对稳定的,但是去餐厅就餐的人员是每天都在发生变化的,因此就餐人员就是访问者。
当系统中存在类型数目稳定(固定)的一类数据结构时,可以通过访问者模式方便地实现对该类型所有数据结构的不同操作,而又不会数据产生任何副作用(脏数据)。简言之,就是对集合中的不同类型数据(类型数量稳定)进行多种操作,则使用访问者模式。下面总结一下访问者模式的适用场景:
- 数据结构稳定,作用于数据结构的操作经常变化的场景;
- 需要数据结构与数据操作分离的场景;
- 需要对不同数据类型(元素)进行操作,而不使用分支判断具体类型的场景。
访问者模式主要包含五种角色:
- 抽象访问者(Visitor) :接口或抽象类, 该类提供了对每一个具体元素(Element) 的访问行为visit() 方法, 其参数就是具体的元素(Element) 对象。理论上来说, Visitor的方法个数与元索(Element) 个数是相等的。如果元素(Element) 个数经常变动, 会导致Visitor的方法也要进行变动,此时,该情形并不适用访问者模式;
- 具体访问者(Concrete Visitor) :实现对具体元素的操作;
- 抽象元素(Element) :接口或抽象类, 定义了一个接受访问者访问的方法accept() , 表示所有元索类型都支持被访问者访问;
- 具体元素(Concrete Element) :具体元素类型, 提供接受访问者的具体实现。通常的实现都为:visitor.visit(this) ;
- 结构对象(Object Struture) :该类内部维护了元素集合, 并提供方法接受访问者对该集合所有元素进行操作。
利用访问者模式实现KPI考核的场景:
每到年底,管理层就要开始评定员工一年的工作绩效,员工分为工程师和经理;管理层有CEO和CTO。那么CTO关注工程师的代码量、经理的新产品数量; CEO关注的是工程师的KPI和经理的KPI以及新产品数量。由于CEO和CTO对于不同员工的关注点是不一样的, 这就需要对不同员工类型进行不同的处理。访问者模式此时可以派上用场了。
Employee类定义了员工基本信息及一个accept() 方法, accept() 方法表示接受访问者的访问,由具体的子类来实现。访问者是个接口,传入不同的实现类,可访问不同的数据。
public abstract class Employee {
public String name;
public int kpi; //员工KPI
public Employee(String name) {
this.name = name;
kpi = new Random().nextInt(10);
}
//接收访问者的访问
public abstract void accept(IVisitor visitor);
}
下面看看工程师Engineer类的代码:
public class Engineer extends Employee {
public Engineer(String name) {
super(name);
}
@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}
//考核指标是每年的代码量
public int getCodeLines() {
return new Random().nextInt(10 * 10000);
}
}
经理Manager类的代码:
public class Manager extends Employee {
public Manager(String name) {
super(name);
}
@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}
//考核的是每年新产品研发数量
public int getProducts() {
return new Random().nextInt(10);
}
}
工程师是考核的是代码数量,经理考核的是产品数量,二者的职责不一样。也正是因为有这样的差异性, 才使得访问模式能够在这个场景下发挥作用。Employee、Engineer、Manager。这3个类型就相当于数据结构,这些类型相对稳定,不会发生变化。
然后将这些员工添加到一个业务报表类中, 公司高层可以通过该报表类的show Report() 方法查看所有员工的业绩,具体代码如下:
public class BusinessReport {
private List<Employee> employees = new LinkedList<Employee>();
public BusinessReport() {
employees.add(new Manager("产品经理A"));
employees.add(new Engineer("程序员A"));
employees.add(new Engineer("程序员B"));
employees.add(new Engineer("程序员C"));
employees.add(new Manager("产品经理B"));
employees.add(new Engineer("程序员D"));
}
public void showReport(IVisitor visitor){
for (Employee employee : employees) {
employee.accept(visitor);
}
}
}
下面就是访问者类型的定义,访问声明了两个visit方法,分别针对工程师与经理:
public interface IVisitor {
//访问工程师类型
void visit(Engineer engineer);
//访问经理类型
void visit(Manager manager);
}
首先定义一个 IVisitor接口,该接口有两个visit方法,参数分别是 Engineer、 Manager 。也就是说对于 Engineer和 Manager的访问会调用两个不同的方法,以此达到差异化处理的目的。这两个访问者具体的实现类为 CEOVisitor类和 CTOVisitor类,代码如下:
public class CTOVistitor implements IVisitor {
public void visit(Engineer engineer) {
System.out.println("工程师" + engineer.name + ",代码行数:" + engineer.getCodeLines());
}
public void visit(Manager manager) {
System.out.println("经理:" + manager.name + ",产品数量:" + manager.getProducts());
}
}
//CEO
public class CEOVistitor implements IVisitor {
public void visit(Engineer engineer) {
System.out.println("工程师" + engineer.name + ",KIP:" + engineer.kpi);
}
public void visit(Manager manager) {
System.out.println("经理:" + manager.name + ",KPI:" + manager.kpi + ",产品数量:" + manager.getProducts());
}
}
在CEO的访问者中,CEO关注工程师的KP,经理的KP和新产品数量,通过两个 visit 方法分别进行处理。如果不使用访问者模式,只通过一个visit方法进行处理,那么就需要在这个代码里进行 if 判断。难以拓展和维护,这个visit方法内就会很复杂,使用访问者模式就会提高灵活度,便于拓展。重载的 visit 方法会对元素进行不同的操作,而通过注入不同的访问的具体实现,使得对元素的操作变得更灵活,可扩展性更高,同时也消除”丑陋”的代码。测试:
public class Test {
public static void main(String[] args) {
BusinessReport report = new BusinessReport();
System.out.println("==========CEO看报表===============");
report.showReport(new CEOVistitor());
System.out.println("==========CTO看报表===============");
report.showReport(new CTOVistitor());
}
}
在上述案例中, Employee扮演了 Element角色,而 Engineer和 Manager 就是 ConcreteElement; CEOVisitor和 CTOVisitor都是具体的ⅵsitor对象,而 BusinessReport 就是 ObjectStructure。
访问者模式最大的优点就是增加访问者非常容易,我们从代码中可以访问者,只要新实现一个访问者接口的类,从而达到数据对象与数据操作分离的效果,如果不使用访问者模式,而又不想对不同的元素进行不同的操作,那么必定使用 if-else 和类型转换和类型转换,这使得代码难以升级维护
我们要根据具体情况来评估是否适合使用访问者模式,例如,我们的对象是否足够稳定,是否需要经常定义新的操作,使用访问者模式是否能优化我们的代码,而不是使我们的代码变得更复杂。
从静态分派到动态分派:
变量被声明时的类型叫做变量的静态类型( Static Type),有些人又把静态类型叫做明显类型(Apparent Type)。而变量所引用的对象的真实类型又叫做变量的实际类型,比如:
Listst list =null:
list =new ArrayList()
声明了一个变量List,它的静态类型(也叫明显类型)是List,而它的实际类型是 ArrayList。根据对象的类型而对方法进行的选择,就是分派 (Dispatch)。分派又分为静态分派和动态分派。
静态分派:
静态分派( Static Dispatch)就是按照变量的静态类型进行分派,从而静态分派在编译时期就可以确定方法的版本。而静态分派最典型的应用就这段代码。
public class Main {
public void test(String string){
System.out.println("string" + string);
}
public void test(Integer integer){
System.out.println("integer" + integer);
}
public static void main(String[] args) {
String string = "1";
Integer integer = 1;
Main main = new Main();
main.test(integer);
main.test(string);
}
}
在静态分派判断的时候我们根据多个判断依据(即参数类型和个数)判断出了方法的版本,那么这就是多分派的概念。因为我们有一个以上的考量标准。所以Java是静态多分派的语言。
动态分派:
对于动态分派,与静态相反,它不是在编译期确定的方法版本,而是在运行时才能确定。动态分派最典型的应用就是多态的特性。举个例子,来看下面的这段代码。
public class Main {
public static void main(String[] args) {
Person man = new Man();
Person woman = new WoMan();
man.test();
woman.test();
}
}
这段程序输出结果为依次打印男人和女人,然而这里的 test 方法版本,就无法根据Man和Woman的静态类型去判断了。他们的静态类型都是 Person接口,根本无从判断。
显然,产生的输出结果,就是因为 test 方法的版本是在运行时判断的,这就是动态分派。动态分派判断的方法是在运行时获取到Man和 Woman的实际引用类型,再确定方法的版本,而由于此时判断的依据只是实际引用类型,只有一个判断依据,所以这就是单分派的概念。这时我们的考量标准只有一个,即变量的实际引用类型。相应的,这说明Java是动态单分派的语言。
访问者模式中的伪动态双分派:
通过前面分析, 我们知道Java是静态多分派、动态单分派的语言。Java底层不支持动态的双分派。但是通过使用设计模式, 也可以在Java语言里实现伪动态双分派。在访问者模式中使用的就是伪动态双分派。所谓动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。
还是回到前面的KPI考核业务场景当中, Business Report类中的showReport() 方法:
public void showReport(IVisitor visitor){
for (Employee employee : employees) {
employee.accept(visitor);
}
}
这里就是依据Employee和I Visitor两个实际类型决定了showReport() 方法的执行结果,从而决定了accept() 方法的动作。分析accept() 方法的调用过程:
当调用accept() 方法时, 根据Employee的实际类型决定是调用Engineer还是Manager 的accept() 方法。这时accept() 方法的版本已经确定, 假如是Engineer, 它的accept() 方法是调用这行代码。visitor.visit(this) ;此时的this是Engineer类型, 所以对应的是I Visitor接口的visit(Engineer engineer) 方法, 此时需要再根据访问者的实际类型确定visit() 方法的版本, 如此一来, 就完成了动态双分派的过程。以上的过程就是通过两次动态双分派, 第一次对accept() 方法进行动态分派, 第二次对访问者的visit 方法进行动态分派, 从而达到了根据两个实际类型确定一个方法的行为的效果。
优点:
- 解耦了数据结构与数据操作,使得操作集合可以独立变化;
- 扩展性好:可以通过扩展访问者角色,实现对数据集的不同操作;
- 元素具体类型并非单一,访问者均可操作;
- 各角色职责分离,符合单一职责原则。
缺点:
- 无法增加元素类型:若系统数据结构对象易于变化,经常有新的数据对象增加进来,则访问者类必须增加对应元素类型的操作,连背了开闭原则;