【趣味設計模式系列】之【訪問者模式】

小豬爸爸發表於2020-09-07

1. 簡介

訪問者模式(Visitor Pattern):表示一個作用在某物件結構中的元素的操作,它可以在不改變類的元素的前提下,定義作用於這些元素的新操作。這是《設計模式-可複用物件導向軟體的基礎》中的定義。換句通俗的話,就是類的結構元素不變,可以根據訪問者重新定義元素的操作

2. 示例

2.1 水果套餐例子

假如有個水果套餐,是蘋果、香蕉、橘子的組合,套餐內的水果種類一般不改變,需要對該購買套餐的消費者實行優惠,個人總價打9折,公司團購打8折,要求在不改變原有套餐內部元素與內部方法的情況下,根據外部訪問者的變化,重新定義新的價格演算法,如圖所示

類圖如下:

FruitPackage水果介面,接受訪問者的方法accept,計算價格的方法getPrice

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:16
 * @Desc: 水果套餐
 */
interface FruitPackage {
    void accept(Visitor v);
    double getPrice();
}

套餐內的每個元素蘋果、橘子、香蕉分別實現水果套餐介面FruitPackage,內部accept方法各種去訪問對應的元素,並把當前元素的例項this傳進去,Apple類

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:16
 * @Desc: 蘋果
 */
public class Apple implements FruitPackage {

    @Override
    public void accept(Visitor visitor) {
        visitor.visitApple(this);
    }

    @Override
    public double getPrice() {
        return 30;
    }
}

Orange類

package com.wzj.visitor.example1;

import com.wzj.proxy.v8.Discount;

/**
 * @Author: wzj
 * @Date: 2020/8/4 20:45
 * @Desc: 橘子
 */
public class Orange implements FruitPackage {

    @Override
    public void accept(Visitor visitor) {
        visitor.visitOrange(this);
    }

    @Override
    public double getPrice() {
        return 50;
    }
}

Banana類

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:16
 * @Desc: 香蕉
 */
public class Banana implements FruitPackage {

    @Override
    public void accept(Visitor visitor) {
        visitor.visitBanana(this);
    }

    @Override
    public double getPrice() {
        return 40;
    }
}

定義訪問者介面,內部依次定義訪問每個元素的方法

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:14
 * @Desc: 訪問者介面
 */
public interface Visitor {

    void visitApple(Apple apple);

    void visitOrange(Orange orange);

    void visitBanana(Banana banana);

}

個人訪問者PersonelVisitor類

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:40
 * @Desc: 個人訪問者--一律9折
 */
public class PersonelVisitor implements Visitor{

    double totalPrice = 0.0;


    @Override
    public void visitApple(Apple apple) {
        totalPrice += apple.getPrice() * 0.9;
    }

    @Override
    public void visitOrange(Orange orange) {
        totalPrice += orange.getPrice() * 0.9;
    }

    @Override
    public void visitBanana(Banana banana) {
        totalPrice += banana.getPrice() * 0.9;
    }
}

團購訪問者GroupVisitor類

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:41
 * @Desc:  團購訪問者--一律8折
 */
public class GroupVisitor implements Visitor{

    double totalPrice = 0.0;


    @Override
    public void visitApple(Apple apple) {
        totalPrice += apple.getPrice() * 0.8;
    }

    @Override
    public void visitOrange(Orange orange) {
        totalPrice += orange.getPrice() * 0.8;
    }

    @Override
    public void visitBanana(Banana banana) {
        totalPrice += banana.getPrice() * 0.8;
    }
}

具體水果套餐類ConcretePackage,把包含三個水果的元素組裝起來

package com.wzj.visitor.example1;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:51
 * @Desc: 具體套餐,蘋果、香蕉、橘子
 */
public class ConcretePackage implements FruitPackage{
    Apple apple;
    Orange orange;
    Banana banana;

    public ConcretePackage(Apple apple, Orange orange, Banana banana) {
        this.apple = apple;
        this.orange = orange;
        this.banana = banana;
    }

    public void accept(Visitor visitor) {
        this.apple.accept(visitor);
        this.orange.accept(visitor);
        this.banana.accept(visitor);
    }

    @Override
    public double getPrice() {
        return apple.getPrice() + orange.getPrice() + banana.getPrice();
    }
}

客戶端類Client

package com.wzj.visitor.example1;

import org.aspectj.weaver.ast.Or;

/**
 * @Author: wzj
 * @Date: 2020/9/2 20:57
 * @Desc:
 */
public class Client {
    public static void main(String[] args) {
        Apple apple = new Apple();
        Orange orange = new Orange();
        Banana banana = new Banana();
        //個人套餐價格
        PersonelVisitor p = new PersonelVisitor();
        new ConcretePackage(apple, orange, banana).accept(p);
        System.out.println("個人套餐價格:" + p.totalPrice);
        //公司套餐價格
        GroupVisitor g = new GroupVisitor();
        new ConcretePackage(apple, orange, banana).accept(g);
        System.out.println("公司套餐價格:" + g.totalPrice);
    }
}

