(二十二)访问者模式详解(伪动态双分派)

2年前 (2022) 程序员胖胖胖虎阿
285 0 0

              作者:zuoxiaolong8810(左潇龙),转载请注明出处,特别说明:本博文来自博主原博客,为保证新博客中博文的完整性,特复制到此留存,如需转载请注明新博客地址即可。

              本次LZ和各位分享一下访问者模式,从场景、设计初衷以及实现方面来说,访问者模式算是LZ即将写到的24种设计模式当中,最复杂也是最难理解的一个设计模式。

              针对这样一个设计模式,LZ到底该如何和各位分享呢?

              

              废话不多说,我们先来看看访问者模式的定义。

              定义(源于GoF《Design Pattern》):表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素类的前提下定义作用于这些元素的新操作。

              初次接触,定义会显得晦涩并且难于理解,没关系,LZ来陪着各位一起一点一点分析定义中所提到的关键点。

              先来看第一句话,说是一个作用于某对象结构中的各元素的操作,这里提到了三个事物,一个是对象结构,一个是各元素,一个是操作。那么我们可以这么理解,有这么一个操作,它是作用于一些元素之上的,而这些元素属于某一个对象结构。

              好了,最关键的第二句来了,它说使用了访问者模式之后,可以让我们在不改变各元素类的前提下定义作用于这些元素的新操作。这里面的关键点在于前半句,即不改变各元素类的前提下,在这个前提下定义新操作是访问者模式精髓中的精髓。

              下面我们来看看访问者模式的类图,去寻找一下访问者模式的角色都有哪些。

(二十二)访问者模式详解(伪动态双分派)


                  我们从类图中可以找到五类角色,两个接口,两种实现类以及一个对象结构。


                  Visitor接口:它定义了对每一个元素(Element)访问的行为,它的参数就是可以访问的元素,它的方法个数理论上来讲与元素个数(Element的实现类个数)是一样的,从这点不难看出,访问者模式要求元素类的个数不能改变(不能改变的意思是说,如果元素类的个数经常改变,则说明不适合使用访问者模式)。


                  ConcreteVisitor:具体的访问者,它需要给出对每一个元素类访问时所产生的具体行为。


                  Element接口:元素接口,它定义了一个接受访问者(accept)的方法,其意义是指,每一个元素都要可以被访问者访问。


                  ConcreteElement:具体的元素类,它提供接受访问方法的具体实现,而这个具体的实现,通常情况下是使用访问者提供的访问该元素类的方法。


                  ObjectStructure:这个便是定义当中所提到的对象结构,对象结构是一个抽象表述,具体点可以理解为一个具有容器性质或者复合对象特性的类,它会含有一组元素(Element),并且可以迭代这些元素,供访问者访问。


                  


                  在上面五个角色当中,最重要的就是最后一个,所谓的访问者模式,就是为了让访问者可以方便的访问对象结构而存在的。关于访问者模式的例子,很多文章和文献使用男人和女人的例子,所以LZ这里就不重复了。


                  我们来看一个财务方面的简单例子,我们都知道财务都是有账本的,这个账本就可以作为一个对象结构,而它其中的元素有两种,收入和支出,这满足我们访问者模式的要求,即元素的个数是稳定的,因为账本中的元素只能是收入和支出,就像人只有男人和女人一样(尽管现在貌似经常出现第三种,具体第三种是什么请大家自行补脑)。


                  账本的访问者都会有哪些呢?


                  LZ不才,对财务只有一个直观的理解,不过这已经足够我们来做例子了,依LZ的了解,查看账本的人可能有这样几种,比如老板,会计事务所的注会,财务主管,等等。而这些人在看账本的时候显然目的和行为是不同的。


                  我们接下来就试着写一下这个例子,首先我们给出单子的接口,它只有一个方法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;
    }

}

                这两个类没什么特别的,为了使得例子更加形象贴近实际,LZ加入了两个属性,一个是金额,一个是单子的项目,而最关键的还是里面的accept方法,它直接让访问者访问自己,这相当于一次静态分派,当然我们也可以不使用重载而直接给方法不同的名称,这些有关分派的知识LZ稍后介绍。

                下面我们给出刚才出现过的账本访问者接口,它有两个方法,如下。

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方法,不过这里由于账本比较特殊,是不能删除的,所以为了在简单的基础上尽量与实际情况贴近,LZ就没有加入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();
    }
}

