访问者模式:
表示一个作用于其对象结构中的各元素的操作,它使你可以在不改变各元素类的前提下定义作用于这些元素的新操作。
分析定义中所提到的关键点。
第一个,作用于某对象结构中的各元素的操作,这里提到对象结构、各元素和操作。那么我们可以这么理解,有这么一个操作,它是作用于一些元素之上的,而这些元素属于某一个对象结构。
第二个关键点在于不改变各元素类的前提下定义新操作 。
访问者模式的基本代码:
Visitor类,为该对象的结构中的ConcreteElement的每一个类声明一个visit()方法
package com.design.visitor;
public interface Visitor {
void visitConcreteElementA(ConcreteElementA A);
void visitConcreteElementB(ConcreteElementB B);
}
ConcreteVisitorA和ConcreteVisitorB具体的访问者,实现每个由Visitor声明的操作,每个操作实现算法的一部分,而该算法片段乃是对应于结构中对象的类
package com.design.visitor;
public class VisitorA implements Visitor {
private String name ;
public VisitorA(String name) {
this.name = name ;
}
@Override
public void visitConcreteElementA(ConcreteElementA A) {
System.out.println(this.getName()+"尝试应用"+A.getName());
}
@Override
public void visitConcreteElementB(ConcreteElementB B) {
System.out.println(this.getName()+"尝试应用"+B.getName());
}
Get,Set方法略
}
package com.design.visitor;
public class VisitorB implements Visitor {
private String name ;
public VisitorB(String name) {
this.name = name ;
}
@Override
public void visitConcreteElementA(ConcreteElementA A) {
System.out.println(this.getName()+"尝试研究"+A.getName());
}
@Override
public void visitConcreteElementB(ConcreteElementB B) {
System.out.println(this.getName()+"尝试研究"+B.getName());
}
Get,Set方法略
}
Element 类,定义一个accept操作,它以一个访问者为参数
package com.design.visitor;
public interface Element {
void accept(Visitor visitor);
}
ConcreateElementA和ConcreteElementB,具体元素,实现accept操作
package com.design.visitor;
public class ConcreteElementA implements Element{
private String name ;
ConcreteElementA(String name){
this.name = name;
}
@Override
public void accept(Visitor visitor) {
visitor.visitConcreteElementA(this);
}
Get,Set方法略
}
package com.design.visitor;
public class ConcreteElementB implements Element{
private String name ;
ConcreteElementB(String name){
this.name = name;
}
@Override
public void accept(Visitor visitor) {
visitor.visitConcreteElementB(this);
}
Get,Set方法略
}
ObjectStructure类,能枚举它的元素,可以提供一个高层的接口以允许访问者访问他的元素
package com.design.visitor;
import java.util.ArrayList;
import java.util.List;
public class ObjectStructure {
private List<Element> list = new ArrayList<Element>();
public void attach(Element element) {
list.add(element);
}
public void detach(Element element) {
list.remove(element);
}
public void accpet(Visitor visitor) {
for (Element element : list) {
element.accept(visitor);
}
}
}
客户端测试:
package com.design.visitor;
public class Client {
public static void main(String[] args) {
ObjectStructure os = new ObjectStructure();
os.attach(new ConcreteElementA("核能"));
os.attach(new ConcreteElementA("光能"));
os.attach(new ConcreteElementB("暗物质"));
os.attach(new ConcreteElementB("黑洞"));
Visitor va = new VisitorA("张三");
Visitor vb = new VisitorB("李四");
os.accpet(va);
os.accpet(vb);
}
}
结果为:
来看一个财务方面的简单例子。财务的账本就可以作为一个对象结构,而它其中的元素有两种,收入和支出,这满足我们访问者模式的要求,即元素的个数是稳定的,因为账本中的元素设定中只有收入和支出。
查看账本的人可能有这样几种,比如老板,会计事务所的注会,财务主管,等等。而这些人在看账本的时候显然目的和行为是不同的。
首先我们给出账本的接口,它只有一个方法accept。
package com.visitor;
//单个单子的接口(相当于Element)
public interface Bill {
void accept(AccountBookViewer viewer);
}
其中的方法参数AccountBookViewer是一个账本访问者接口,这个接口稍后给出,先给出两个具体的元素,也就是收入账本和消费账本,或者说收入和支出类。
package com.visitor;
//消费的单子
public class ConsumeBill implements Bill{
private double amount;
private String item;
public ConsumeBill(double amount, String item) {
super();
this.amount = amount;
this.item = item;
}
public void accept(AccountBookViewer viewer) {
viewer.view(this);
}
public double getAmount() {
return amount;
}
public String getItem() {
return item;
}
}
package com.visitor;
//收入单子
public class IncomeBill implements Bill{
private double amount;
private String item;
public IncomeBill(double amount, String item) {
super();
this.amount = amount;
this.item = item;
}
public void accept(AccountBookViewer viewer) {
viewer.view(this);
}
public double getAmount() {
return amount;
}
public String getItem() {
return item;
}
}
这两个类加入了两个属性,一个是金额,一个是单子的项目,而最关键的还是里面的accept方法,它直接让访问者访问自己,这相当于一次静态分派
下面给出刚才出现过的账本访问者接口,它有两个方法,如下。
package com.visitor;
//账单查看者接口(相当于Visitor)
public interface AccountBookViewer {
//查看消费的单子
void view(ConsumeBill bill);
//查看收入的单子
void view(IncomeBill bill);
}
这两个方法是重载方法,就是在上面的元素类当中用到的,当然你也可以按照访问者模式类图当中的方式去做,将两个方法分别命名为viewConsumeBill和viewIncomeBill,不过无论怎么写,这并不影响访问者模式的使用。
下面我们给出两个访问者的例子,当然访问者可能会有很多,但是作为例子,我们并不需要写太多,这些访问者都需要实现上面的接口,并且提供两个view方法,也就是他们针对消费的单子和收入的单子都分别要做些什么。
package com.visitor;
//老板类,查看账本的类之一
public class Boss implements AccountBookViewer{
private double totalIncome;
private double totalConsume;
//老板只关注一共花了多少钱以及一共收入多少钱,其余并不关心
public void view(ConsumeBill bill) {
totalConsume += bill.getAmount();
}
public void view(IncomeBill bill) {
totalIncome += bill.getAmount();
}
public double getTotalIncome() {
System.out.println("老板查看一共收入多少,数目是:" + totalIncome);
return totalIncome;
}
public double getTotalConsume() {
System.out.println("老板查看一共花费多少,数目是:" + totalConsume);
return totalConsume;
}
}
package com.visitor;
//注册会计师类,查看账本的类之一
public class CPA implements AccountBookViewer{
//注会在看账本时,如果是支出,则如果支出是工资,则需要看应该交的税交了没
public void view(ConsumeBill bill) {
if (bill.getItem().equals("工资")) {
System.out.println("注会查看账本时,如果单子的消费目的是发工资,则注会会查看有没有交个人所得税。");
}
}
//如果是收入,则所有的收入都要交税
public void view(IncomeBill bill) {
System.out.println("注会查看账本时,只要是收入,注会都要查看公司交税了没。");
}
}
可以看到,这两个类有巨大的差异,老板只关心收入和支出的总额,而注会只关注该交税的是否交税,当然现实当中,二者可能关注的不只是这些,甚至完全不是这些,不过作为例子,这并不是我们关注的重点。
下面该出场的是最重要的一个类,账本类,它是当前访问者模式例子中的对象结构,如下。
package com.visitor;
import java.util.ArrayList;
import java.util.List;
//账本类(相当于ObjectStruture)
public class AccountBook {
//单子列表
private List<Bill> billList = new ArrayList<Bill>();
//添加单子
public void addBill(Bill bill){
billList.add(bill);
}
//供账本的查看者查看账本
public void show(AccountBookViewer viewer){
for (Bill bill : billList) {
bill.accept(viewer);
}
}
}
可以看到,我们的账本类当中有一个列表,这个列表是元素(Bill)的集合,这便是对象结构的通常表示,它一般会是一堆元素的集合,不过这个集合不一定是列表,也可能是树,链表等等任何数据结构,甚至是若干个数据结构。
有一些文章当中的例子,对象结构还有remove方法,不过这里由于账本比较特殊,是不能删除的,所以为了在简单的基础上尽量与实际情况贴近,所以就没有加入remove方法。至于show方法,就是账本类的精髓了,它会枚举每一个元素,让访问者访问。
下面我们给出一个简单的客户端,测试一下这个访问者模式
package com.visitor;
public class Client {
public static void main(String[] args) {
AccountBook accountBook = new AccountBook();
//添加两条收入
accountBook.addBill(new IncomeBill(10000, "卖商品"));
accountBook.addBill(new IncomeBill(12000, "卖广告位"));
//添加两条支出
accountBook.addBill(new ConsumeBill(1000, "工资"));
accountBook.addBill(new ConsumeBill(2000, "材料费"));
AccountBookViewer boss = new Boss();
AccountBookViewer cpa = new CPA();
//两个访问者分别访问账本
accountBook.show(cpa);
accountBook.show(boss);
((Boss) boss).getTotalConsume();
((Boss) boss).getTotalIncome();
}
}
可以看到,两个访问者老板和注会对账本的查看,行为是完全不同的,但是这正是访问者模式的意义所在,它其实是将访问者这部分逻辑独立出去,让其自生自灭。我们可以直观的去理解,上面的代码中,账本以及账本中的元素是非常稳定的,这些几乎不可能改变,而最容易改变的就是访问者这部分。
访问者模式最大的优点就是增加访问者非常容易,我们从代码上来看,如果要增加一个访问者,你只需要做一件事即可,那就是写一个类,实现Account-BookViewer接口,然后就可以直接调用AccountBook的show方法去访问账本了。
访问者模式适用于数据结构相对稳定,而算法行为又易变化的系统。
试想一下,如果账本结构不稳定,经常有元素加进来,那么假设有了第三种非支出也非收入的单子,那我们需要做以下两件事。
1)添加一个类ABill,实现Bill接口。
2)在AccountBookViewer接口中添加一个方法view(ABill bill),并且在所有AccountBookViewer接口的实现类中都增加view(ABill bill)方法的具体实现。
这其中第一件事并不难,而且也符合开闭原则,但是第二件事就值得商榷了。它修改了抽象,导致所有细节都跟着变化,这完全破坏了开闭原则。所以第二点说使用访问者模式的前提是数据结构相对稳定也就不奇怪了。
然而对于算法操作,在访问者模式的使用下,我们可以自由的添加,这个在上面已经提及到,也就是说我们如果要增加查看账本的类,是非常简单的,我们只需要写一个类去实现AccountBookViewer接口,这是开闭原则的完美诠释。
访问者模式中,元素的添加会破坏开闭原则,访问者的添加又符合开闭原则,所以有文献称该模式是倾斜的开闭原则,即一边是符合开闭原则的,一边又是破坏了开闭原则的,有点倾斜的感觉。
访问者模式的优点:
1、使得数据结构和作用于结构上的操作解耦,使得操作集合可以独立变化。
2、添加新的操作或者说访问者会非常容易。
3、将对各个元素的一组操作集中在一个访问者类当中。
访问者模式的缺点:
1、增加新的元素会非常困难。
2、实现起来比较复杂,会增加系统的复杂性。
3、破坏封装,如果将访问行为放在各个元素中,则可以不暴露元素的内部结构和状态,但使用访问者模式的时候,为了让访问者能获取到所关心的信息,元素类不得不暴露出一些内部的状态和结构,就像收入和支出类必须提供访问金额和单子的项目的方法一样。
访问者模式的适用性:
1、数据结构稳定,作用于数据结构的操作经常变化的时候。
2、当一个数据结构中,一些元素类需要负责与其不相关的操作的时候,为了将这些操作分离出去,以减少这些元素类的职责时,可以使用访问者模式。
3、有时在对数据结构上的元素进行操作的时候,需要区分具体的类型,这时使用访问者模式可以针对不同的类型,在访问者类中定义不同的操作,从而去除掉类型判断。
先解释一下前面提到的分派,分派按照分派的方式可以分为静态分派和动态分派,按照宗量(自变量)或者说判断依据的多少,可以分为单分派和多分派。
静态分派以及多分派:
静态分派就是按照变量的静态类型进行分派,从而确定方法的执行版本,静态分派在编译时期就可以确定方法的版本。而静态分派最典型的应用就是方法重载,考虑下面一段程序。
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(integer);
main.test(string);
}
}
运行结果会依次打印integer和string,对于test方法,会根据静态类型决定方法版本,而所判断的依据就是,在main类型确定之后,依据test方法的参数类型和参数数量,我们就可以唯一的确定一个重载方法的版本。比如上面的例子,我们确定完main的类型之后,就可以根据test方法是一个参数,并且这个参数是Integer类型还是String类型,就可以确定到底调用哪个重载方法了。
可以看到,在静态分派判断的时候,我们根据多个判断依据(即参数类型和个数)判断出了方法的版本,那么这个就是多分派的概念,因为我们有一个以上的考量标准,也可以称为宗量。所以JAVA是静态多分派的语言。
动态分派以及单分派:
对于动态分派,与静态相反,它不是在编译期确定的方法版本,而是在运行时才能确定。而动态分派最典型的应用就是多态的特性,考虑下面一段程序。
package com.visitor1;
interface Person{
void test();
}
class Man implements Person{
public void test(){
System.out.println("男人");
}
}
class Woman implements Person{
public void test(){
System.out.println("女人");
}
}
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是动态单分派的语言这点毋庸置疑,而访问者模式只是利用了一些手段达到了看似双分派的效果。
动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。
我们来看上面例子当中账本类中的accept方法的调用。
for (Bill bill : billList) {
bill.accept(viewer);
}
上面说了访问者模式是使用两次动态单分派达到了依据两个实际类型在运行时判断一个方法版本的效果,那么对于我们现在的例子来说,就是依据biil和viewer两个实际类型决定了view方法的版本,从而决定了accept方法的动作。请注意是决定accept方法的动作以及决定了view方法的版本。
为什么要强调是accept方法的动作而不是方法的版本,是因为accept方法的版本只需要一次动态分派就可以确定,但是它所产生的动作却需要两次动态分派才能确定。
来看下这个accept方法的调用过程,分步骤解释。
1、当调用accept方法时,根据bill的实际类型决定是调用ConsumeBill还是IncomeBill的accept方法。
2、这时accept方法的版本已经确定,假如是ConsumeBill,它的accept方法是调用下面这行代码。
public void accept(AccountBookViewer viewer) {
viewer.view(this);
}
此时的this是ConsumeBill类型,所以对应于AccountBookViewer接口的view(ConsumeBill bill)方法,此时需要再根据viewer的实际类型确定view方法的版本,如此一来,就完成了动态双分派的过程。
以上的过程就是通过两次动态单分派,第一次对accept方法进行动态分派,第二次对view(类图中的visit方法)方法进行动态分派,从而达到了根据两个实际类型确定一个方法的行为的效果。
而原本我们的做法,通常是传入一个接口,直接使用该接口的方法,此为动态单分派,就像策略模式一样。在访问者模式中,show方法传入的viewer接口并不是直接调用自己的view方法,而是通过bill的实际类型先动态分派一次,然后在分派后确定的方法版本里再进行自己的动态分派。
在此之外,还需要再解释一点,在上面第2步,确定view(ConsumeBill bill)方法是静态分派决定的,所以这个并不在此次动态双分派的范畴内,而且静态分派是在编译期就完成的,也就是说,在上述第1步之前就已经完成了对view(ConsumeBill bill)方法版本的选取。况且把静态分派算在内的话,由于静态分派是多分派,这里就不能叫双分派了,应该叫动态多分派,这显然是不成立的。所以view(ConsumeBill bill)方法的静态分派与访问者模式的动态双分派并没有任何关系。
而且退一步讲,我们完全可以将AccountBookViewer接口中的两个view方法取不同的名字,这样也就完全避免了方法版本确定中静态分派参与的嫌疑,而且这完全不影响访问者模式的效果,可以清楚的看到,标准类图中也是这么建议的。上例中写成一样的名字,只是为了方便和更加清晰的展示访问者模式,而且在只有两个方法的时候这么做也并无不可,在实际应用中,还是强烈建议各位使用不同的方法名称去命名各个元素的访问方法,由于静态分派的重载版本往往不是唯一的,所以重载版本过多会造成一定的干扰。
动态双分派说到底还是动态分派,是在运行时发生的,它与静态分派有着本质上的区别,不可以说一次动态分派加一次静态分派就是动态双分派,而且访问者模式的双分派本身也是另有所指.
访问者模式还有两个优点:
1、使得类层次结构不改变的情况下,可以针对各个层次做出不同的操作,而不影响类层次结构的完整性。
2、可以跨越类层次结构,访问不同层次的元素类,做出相应的操作。
优化一下上面的例子来体现以上两个优点。现在假设我们上面的例子当中再添加一个财务主管,而财务主管不管你是支出还是收入,都要详细的查看你的单子的项目以及金额,简单点说就是财务主管类的两个view方法的代码是一样的。
解决方案就是我们可以将元素提炼出层次结构,针对层次结构提供操作的方法,这样就实现了最后两点优点提到的针对层次定义操作以及跨越层次定义操作。
设计的类图:
针对刚才的实现体系,分别抽象出一个层次,现在我们便可以针对层次定义操作了,左边的CFO(财务主管类)便是针对高层次定义的操作,它会统一作用于AbstractBill。
为了支持层次操作,我们需要在处于最低层次的元素类当中添加一些层次结构的条件判断,不过这个是可以接受的,因为这段代码不会被更改,这是符合开闭原则的。
package com.design.visitor;
//账单的接口,相当于element
public interface Bill {
void accept(Viewer viewer);
}
package com.design.visitor;
//抽象账单类,一个高层次的账单抽象
public abstract class AbstractBill implements Bill{
protected double amount;
protected String item;
public AbstractBill(double amount, String item) {
super();
this.amount = amount;
this.item = item;
}
public double getAmount() {
return amount;
}
public String getItem() {
return item;
}
}
package com.design.visitor;
//收入账单
public class IncomeBill extends AbstractBill{
public IncomeBill(double amount, String item) {
super(amount, item);
}
public void accept(Viewer viewer) {
if (viewer instanceof AbstractViewer) {
((AbstractViewer)viewer).viewIncomeBill(this);
return;
}
viewer.viewAbstractBill(this);
}
}
package com.design.visitor;
//消费账单
public class ConsumeBill extends AbstractBill{
public ConsumeBill(double amount, String item) {
super(amount, item);
}
public void accept(Viewer viewer) {
if (viewer instanceof AbstractViewer) {
((AbstractViewer)viewer).viewConsumeBill(this);
return;
}
viewer.viewAbstractBill(this);
}
}
这是元素类的层次结构,可以看到具体的元素实现accept当中出现了if判断,仔细体会这里的条件判断的意义,它不是在判断一个接口或者类的具体类型,而是在判断一个层次(是访问者的低层次抽象继承还是高层次接口实现),基于这一点,这个判断是稳定的,通俗点说,这段代码是不会被更改的。
下面是访问者的层次:
package com.design.visitor;
//超级访问者接口(它支持定义高层操作)
public interface Viewer{
void viewAbstractBill(AbstractBill bill);
}
package com.design.visitor;
//比Viewer接口低一个层次的访问者接口
public abstract class AbstractViewer implements Viewer{
//查看消费的单子
abstract void viewConsumeBill(ConsumeBill bill);
//查看收入的单子
abstract void viewIncomeBill(IncomeBill bill);
//实现了高等接口的空方法,使用final修饰,低层次继承不能重写,减少了重复代码
public final void viewAbstractBill(AbstractBill bill){}
}
package com.design.visitor;
//老板类,查看账本的类之一,作用于最低层次结构
public class Boss extends AbstractViewer{
private double totalIncome;
private double totalConsume;
//老板只关注一共花了多少钱以及一共收入多少钱,其余并不关心
public void viewConsumeBill(ConsumeBill bill) {
totalConsume += bill.getAmount();
}
public void viewIncomeBill(IncomeBill bill) {
totalIncome += bill.getAmount();
}
public double getTotalIncome() {
System.out.println("老板查看一共收入多少,数目是:" + totalIncome);
return totalIncome;
}
public double getTotalConsume() {
System.out.println("老板查看一共花费多少,数目是:" + totalConsume);
return totalConsume;
}
}
package com.design.visitor;
//注册会计师类,查看账本的类之一,作用于最低层次结构
public class CPA extends AbstractViewer{
//注会在看账本时,如果是支出,则如果支出是工资,则需要看应该交的税交了没
public void viewConsumeBill(ConsumeBill bill) {
if (bill.getItem().equals("工资")) {
System.out.println("注会查看账本时,如果单子的消费目的是发工资,则注会会查看有没有交个人所得税。");
}
}
//如果是收入,则所有的收入都要交税
public void viewIncomeBill(IncomeBill bill) {
System.out.println("注会查看账本时,只要是收入,注会都要查看公司交税了没。");
}
}
package com.design.visitor;
public class CFO implements Viewer {
//财务主管对每一个单子都要核对项目和金额
public void viewAbstractBill(AbstractBill bill) {
System.out.println("财务主管查看账本时,每一个都核对项目和金额,金额是" + bill.getAmount() + ",项目是" + bill.getItem());
}
}
这里要说一下的是,财务主管(CFO)是针对AbstractBill这一层定义的操作,而原来的老板(Boss)和注册会计师(CPA)都是针对ConsumeBill和IncomeBill这一层定义的操作,这时已经产生了跨越层次结构的行为,老板和注册会计师都跨过了抽象账单这一层,直接针对具体的账单定义操作。
账本类:
package com.design.visitor;
import java.util.ArrayList;
import java.util.List;
//账本类(相当于ObjectStruture)
public class AccountBook {
//单子列表
private List<Bill> billList = new ArrayList<Bill>();
//添加单子
public void addBill(Bill bill){
billList.add(bill);
}
//供账本的查看者查看账本
public void show(Viewer viewer){
for (Bill bill : billList) {
bill.accept(viewer);
}
}
}
客户端:
package com.design.visitor;
public class Client {
public static void main(String[] args) {
AccountBook accountBook = new AccountBook();
//添加两条收入
accountBook.addBill(new IncomeBill(10000, "卖商品"));
accountBook.addBill(new IncomeBill(12000, "卖广告位"));
//添加两条支出
accountBook.addBill(new ConsumeBill(1000, "工资"));
accountBook.addBill(new ConsumeBill(2000, "材料费"));
Viewer boss = new Boss();
Viewer cpa = new CPA();
Viewer cfo = new CFO();
//两个访问者分别访问账本
accountBook.show(cpa);
accountBook.show(boss);
accountBook.show(cfo);
((Boss) boss).getTotalConsume();
((Boss) boss).getTotalIncome();
}
}
结果:
我们的注会和老板会针对收入和支出做出不同的操作,而我们财务主管则对所有的单子都是一样的处理方法。
现在,如果再出现和财务主管一样对所有单子都是一样操作的人,我们就不需要复制代码,只需要让他实现Viewer接口就可以,而如果要像老板和注会一样区分单子的具体类型,则继承AbstractViewer就可以。
目前我们的元素类是两层结构,所以我们现在可以针对这两层定义操作,如果元素类的结构是三层、四层或者N层,我们依然可以使用同样的手法达到现在的效果,比如将收入和支出类再写成抽象类,然后支出又分为出差、工资等等,收入又分为现金收入、债务收入等等,随便怎么分都可以,总之就是出现了三层元素类。那么我们就需要将viewer再抽象一层,从而支持三层定义。