設計模式學習之訪問者模式

shuilaner_發表於2018-12-11

訪問者模式,是行為型設計模式之一。訪問者模式是一種將資料操作與資料結構分離的設計模式,它可以算是 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就可以。

Android中的訪問者模式

安卓中的著名開源庫ButterKnife、Dagger、Retrofit都是基於APT(Annotation Processing Tools)實現。而編譯註解核心依賴APT。當我們通過APT處理註解時,最終會將獲取到的元素轉換為相應的Element元素,以便獲取到它們對應資訊。那麼元素基類的原始碼如下:(路徑:javax.lang.model.element.Element)

public interface Element extends javax.lang.model.AnnotatedConstruct {

    /**
     * Returns the {@code kind} of this element.
     *
     * @return the kind of this element
     */
    ElementKind getKind();//獲取元素型別

    //程式碼省略

    /**
     * Applies a visitor to this element.
     *
     * @param <R> the return type of the visitor's methods
     * @param <P> the type of the additional parameter to the visitor's methods
     * @param v   the visitor operating on this element
     * @param p   additional parameter to the visitor
     * @return a visitor-specified result
     */
    <R, P> R accept(ElementVisitor<R, P> v, P p);//接受訪問者的訪問
}

ElementVisitor就是訪問者型別,ElementVisitor原始碼如下:

public interface ElementVisitor<R, P> {
    /**
     * Visits an element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visit(Element e, P p);

    /**
     * A convenience method equivalent to {@code v.visit(e, null)}.
     * @param e  the element to visit
     * @return a visitor-specified result
     */
    R visit(Element e);

    /**
     * Visits a package element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visitPackage(PackageElement e, P p);

    /**
     * Visits a type element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visitType(TypeElement e, P p);

    /**
     * Visits a variable element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visitVariable(VariableElement e, P p);

    /**
     * Visits an executable element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visitExecutable(ExecutableElement e, P p);

    /**
     * Visits a type parameter element.
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     */
    R visitTypeParameter(TypeParameterElement e, P p);

    /**
     * Visits an unknown kind of element.
     * This can occur if the language evolves and new kinds
     * of elements are added to the {@code Element} hierarchy.
     *
     * @param e  the element to visit
     * @param p  a visitor-specified parameter
     * @return a visitor-specified result
     * @throws UnknownElementException
     *  a visitor implementation may optionally throw this exception
     */
    R visitUnknown(Element e, P p);
}

在ElementVisitor中定義了多種visit介面,每個介面處理一種元素型別,那麼這就是典型的訪問者模式。

總結

優點:

1、使得資料結構和作用於結構上的操作解耦,使得操作集合可以獨立變化。

2、新增新的操作或者說訪問者會非常容易。

3、將對各個元素的一組操作集中在一個訪問者類當中。

4、使得類層次結構不改變的情況下,可以針對各個層次做出不同的操作,而不影響類層次結構的完整性。

5、可以跨越類層次結構,訪問不同層次的元素類,做出相應的操作。

缺點:

1、增加新的元素會非常困難。

2、實現起來比較複雜,會增加系統的複雜性。

3、破壞封裝,如果將訪問行為放在各個元素中,則可以不暴露元素的內部結構和狀態,但使用訪問者模式的時候,為了讓訪問者能獲取到所關心的資訊,元素類不得不暴露出一些內部的狀態和結構,就像收入和支出類必須提供訪問金額和單子的專案的方法一樣。

適用性:

1、資料結構穩定,作用於資料結構的操作經常變化的時候。

2、當一個資料結構中,一些元素類需要負責與其不相關的操作的時候,為了將這些操作分離出去,以減少這些元素類的職責時,可以使用訪問者模式。

3、有時在對資料結構上的元素進行操作的時候,需要區分具體的型別,這時使用訪問者模式可以針對不同的型別,在訪問者類中定義不同的操作,從而去除掉型別判斷

相關文章