(二十二)访问者模式详解(伪动态双分派)

 

                 可以看到,两个访问者老板和注会对账本的查看,行为是完全不同的,但是这正是访问者模式的意义所在,它其实是将访问者这部分逻辑独立出去,让其自生自灭。我们可以直观的去理解,上面的代码中,账本以及账本中的元素是非常稳定的,这些几乎不可能改变,而最容易改变的就是访问者这部分。

                 访问者模式最大的优点就是增加访问者非常容易,我们从代码上来看,如果要增加一个访问者,你只需要做一件事即可,那就是写一个类,实现AccountBookViewer接口,然后就可以直接调用AccountBook的show方法去访问账本了。

                 很简单吧!

                 如果没使用访问者模式,各位可以自己写一下,你一定会在show方法中写一堆if else,就算不是在show方法,你也一定逃不掉这些if else,它们一定会出现。而且每增加一个访问者,你都需要改你的if else,代码会显得非常臃肿,而且非常难以扩展和维护。这里由于本章内容较多,限于篇幅,LZ就不给各位写了。

                 在这里,LZ给出上面的例子的类图供各位参考,类图与标准的类图一模一样。

(二十二)访问者模式详解(伪动态双分派)


                 


                 下面LZ结合上面的例子,给各位分析一下百度百科中对访问者模式的一些描述。


                 访问者模式的几个特点:


                 1、访问者模式把数据结构和作用于结构上的操作解耦合,使得操作集合可相对自由地演化。


                 2、访问者模式适用于数据结构相对稳定算法又易变化的系统。因为访问者模式使得算法操作增加变得容易。若系统数据结构对象易于变化,经常有新的数据对象增加进来,则不适合使用访问者模式。


                 3、访问者模式的优点是增加操作很容易,因为增加操作意味着增加新的访问者。访问者模式将有关行为集中到一个访问者对象中,其改变不影响系统数据结构。其缺点就是增加新的数据结构很困难。


                 第一点,数据结构和作用于结构上的操作解耦合,使操作集合可以相对自由的演化,这在上面的例子当中指的是,我们把账本以及账本的元素与查看账本的人解耦,使得这些访问者的行为可以相对独立的变化,这点其实并不难理解。这一点其实说的是访问者模式的优点。


                 至于剩下的两点,开始提到访问者模式适用于数据结构相对稳定,而算法行为又易变化的系统,这点不难理解,试想一下,如果账本结构不稳定,经常有元素加进来,那么假设有了第三种非支出也非收入的单子,那我们需要做以下两件事。


                 1)添加一个类ABill,实现Bill接口。


                 2)在AccountBookViewer接口中添加一个方法view(ABill bill),并且在所有AccountBookViewer接口的实现类中都增加view(ABill bill)方法的具体实现。


                 这其中第一件事并不难,而且也符合开闭原则,但是第二件事就值得商榷了。它修改了抽象,导致所有细节都跟着变化,这完全破坏了开闭原则。所以第二点说使用访问者模式的前提是数据结构相对稳定也就不奇怪了。


                 然而对于算法操作,在访问者模式的使用下,我们可以自由的添加,这个在上面已经提及到,也就是说我们如果要增加查看账本的类,是非常简单的,我们只需要写一个类去实现AccountBookViewer接口,这是开闭原则的完美诠释。


                 访问者模式中,元素的添加会破坏开闭原则,访问者的添加又符合开闭原则,所以有文献称该模式是倾斜的开闭原则,即一边是符合开闭原则的,一边又是破坏了开闭原则的,有点倾斜的感觉。


                 


                 从上面的描述,我们可以大致总结出访问者模式的优缺点以及适用性。


                 优点:


                 1、使得数据结构和作用于结构上的操作解耦,使得操作集合可以独立变化。


                 2、添加新的操作或者说访问者会非常容易。


                 3、将对各个元素的一组操作集中在一个访问者类当中。


                 4、使得类层次结构不改变的情况下,可以针对各个层次做出不同的操作,而不影响类层次结构的完整性。


                 5、可以跨越类层次结构,访问不同层次的元素类,做出相应的操作。


                 缺点:


                 1、增加新的元素会非常困难。


                 2、实现起来比较复杂,会增加系统的复杂性。


                 3、破坏封装,如果将访问行为放在各个元素中,则可以不暴露元素的内部结构和状态,但使用访问者模式的时候,为了让访问者能获取到所关心的信息,元素类不得不暴露出一些内部的状态和结构,就像收入和支出类必须提供访问金额和单子的项目的方法一样。
                 适用性:


                 1、数据结构稳定,作用于数据结构的操作经常变化的时候。


                 2、当一个数据结构中,一些元素类需要负责与其不相关的操作的时候,为了将这些操作分离出去,以减少这些元素类的职责时,可以使用访问者模式。


                 3、有时在对数据结构上的元素进行操作的时候,需要区分具体的类型,这时使用访问者模式可以针对不同的类型,在访问者类中定义不同的操作,从而去除掉类型判断。


                 


                 上面优点当中的前三点比较好理解,缺点和适用性当中提到的三点也比较好理解,如果优点当中的后两点不理解也没关系,LZ会在后面给出简单的例子。


                 在给出后两点优点的例子之前,不得不先解释一下前面提到的分派,分派按照分派的方式可以分为静态分派和动态分派,按照宗量或者说判断依据的多少,可以分为单分派和多分派,下面LZ简单的介绍下这几种分派方式。


                 


                 静态分派以及多分派:


                 静态分派就是按照变量的静态类型进行分派,从而确定方法的执行版本,静态分派在编译时期就可以确定方法的版本。而静态分派最典型的应用就是方法重载,考虑下面一段程序。

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);
    }
}

                相信运行结果不需要LZ给出了,会依次打印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是动态单分派的语言。

                

                访问者模式中的伪动态双分派:

                上面LZ已经简单的介绍了JAVA语言中的静态多分派和动态单分派,更详细的解释各位可以在其它文献和文章中寻找,这里我们谈谈访问者模式的分派方式。


                标题上已经注明了,访问者模式中使用的是伪动态双分派,之所以加了一个“伪”字,是因为一个模式当然不可能更改语言的特性,所以JAVA是动态单分派的语言这点毋庸置疑,而访问者模式只是利用了一些手段达到了看似双分派的效果。


                对于动态双分派这个词语,初次接触的读友或许比较迷惑,LZ先给大家一个初步的解释,动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。


                我们来看上面例子当中账本类中的accept方法的调用。

