访问者模式,是行为型设计模式之一。访问者模式是一种将数据操作与数据结构分离的设计模式,它可以算是 23 中设计模式中最复杂的一个,但它的使用频率并不是很高,大多数情况下,你并不需要使用访问者模式,但是当你一旦需要使用它时,那你就是需要使用它了。
访问者模式的基本想法是,软件系统中拥有一个由许多对象构成的、比较稳定的对象结构,这些对象的类都拥有一个 accept 方法用来接受访问者对象的访问。访问者是一个接口,它拥有一个 visit 方法,这个方法对访问到的对象结构中不同类型的元素做出不同的处理。在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施 accept 方法,在每一个元素的 accept 方法中会调用访问者的 visit 方法,从而使访问者得以处理对象结构的每一个元素,我们可以针对对象结构设计不同的访问者类来完成不同的操作,达到区别对待的效果。
定义及使用场景
定义:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变这个数据结构的前提下定义作用于这些元素的新的操作。
可以对定义这么理解:有这么一个操作,它是作用于一些元素之上的,而这些元素属于某一个对象结构。同时这个操作是在不改变各元素类的前提下,在这个前提下定义新操作是访问者模式精髓中的精髓。
使用场景:
(1)对象结构比较稳定,但经常需要在此对象结构上定义新的操作。
(2)需要对一个对象结构中的对象进行很多不同的且不相关的操作,而需要避免这些操作“污染”这些对象的类,也不希望在增加新操作时修改这些类。
UML图
(1)Visitor:接口或者抽象类,它定义了对每一个元素(Element)访问的行为,它的参数就是可以访问的元素,它的方法数理论上来讲与元素个数是一样的,因此,访问者模式要求元素的类族要稳定,如果经常添加、移除元素类,必然会导致频繁地修改Visitor接口,如果这样则不适合使用访问者模式。
(2)ConcreteVisitor1、ConcreteVisitor2:具体的访问类,它需要给出对每一个元素类访问时所产生的具体行为。
(3)Element:元素接口或者抽象类,它定义了一个接受访问者的方法(Accept),其意义是指每一个元素都要可以被访问者访问。
(4)ConcreteElementA、ConcreteElementB:具体的元素类,它提供接受访问方法的具体实现,而这个具体的实现,通常情况下是使用访问者提供的访问该元素类的方法。
(5)ObjectStructure:定义当中所说的对象结构,对象结构是一个抽象表述,它内部管理了元素集合,并且可以迭代这些元素供访问者访问。
访问者模式的简单例子
我们都知道财务都是有账本的,这个账本就可以作为一个对象结构,而它其中的元素有两种,收入和支出,这满足我们访问者模式的要求,即元素的个数是稳定的,因为账本中的元素只能是收入和支出。
而查看账本的人可能有这样几种,比如老板,会计事务所的注会,财务主管,等等。而这些人在看账本的时候显然目的和行为是不同的。
首先我们给出单子的接口,它只有一个方法accept。
//单个单子的接口(相当于Element)
public interface Bill {
void accept(AccountBookViewer viewer);
}
其中的方法参数AccountBookViewer是一个账本访问者接口,接下来也就是实现类,收入单子和消费单子,或者说收入和支出类。
//消费的单子
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;
}
}
//收入单子
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方法,它直接让访问者访问自己,这相当于一次静态分派(文章最后进行解释),当然我们也可以不使用重载而直接给方法不同的名称。
接下来是账本访问者接口
//账单查看者接口(相当于Visitor)
public interface AccountBookViewer {
//查看消费的单子
void view(ConsumeBill bill);
//查看收入的单子
void view(IncomeBill bill);
}
这两个方法是重载方法,就是在上面的元素类当中用到的,当然你也可以按照访问者模式类图当中的方式去做,将两个方法分别命名为viewConsumeBill和viewIncomeBill,而一般建议按照类图上来做的
访问者的实现
//老板类,查看账本的类之一
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;
}
}
//注册会计师类,查看账本的类之一
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("注会查看收入交税了没。");
}
}
老板只关心收入和支出的总额,而注会只关注该交税的是否交税
接下来是账本类,它是当前访问者模式例子中的对象结构
//账本类(相当于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)的集合,这便是对象结构的通常表示,它一般会是一堆元素的集合,不过这个集合不一定是列表,也可能是树,链表等等任何数据结构,甚至是若干个数据结构。其中show方法,就是账本类的精髓,它会枚举每一个元素,让访问者访问。
测试客户端
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方法去访问账本了。
如果没使用访问者模式,一定会增加许多if else,而且每增加一个访问者,你都需要改你的if else,代码会显得非常臃肿,而且非常难以扩展和维护。
静态分派以及动态分派
变量被声明时的类型叫做变量的静态类型(Static Type),有些人又把静态类型叫做明显类型(Apparent Type);而变量所引用的对象的真实类型又叫做变量的实际类型(Actual Type)。比如:
List list = null;
list = new ArrayList();
声明了一个变量list,它的静态类型(也叫明显类型)是List,而它的实际类型是ArrayList。根据对象的类型而对方法进行的选择,就是分派(Dispatch),分派(Dispatch)又分为两种,即静态分派和动态分派。静态分派(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(integer);
main.test(string);
}
}
在静态分派判断的时候,我们根据多个判断依据(即参数类型和个数)判断出了方法的版本,那么这个就是多分派的概念,因为我们有一个以上的考量标准,也可以称为宗量。所以JAVA是静态多分派的语言。
动态分派
对于动态分派,与静态相反,它不是在编译期确定的方法版本,而是在运行时才能确定。而动态分派最典型的应用就是多态的特性
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是动态单分派的语言。
访问者模式中的伪动态双分派
访问者模式中使用的是伪动态双分派,所谓的动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。
回到上面例子当中账本类中的accept方法
for (Bill bill : billList) {
bill.accept(viewer);
}
这里就是依据biil和viewer两个实际类型决定了view方法的版本,从而决定了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的实际类型先动态分派一次,然后在分派后确定的方法版本里再进行自己的动态分派。
注意:这里确定view(ConsumeBill bill)方法是静态分派决定的,所以这个并不在此次动态双分派的范畴内,而且静态分派是在编译期就完成的,所以view(ConsumeBill bill)方法的静态分派与访问者模式的动态双分派并没有任何关系。动态双分派说到底还是动态分派,是在运行时发生的,它与静态分派有着本质上的区别,不可以说一次动态分派加一次静态分派就是动态双分派,而且访问者模式的双分派本身也是另有所指。
这里的this的类型不是动态确定的,你写在哪个类当中,它的静态类型就是哪个类,这是在编译期就确定的,不确定的是它的实际类型,请各位区分开这一点。
对访问者模式的一些思考
假设我们上面的例子当中再添加一个财务主管,而财务主管不管你是支出还是收入,都要详细的查看你的单子的项目以及金额,简单点说就是财务主管类的两个view方法的代码是一样的。
这里的将两个view方法抽取的方案是,我们可以将元素提炼出层次结构,针对层次结构提供操作的方法,这样就实现了优点当中最后两点提到的针对层次定义操作以及跨越层次定义操作。
//单个单子的接口(相当于Element)
public interface Bill {
void accept(Viewer viewer);
}
//抽象单子类,一个高层次的单子抽象
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;
}
}
//收入单子
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);
}
}
//消费的单子
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判断,这里的判断是在判断一个层次,这段代码是不会被更改的。
访问者层次
//超级访问者接口(它支持定义高层操作)
public interface Viewer{
void viewAbstractBill(AbstractBill bill);
}
//比Viewer接口低一个层次的访问者接口
public abstract class AbstractViewer implements Viewer{
//查看消费的单子
abstract void viewConsumeBill(ConsumeBill bill);
//查看收入的单子
abstract void viewIncomeBill(IncomeBill bill);
public final void viewAbstractBill(AbstractBill bill){}
}
//老板类,查看账本的类之一,作用于最低层次结构
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;
}
}
//注册会计师类,查看账本的类之一,作用于最低层次结构
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("注会查看收入交税了没。");
}
}
//财务主管类,查看账本的类之一,作用于高层的层次结构
public class CFO implements Viewer {
//财务主管对每一个单子都要核对项目和金额
public void viewAbstractBill(AbstractBill bill) {
System.out.println("财务主管查看账本时,每一个都核对项目和金额,金额是" + bill.getAmount() + ",项目是" + bill.getItem());
}
}
财务主管(CFO)是针对AbstractBill这一层定义的操作,而原来的老板(Boss)和注册会计师(CPA)都是针对ConsumeBill和IncomeBill这一层定义的操作,这时已经产生了跨越层次结构的行为,老板和注册会计师都跨过了抽象单子这一层,直接针对具体的单子定义操作。
账本类没有变化,最后看客户端的使用
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就可以。
总结
优点:
1、使得数据结构和作用于结构上的操作解耦,使得操作集合可以独立变化。
2、添加新的操作或者说访问者会非常容易。
3、将对各个元素的一组操作集中在一个访问者类当中。
4、使得类层次结构不改变的情况下,可以针对各个层次做出不同的操作,而不影响类层次结构的完整性。
5、可以跨越类层次结构,访问不同层次的元素类,做出相应的操作。
缺点:
1、增加新的元素会非常困难。
2、实现起来比较复杂,会增加系统的复杂性。
3、破坏封装,如果将访问行为放在各个元素中,则可以不暴露元素的内部结构和状态,但使用访问者模式的时候,为了让访问者能获取到所关心的信息,元素类不得不暴露出一些内部的状态和结构,就像收入和支出类必须提供访问金额和单子的项目的方法一样。
适用性:
1、数据结构稳定,作用于数据结构的操作经常变化的时候。
2、当一个数据结构中,一些元素类需要负责与其不相关的操作的时候,为了将这些操作分离出去,以减少这些元素类的职责时,可以使用访问者模式。
3、有时在对数据结构上的元素进行操作的时候,需要区分具体的类型,这时使用访问者模式可以针对不同的类型,在访问者类中定义不同的操作,从而去除掉类型判断。