徹底搞懂訪問者模式的靜態、動態和偽動態分派

Tom彈架構發表於2021-11-24

本文節選自《設計模式就該這樣學》

1 使用訪問者模式實現KPI考核的場景

每到年底,管理層就要開始評定員工一年的工作績效,員工分為工程師和經理;管理層有CEO和CTO。那麼CTO關注工程師的程式碼量、經理的新產品數量;CEO關注工程師的KPI、經理的KPI及新產品數量。
由於CEO和CTO對於不同的員工的關注點是不一樣的,這就需要對不同的員工型別進行不同的處理。此時,訪問者模式可以派上用場了,來看程式碼。


//員工基類
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);
}

Employee類定義了員工基本資訊及一個accept()方法,accept()方法表示接受訪問者的訪問,由具體的子類來實現。訪問者是一個介面,傳入不同的實現類,可訪問不同的資料。下面看工程師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個型別相當於資料結構,這些型別相對穩定,不會發生變化。
將這些員工新增到一個業務報表類中,公司高層可以通過該報表類的showReport()方法檢視所有員工的業績,程式碼如下。


//員工業務報表類
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"));
    }

    /**
     * 為訪問者展示報表
     * @param visitor 公司高層,如CEO、CTO
     */
    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類。首先來看CEOVisitor類的程式碼。


//CEO訪問者
public class CEOVisitor implements IVisitor {

    public void visit(Engineer engineer) {
        System.out.println("工程師: " + engineer.name + ", KPI: " + engineer.kpi);
    }

    public void visit(Manager manager) {
        System.out.println("經理: " + manager.name + ", KPI: " + manager.kpi +
                ", 新產品數量: " + manager.getProducts());
    }
}

在CEO的訪問者中,CEO關注工程師的KPI、經理的KPI和新產品數量,通過兩個visit()方法分別進行處理。如果不使用訪問者模式,只通過一個visit()方法進行處理,則需要在這個visit()方法中進行判斷,然後分別處理,程式碼如下。


public class ReportUtil {
    public void visit(Employee employee) {
        if (employee instanceof Manager) {
            Manager manager = (Manager) employee;
            System.out.println("經理: " + manager.name + ", KPI: " + manager.kpi +
                    ", 新產品數量: " + manager.getProducts());
        } else if (employee instanceof Engineer) {
            Engineer engineer = (Engineer) employee;
            System.out.println("工程師: " + engineer.name + ", KPI: " + engineer.kpi);
        }
    }
}

這就導致了if...else邏輯的巢狀及型別的強制轉換,難以擴充套件和維護,當型別較多時,這個ReportUtil就會很複雜。而使用訪問者模式,通過同一個函式對不同的元素型別進行相應處理,使結構更加清晰、靈活性更高。然後新增一個CTO的訪問者類CTOVisitor。


public class CTOVisitor 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());
    }
}

過載的visit()方法會對元素進行不同的操作,而通過注入不同的訪問者又可以替換掉訪問者的具體實現,使得對元素的操作變得更靈活,可擴充套件性更高,同時,消除了型別轉換、if...else等“醜陋”的程式碼。
客戶端測試程式碼如下。


public static void main(String[] args) {
        //構建報表
        BusinessReport report = new BusinessReport();
        System.out.println("=========== CEO看報表 ===========");
        report.showReport(new CEOVisitor());
        System.out.println("=========== CTO看報表 ===========");
        report.showReport(new CTOVisitor());
}

執行結果如下圖所示。

file

在上述案例中,Employee扮演了Element角色,Engineer和Manager都是 ConcreteElement,CEOVisitor和CTOVisitor都是具體的Visitor物件,BusinessReport就是ObjectStructure。
訪問者模式最大的優點就是增加訪問者非常容易,從程式碼中可以看到,如果要增加一個訪問者,則只要新實現一個訪問者介面的類,從而達到資料物件與資料操作相分離的效果。如果不使用訪問者模式,而又不想對不同的元素進行不同的操作,則必定需要使用if...else和型別轉換,這使得程式碼難以升級維護。
我們要根據具體情況來評估是否適合使用訪問者模式。例如,物件結構是否足夠穩定,是否需要經常定義新的操作,使用訪問者模式是否能優化程式碼,而不使程式碼變得更復雜。