for (Bill bill : billList) {
            bill.accept(viewer);
        }

                上面说了访问者模式是使用两次动态单分派达到了依据两个实际类型在运行时判断一个方法版本的效果,那么对于我们现在的例子来说,就是依据biil和viewer两个实际类型决定了view方法的版本,从而决定了accept方法的动作。请注意LZ的用词,是决定accept方法的动作以及决定了view方法的版本。

                为什么要强调是accept方法的动作而不是方法的版本,是因为accept方法的版本只需要一次动态分派就可以确定,但是它所产生的动作却需要两次动态分派才能确定。

                我们来看下这个accept方法的调用过程,LZ还是分步骤给各位解释。

                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方法取不同的名字,这样也就完全避免了方法版本确定中静态分派参与的嫌疑,而且这完全不影响访问者模式的效果,可以清楚的看到,标准类图中也是这么建议的。这里LZ写成一样的名字,只是为了方便和更加清晰,而且在只有两个方法的时候这么做也并无不可,但是如果方法多的时候,强烈建议不要取一样的名字,由于静态分派的重载版本往往不是唯一的,所以重载版本过多会造成一定的干扰。


                LZ的例子只是为了更加清晰的展示访问者模式,在实际应用中,还是强烈建议各位使用不同的方法名称去命名各个元素的访问方法。


                在这里LZ解释静态分派不参与双分派的原因,是因为看到不少文章都多多少少在暗示访问者模式的双分派与view(this)的静态分派相关,这个观点恕LZ不能苟同,动态双分派说到底还是动态分派,是在运行时发生的,它与静态分派有着本质上的区别,不可以说一次动态分派加一次静态分派就是动态双分派,而且访问者模式的双分派本身也是另有所指。


                你可能会说,this的类型是动态确定的,这么算下来不也是根据AccountBookViewer的实际类型和Bill的实际类型做了一次动态双分派吗?


                答案是当然不能这么算,this的类型可不是动态确定的,你写在哪个类当中,它的静态类型就是哪个类,这是在编译期就确定的,不确定的是它的实际类型,请各位区分开这一点。


                如果各位只是为了暂时理解访问者模式的双分派,这样理解倒也不是不可,以后随着理解的加深,会渐渐更加清晰。不过如果你这么理解访问者模式的双分派,一定要搞清楚一点,那就是this的类型是在编译期就可以确定的,而不是在运行时动态确定的,它并不是真正的动态绑定,而且说到底它始终是一个参数类型,参与分派的是它的静态类型,依旧是静态分派的范畴。总之怎么理解是自己的事,LZ只是试图点破一下这里面的分派关系。


                 上面的分析算是一个铺垫,至于后面对于静态分派的分析,各位可以当做一个讨论,也可以在文章下方发表评论参与进来,LZ不胜欢迎。


                 接下来我们还有最后一件事没完成了,那就是前面提到的优点的后两点的体现,因为在前面的例子当中没有用到,所以可能会理解起来会有点困难,这里LZ把上面的例子优化一下,让各位体验一下优点中的后两点是多么强大。


                 我们先来考虑一个问题,假设我们上面的例子当中再添加一个财务主管,而财务主管不管你是支出还是收入,都要详细的查看你的单子的项目以及金额,简单点说就是财务主管类的两个view方法的代码是一样的。


                 你可能会说,这好办啊,我们可以复制一下嘛。是的,这当然是一个办法,不过相信对代码有要求的程序猿们一定不喜欢他们的代码里出现一堆的重复代码。而且假设有很多人的访问方式和财务主管一样,对收入和支出的操作一样,那得复制多少代码。


                 解决方案就是我们可以将元素提炼出层次结构,针对层次结构提供操作的方法,这样就实现了优点当中最后两点提到的针对层次定义操作以及跨越层次定义操作。


                 这次LZ跟以前的步骤反过来一下,先给出LZ设计的类图,然后我们再来一步步实现针对层次结构做出的访问者模式。