最後執行結果

個人套餐價格:108.0
公司套餐價格:96.0

2.2 桌上型電腦組裝例子

假如有個桌上型電腦組裝,為簡化起見,是組裝元素由固定的三部分組成,CPU、記憶體條、主機板,現對個人來訪者總價打9折,公司團購來訪者總價打8折,要求在不改變原有套餐內部元素與內部方法的情況下,根據外部訪問者的變化,重新定義新的價格演算法,如圖所示

類圖設計

電腦部件類ComputerPart

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:15
 * @Desc: 電腦部件
 */
public interface ComputerPart {

    void accept(Visitor v);
    double getPrice();
}

CPU類、Memory類、Board類如下:

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:13
 * @Desc: CPU
 */
public class CPU implements ComputerPart{
    @Override
    public void accept(Visitor v) {
        v.visitCpu(this);
    }

    @Override
    public double getPrice() {
        return 1000;
    }
}

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:13
 * @Desc: 記憶體條
 */
public class Memory implements ComputerPart{
    @Override
    public void accept(Visitor v) {
        v.visitMemory(this);
    }

    @Override
    public double getPrice() {
        return 500;
    }
}

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:13
 * @Desc: CPU
 */
public class Board implements ComputerPart{
    @Override
    public void accept(Visitor v) {
        v.visitBoard(this);
    }

    @Override
    public double getPrice() {
        return 800;
    }
}

個人訪問者PersonelVisitor類

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:21
 * @Desc: 個人購買9折
 */
public class PersonelVisitor implements Visitor {
    double totalPrice = 0.0;

    @Override
    public void visitCpu(CPU cpu) {
        totalPrice += cpu.getPrice() * 0.9;
    }

    @Override
    public void visitMemory(Memory memory) {
        totalPrice += memory.getPrice() * 0.9;
    }

    @Override
    public void visitBoard(Board board) {
        totalPrice += board.getPrice() * 0.9;
    }
}

團購訪問者GroupVisitor類

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:21
 * @Desc:  公司團購8折
 */
public class GroupVisitor implements Visitor {
    double totalPrice = 0.0;

    @Override
    public void visitCpu(CPU cpu) {
        totalPrice += cpu.getPrice() * 0.8;
    }

    @Override
    public void visitMemory(Memory memory) {
        totalPrice += memory.getPrice() * 0.8;
    }

    @Override
    public void visitBoard(Board board) {
        totalPrice += board.getPrice() * 0.8;
    }
}

電腦類Computer類

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:24
 * @Desc:  電腦
 */
public class Computer {

    CPU cpu;
    Memory memory;
    Board board;

    public Computer(CPU cpu, Memory memory, Board board) {
        this.cpu = cpu;
        this.memory = memory;
        this.board = board;
    }

    public void acccept(Visitor v) {
        this.cpu.accept(v);
        this.memory.accept(v);
        this.board.accept(v);
    }
}

客戶端Client類

package com.wzj.visitor.example2;

/**
 * @Author: wzj
 * @Date: 2020/9/2 21:24
 * @Desc:
 */
public class Client {
    public static void main(String[] args) {
        CPU cpu = new CPU();
        Memory memory = new Memory();
        Board board = new Board();
        PersonelVisitor p = new PersonelVisitor();
        new Computer(cpu, memory, board).acccept(p);
        System.out.println("個人套餐價格:" + p.totalPrice);
        GroupVisitor g = new GroupVisitor();
        new Computer(cpu, memory, board).acccept(g);
        System.out.println("公司套餐價格:" + g.totalPrice);
    }
}

結果:

個人套餐價格:2070.0
公司套餐價格:1840.0

3. 應用場景分析

訪問者模式一般用在特定的場景中,在經典的四人幫寫的【設計模式】一書中,舉了編譯器的例子,如果需要將源程式表示一個抽象語法樹,編譯器需要對抽象語法樹實施某些操作,如型別檢查、程式碼優化、優化格式列印,大多數操作對於不同節點進行不同處理,但是對於編譯器來說,節點類的集合對於給定的語言,內部結構很少變化,如下圖

編譯器對使用訪問者對程式進行型別檢查,它將建立一個TypeCheckingVisitor物件,並以這個物件為傳入引數,在抽象語法樹上呼叫accept方法,每一個節點在實現accept方法時會回撥訪問者:一個賦值節點AssignmentNode物件會回撥visitAssignment(this)方法,一個變數引用節點VariableRefNode物件會呼叫visitVariableRef(this)方法。