2 從靜態分派到動態分派

變數被宣告時的型別叫作變數的靜態型別(Static Type),有些人又把靜態型別叫作明顯型別(Apparent Type);而變數所引用的物件的真實型別又叫作變數的實際型別(Actual Type)。比如:


List list = null;
list = new ArrayList();

上面程式碼宣告瞭一個變數list,它的靜態型別(也叫作明顯型別)是List,而它的實際型別是ArrayList。根據物件的型別對方法進行的選擇,就是分派(Dispatch)。分派又分為兩種,即靜態分派和動態分派。

2.1 靜態分派

靜態分派(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是靜態多分派的語言。

2.2 動態分派

對於動態分派,與靜態分派相反,它不是在編譯期確定的方法版本,而是在執行時才能確定的。而動態分派最典型的應用就是多型的特性。舉個例子,來看下面的程式碼。


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是動態單分派的語言。

3 訪問者模式中的偽動態分派

通過前面的分析,我們知道Java是靜態多分派、動態單分派的語言。Java底層不支援動態雙分派。但是通過使用設計模式,也可以在Java裡實現偽動態雙分派。在訪問者模式中使用的就是偽動態雙分派。所謂動態雙分派就是在執行時依據兩個實際型別去判斷一個方法的執行行為,而訪問者模式實現的手段是進行兩次動態單分派來達到這個效果。
還是回到前面的KPI考核業務場景中,BusinessReport類中的showReport()方法的程式碼如下。


public void showReport(IVisitor visitor) {
        for (Employee employee : employees) {
            employee.accept(visitor);
        }
}

這裡依據Employee和IVisitor兩個實際型別決定了showReport()方法的執行結果,從而決定了accept()方法的動作。
accept()方法的呼叫過程分析如下。

(1)當呼叫accept()方法時,根據Employee的實際型別決定是呼叫Engineer還是Manager的accept()方法。

(2)這時accept()方法的版本已經確定,假如是Engineer,則它的accept()方法呼叫下面這行程式碼。


    public void accept(IVisitor visitor) {
        visitor.visit(this);
    }
		

此時的this是Engineer型別,因此對應的是IVisitor介面的visit(Engineer engineer)方法,此時需要再根據訪問者的實際型別確定visit()方法的版本,如此一來,就完成了動態雙分派的過程。
以上過程通過兩次動態雙分派,第一次對accept()方法進行動態分派,第二次對訪問者的visit()方法進行動態分派,從而達到根據兩個實際型別確定一個方法的行為的效果。
而原本的做法通常是傳入一個介面,直接使用該介面的方法,此為動態單分派,就像策略模式一樣。在這裡,showReport()方法傳入的訪問者介面並不是直接呼叫自己的visit()方法,而是通過Employee的實際型別先動態分派一次,然後在分派後確定的方法版本里進行自己的動態分派。

注:這裡確定accept(IVisitor visitor)方法是由靜態分派決定的,所以這個並不在此次動態雙分派的範疇內,而且靜態分派是在編譯期完成的,所以accept(IVisitor visitor)方法的靜態分派與訪問者模式的動態雙分派並沒有任何關係。動態雙分派說到底還是動態分派,是在執行時發生的,它與靜態分派有著本質上的區別,不可以說一次動態分派加一次靜態分派就是動態雙分派,而且訪問者模式的雙分派本身也是另有所指。

而this的型別不是動態分派確定的,把它寫在哪個類中,它的靜態型別就是哪個類,這是在編譯期就確定的,不確定的是它的實際型別,請小夥伴們也要區分開來。

4 訪問者模式在JDK原始碼中的應用

首先來看JDK的NIO模組下的FileVisitor介面,它提供了遞迴遍歷檔案樹的支援。這個介面上的方法表示了遍歷過程中的關鍵過程,允許在檔案被訪問、目錄將被訪問、目錄已被訪問、發生錯誤等過程中進行控制。換句話說,這個介面在檔案被訪問前、訪問中和訪問後,以及產生錯誤的時候都有相應的鉤子程式進行處理。
呼叫FileVisitor中的方法,會返回訪問結果的FileVisitResult物件值,用於決定當前操作完成後接下來該如何處理。FileVisitResult的標準返回值存放在FileVisitResult列舉型別中,程式碼如下。


public interface FileVisitor<T> {

    FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
        throws IOException;

    FileVisitResult visitFile(T file, BasicFileAttributes attrs)
        throws IOException;

    FileVisitResult visitFileFailed(T file, IOException exc)
        throws IOException;

    FileVisitResult postVisitDirectory(T dir, IOException exc)
        throws IOException;
}

(1)FileVisitResult.CONTINUE:這個訪問結果表示當前的遍歷過程將會繼續。

(2)FileVisitResult.SKIP_SIBLINGS:這個訪問結果表示當前的遍歷過程將會繼續,但是要忽略當前檔案/目錄的兄弟節點。

(3)FileVisitResult.SKIP_SUBTREE:這個訪問結果表示當前的遍歷過程將會繼續,但是要忽略當前目錄下的所有節點。

(4)FileVisitResult.TERMINATE:這個訪問結果表示當前的遍歷過程將會停止。

通過訪問者去遍歷檔案樹會比較方便,比如查詢資料夾內符合某個條件的檔案或者某一天內所建立的檔案,這個類中都提供了相對應的方法。它的實現其實也非常簡單,程式碼如下。


public class SimpleFileVisitor<T> implements FileVisitor<T> {
    protected SimpleFileVisitor() {
    }

    @Override
    public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
        throws IOException
    {
        Objects.requireNonNull(dir);
        Objects.requireNonNull(attrs);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFile(T file, BasicFileAttributes attrs)
        throws IOException
    {
        Objects.requireNonNull(file);
        Objects.requireNonNull(attrs);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFileFailed(T file, IOException exc)
        throws IOException
    {
        Objects.requireNonNull(file);
        throw exc;
    }

    @Override
    public FileVisitResult postVisitDirectory(T dir, IOException exc)
        throws IOException
    {
        Objects.requireNonNull(dir);
        if (exc != null)
            throw exc;
        return FileVisitResult.CONTINUE;
    }
}

5 訪問者模式在Spring原始碼中的應用

再來看訪問者模式在Spring中的應用,Spring IoC中有個BeanDefinitionVisitor類,其中有一個visitBeanDefinition()方法,原始碼如下。



public class BeanDefinitionVisitor {

	@Nullable
	private StringValueResolver valueResolver;


	public BeanDefinitionVisitor(StringValueResolver valueResolver) {
		Assert.notNull(valueResolver, "StringValueResolver must not be null");
		this.valueResolver = valueResolver;
	}

	protected BeanDefinitionVisitor() {
	}

	public void visitBeanDefinition(BeanDefinition beanDefinition) {
		visitParentName(beanDefinition);
		visitBeanClassName(beanDefinition);
		visitFactoryBeanName(beanDefinition);
		visitFactoryMethodName(beanDefinition);
		visitScope(beanDefinition);
		if (beanDefinition.hasPropertyValues()) {
			visitPropertyValues(beanDefinition.getPropertyValues());
		}
		if (beanDefinition.hasConstructorArgumentValues()) {
			ConstructorArgumentValues cas = beanDefinition.getConstructorArgumentValues();
			visitIndexedArgumentValues(cas.getIndexedArgumentValues());
			visitGenericArgumentValues(cas.getGenericArgumentValues());
		}
	}
	...
}

我們看到,在visitBeanDefinition()方法中,訪問了其他資料,比如父類的名字、自己的類名、在IoC容器中的名稱等各種資訊。
關注微信公眾號『 Tom彈架構 』回覆“設計模式”可獲取完整原始碼。

【推薦】Tom彈架構:30個設計模式真實案例(附原始碼),挑戰年薪60W不是夢

本文為“Tom彈架構”原創,轉載請註明出處。技術在於分享,我分享我快樂!
如果本文對您有幫助,歡迎關注和點贊;如果您有任何建議也可留言評論或私信,您的支援是我堅持創作的動力。關注微信公眾號『 Tom彈架構 』可獲取更多技術乾貨!

相關文章