(二十二)访问者模式详解(伪动态双分派)


                    可以看出,我们针对刚才的实现体系,分别抽象出一个层次,现在我们便可以针对层次定义操作了,左边的CFO(财务主管类)便是针对高层次定义的操作,它会统一作用于AbstractBill。


                    为了支持层次操作,我们需要在处于最低层次的元素类当中添加一些层次结构的条件判断,不过这个是可以接受的,因为这段代码不会被更改,这是符合开闭原则的。


                    下面LZ直接将代码贴上来,各位可以从中体会一下层次的感觉。

package com.visitor;
//单个单子的接口(相当于Element)
public interface Bill {

    void accept(Viewer viewer);
    
}
package com.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.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.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判断,各位看到可能会疑惑了,不是说不让用if else吗,这里怎么又出现了?

                请各位仔细体会这里的条件判断的意义,它不是在判断一个接口或者类的具体类型,而是在判断一个层次,基于这一点,这个判断是稳定的,就像上面说的一样,通俗点说,这段代码是不会被更改的。

                下面LZ再把分层之后的访问者层次贴上来。

package com.visitor;

//超级访问者接口(它支持定义高层操作)
public interface Viewer{

    void viewAbstractBill(AbstractBill bill);
    
}
package com.visitor;

//比Viewer接口低一个层次的访问者接口
public abstract class AbstractViewer implements Viewer{

    //查看消费的单子
    abstract void viewConsumeBill(ConsumeBill bill);
    
    //查看收入的单子
    abstract void viewIncomeBill(IncomeBill bill);
    
    public final void viewAbstractBill(AbstractBill bill){}
}
package com.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.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.visitor;

//财务主管类,查看账本的类之一,作用于高层的层次结构
public class CFO implements Viewer {

    //财务主管对每一个单子都要核对项目和金额
    public void viewAbstractBill(AbstractBill bill) {
        System.out.println("财务主管查看账本时,每一个都核对项目和金额,金额是" + bill.getAmount() + ",项目是" + bill.getItem());
    }

}

                可以看到,LZ这次为了避免迷惑,将view方法的名字全都给改了,在这种情况下,如果你仍然使用相同的名字,那么就算你对静态分派特别熟悉,在层次较多的情况下也会被绕晕的,而且最关键的是,我们改下名字完全不影响访问者模式的效果。

                这里要说一下的是,财务主管(CFO)是针对AbstractBill这一层定义的操作,而原来的老板(Boss)和注册会计师(CPA)都是针对ConsumeBill和IncomeBill这一层定义的操作,这时已经产生了跨越层次结构的行为,老板和注册会计师都跨过了抽象单子这一层,直接针对具体的单子定义操作。

                我们的账本类是完全不需要变化的,不过为了方便观看,这里再次贴上来。

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(Viewer viewer){
        for (Bill bill : billList) {
            bill.accept(viewer);
        }
    }
}

                下面我们就写一个客户端,来看一下针对层次定义的操作是什么效果。

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, "材料费"));
        
        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再抽象一层,从而支持三层定义。

                 从上面这个例子,我们就可以体会到访问者模式的另外一些优点了,即上面提到的第4和第5点。

                 这里针对层次结构做出的更改是基于静态分派的分派规则,即没有符合与方法参数列表相同的方法,则会将方法参数自动向上转型去匹配方法版本。

                 

                 好了,访问者模式已经介绍了不少了,到现在也基本上该结束了。本章的文章有点长,这也是实属无奈,因为访问者模式牵扯的内容比较多,而且这还是LZ尽量缩短文章长度的结果。

                 各位如果有兴趣,可以去私下试一下支持三层操作定义怎么做,而且对于动态双分派的问题也可以在下方留言讨论。

                 本次文章就到此吧,十分感谢各位的收看。

                

 

 

 

相关文章

暂无评论

暂无评论...