筆者在【趣味設計模式系列】之【代理模式4--ASM框架解析】裡面分析的ASM框架也是運用的訪問者模式,幾個核心類的關係如下圖

成員變數節點FieldNode類、方法節點MethodNode類,擁有接收訪問者的方法accept,通過傳入的具體的訪問者ClassAdapter類或ClassWriter類,方法內部各自呼叫具體訪問者訪問自己部件的方法,對應途中的訪問屬性visitField,訪問方法visitMethod。

4. 總結

4.1 雙分派技術

講到訪問者模式,大部分書籍或者資料都會講到 Double Dispatch,中文翻譯為雙分派。為什麼支援雙分派的語言就不需要訪問者模式。

既然有 Double Dispatch,對應的就有 Single Dispatch。所謂 Single Dispatch,指的是執行哪個物件的方法,根據物件的執行時型別來決定;執行物件的哪個方法,根據方法引數的編譯時型別來決定。所謂 Double Dispatch,指的是執行哪個物件的方法,根據物件的執行時型別來決定;執行物件的哪個方法,根據方法引數的執行時型別來決定。

如何理解“Dispatch”這個單詞呢?在物件導向程式語言中,我們可以把方法呼叫理解為一種訊息傳遞,也就是“Dispatch”。一個物件呼叫另一個物件的方法,就相當於給它傳送一條訊息。這條訊息起碼要包含物件名、方法名、方法引數。如何理解“Single”“Double”這兩個單詞呢?“Single”“Double”指的是執行哪個物件的哪個方法,跟幾個因素的執行時型別有關。我們進一步解釋一下。Single Dispatch 之所以稱為“Single”,是因為執行哪個物件的哪個方法,只跟“物件”的執行時型別有關。Double Dispatch 之所以稱為“Double”,是因為執行哪個物件的哪個方法,跟“物件”和“方法引數”兩者的執行時型別有關。

Java 支援多型特性,程式碼可以在執行時獲得物件的實際型別(也就是前面提到的執行時型別),然後根據實際型別決定呼叫哪個方法。儘管 Java 支援函式過載,但 Java 設計的函式過載的語法規則是,並不是在執行時,根據傳遞進函式的引數的實際型別,來決定呼叫哪個過載函式,而是在編譯時,根據傳遞進函式的引數的宣告型別(也就是前面提到的編譯時型別),來決定呼叫哪個過載函式。也就是說,具體執行哪個物件的哪個方法,只跟物件的執行時型別有關,跟引數的執行時型別無關。所以,Java 語言只支援 Single Dispatch。
舉個例子來具體說明一下,程式碼如下所示:


public class ParentClass {
  public void f() {
    System.out.println("I am ParentClass's f().");
  }
}

public class ChildClass extends ParentClass {
  public void f() {
    System.out.println("I am ChildClass's f().");
  }
}

public class SingleDispatchClass {
  public void polymorphismFunction(ParentClass p) {
    p.f();
  }

  public void overloadFunction(ParentClass p) {
    System.out.println("I am overloadFunction(ParentClass p).");
  }

  public void overloadFunction(ChildClass c) {
    System.out.println("I am overloadFunction(ChildClass c).");
  }
}

public class DemoMain {
  public static void main(String[] args) {
    SingleDispatchClass demo = new SingleDispatchClass();
    ParentClass p = new ChildClass();
    demo.polymorphismFunction(p);//執行哪個物件的方法,由物件的實際型別決定
    demo.overloadFunction(p);//執行物件的哪個方法,由引數物件的宣告型別決定
  }
}

//程式碼執行結果:
I am ChildClass's f().
I am overloadFunction(ParentClass p).

這也回答了為什麼支援 Double Dispatch 的語言不需要訪問者模式。

訪問者模式允許在不改變類的情況下,有效的增加新的操作,這是一種很著名的技術,意味著執行的操作取決於請求的種類與接受者型別,accept方法是一個雙分派操作, 取決於visitor型別與Node節點型別,使得訪問者可以對每一種型別的請求執行不用的操作。

4.2 優點

  • 容易增加新的操作
    如果有複雜物件結構,需要增加新的操作,只需要增加新的訪問者定義新操作即可。
  • 集中相關操作分離無關操作
    相關行為集中在訪問者中,無關行為被分離到各自訪問者的關子類中,所有與演算法相關的資料結構都被隱藏在訪問者中。

4.3 缺點

  • 具體元素對訪問者公佈,破壞封裝;
  • 訪問者依賴具體元素,而非依賴抽象,破壞了依賴倒置原則,導致具體元素的增加、刪除、修改比較困難。

綜上,訪問者模式一般都用在類似於編譯器等比較窄卻很專業的場景中,如果自己非要使用,適合類的結構元素不變的情況下,需要重新定義元素操作。


附:githup原始碼下載地址:https://github.com/wuzhujun2006/design-patterns

相關文章