Visitor

January 5, 2022

你想要設計一個 2D 繪圖程式庫,繪圖嘛!最基本的就是點的資訊:

class Point {
    final double x;
    final double y;
    Point(double x, double y) {
        this.x = x;
        this.y = y;
    }
}

你會提供一些基本的 2D 圖案,2D 圖形的幾何資訊與呈現會是分離的,因此你設計了一些類別,來封裝 2D 圖形的幾何資訊:

interface Shape {
    List<Point> polygon(); // 傳回一組逆時針順序的點,作為繪製形狀的資訊
}

class Rectangle implements Shape {
    final Point center;
    final double width;
    final double height;
    
    Rectangle(Point center, double width, double height) {
        this.center = center;
        this.width = width;
        this.height = height;
    }

    @Override
    public List<Point> polygon() {
        // 實作…逆時針建立長方形的四個點 …
    }
}

class Circle implements Shape {
    final Point center;
    final double radius;
    
    Circle(Point center, double radius) {
        this.center = center;
        this.radius = radius;
    }

    @Override
    public List<Point> polygon() {
        // 實作…逆時針建立 96 邊形的 96 個點 … 96 邊形在人眼看來就像個圓了
    }
}

在你的設計中,為了繪製圖形,每個 2D 圖案最後都會轉換為多邊形,Shapepolygon 方法要傳回一組逆時針順序的點,代表轉換後的多邊形,你會提供固定的幾個基本 2D 圖案幾何資訊,也許目前是五個吧!只不過上面只列出了其中兩個 RectangleCircle,總之,這些類別只用來封裝幾何資訊,除了規範的 polygon 方法之外,不提供其他的方法操作,因為你也不知道使用者會用這些資訊做哪些計算。

面積/周長

如果使用者要計算 2D 圖案的面積,在拿到一個 Shape 後,必須知道它是什麼型態,轉型後進一步取得幾何資訊吧!也就是可以自定義一個方法:

static double area(Shape shape) {
    if(shape instanceof Rectangle) {
        var rect = (Rectangle) shape;
        return rect.width * rect.height;
    }
    else if(shape instanceof Circle) {
        var circle = (Circle) shape;
        return circle.radius * circle.radius * Math.PI;
    }
    ...其他 else if 判斷 Shape 實例的真正型態後計算面積
}

喔!instanceof 耶!不好的訊號!沒辦法,因為 CircleRectangle 只作為純綷的資料載體,而且程式庫是由你設計的,使用者不能變動原始碼,只能拿到幾何資訊後,做他想做的事!

或許使用者又想計算周長了,因此自定義了一個 perimeter

static double perimeter(Shape shape) {
    if(shape instanceof Rectangle) {
        var rect = (Rectangle) shape;
        return 2 * (rect.width + rect.height);
    }
    else if(shape instanceof Circle) {
        var circle = (Circle) shape;
        return 2 * Math.PI * circle.radius;
    }
    ...其他 else if 判斷 Shape 實例的真正型態後計算周長
}

總之,如果使用者想利用幾何資訊做些什麼,他可以自定方法、判斷型態、取得對應的幾何資訊後進行對應的計算處理,顯然地,這些方法實現上出現了固定的比對形態、抽取資料欄位的流程模式…使用者問你有沒有辦法,用多型什麼的來解決這類重複。

ad hoc 多型

多型啊!如果你指的是 ad hoc 多型就有可能,ad hoc 多型 … 就 Java 而言的白話文,就是運用重載(overload)啦!

為了能讓使用者指定數學公式,你先定義了一個 Func 介面,這個介面中要列出你定義的 Shape 子型態:

interface Func {
    double apply(Rectangle rect);
    double apply(Circle circle);
}

接著讓 Shape 實例都提供 apply(Func func) 方法,其中 RectangleCircle 在實作時,都是 func.apply(this)

interface Shape {
    List<Point> polygon();
    double apply(Func func);
}

class Rectangle extends Shape {
    final Point center;
    final double width;
    final double height;
    
    Rectangle(Point center, double width, double height) {
        this.center = center;
        this.width = width;
        this.height = height;
    }
    
    @Override
    public double apply(Func func) {
        return func.apply(this);
    }

    ...
}

class Circle implements Shape {
    final Point center;
    final double radius;
    
    Circle(Point center, double radius) {
        this.center = center;
        this.radius = radius;
    }
    
    @Override
    public double apply(Func func) {
        return func.apply(this);
    }

    ...
}

好了!你的工作結束了!若使用者想計算面積,可以實現 Func

class Area implements Func {
    public double apply(Rectangle rect) {
        return rect.width * rect.height;
    }
    public double apply(Circle circle) {
        return circle.radius * circle.radius * Math.PI;
    }
}

以計算面積為例,使用者可以這麼撰寫程式了:

var area = new Area();
var rect = new Rectangle(new Point(0, 0), 10, 20);
var circle = new Circle(new Point(0, 0), 10);

System.out.println(rect.apply(area));
System.out.println(circle.apply(area));

類似地,如果使用者想計算周長,可以寫個 Perimeter

class Perimeter implements Func {
    public double apply(Rectangle rect) {
        return rect.width * rect.height;
    }
    public double apply(Circle circle) {
        return circle.radius * circle.radius * Math.PI;
    }
}

這麼一來就可以計算周長了:

var perimeter = new Perimeter();
var rect = new Rectangle(new Point(0, 0), 10, 20);
var circle = new Circle(new Point(0, 0), 10);

System.out.println(rect.apply(perimeter));
System.out.println(circle.apply(perimeter));

嗯?好像不需要 instanceof 了耶!在 Gof 中,Func 的實現是被稱為 Visitor 的角色,不需要 instanceof 的原因在於,Func 實例在 RectangleCircleapply 方法中造訪了 this,方法中的 this 本來就具備型態,編譯器知道要呼叫哪個 Func 實例的哪個 apply,也就是這個行為是在靜態時期就決定的,其實就是 Java 的重載應用。

揭露結構/型態

好處呢?記得一開始設下的限制嗎?Shape 的實作類別只用來封裝幾何資訊,除了規範的 polygon 方法之外,沒有提供其他的方法操作,因為也不知道使用者會用這些資訊做哪些計算。

Shape 的實作類別只用來封裝幾何資訊,它們是作為純綷的資料載體(data carrier),目的就是要揭露它們擁有的資料、擁有的結構,例如,Rectangle 就是有 centerwidthheightCircle 就是有 centerradius

因此 Func 進入 RectangleCircleapply 中造訪,並沒有破壞封裝,資料本來就都是公開的!

由於程式庫由你設計,使用者只能運用你提供的資料載體,這就會出現根據實際型態,取得其中承載的資料之需求,這就是為何一開始的範例,會出現比對型態、抽取資料欄位的類似流程。

其實一開始寫的那些 staticareaperimeter,並沒有什麼不好,就是實作上出現類似針對型態、欄位等模式進行比對的流程,一堆 if/elseinstanceof、cast 讓人看了厭煩罷了,如果你真的想去之而後快,那麼 Visitor 模式會是個思考的方向。

不過實作起來麻煩,許多人因為看到進入方法造訪了 this,還認為 Visitor 破壞了封裝,是個反模式(anti-pattern)…這些其實都是誤會…

誤會有幾方面,例如,你在不該用 Visitor 的地方用了 Visitor,也許用次型態多型就能解決,或者你不知道是什麼情境限制下,才會導致實作時會出現什麼樣的類似流程,為了解決重複問題,才會出現類似 Visitor 的實作。

那麼,到底 Visitor 想解決什麼?剛才談到的,使用者運用你提供的資料載體,會出現根據實際型態,取得其中承載的資料之需求,這時就會出現類似的模式比對流程,既然如此,有沒有想過,如果語言內建了模式比對(pattern matching)的簡潔語法,寫來會是如何呢?

模式比對

其實模式比對這類功能,概念源自於函數式,現代開發者多多少少都認識一些函數式了吧!就算你不願意,語言、程式庫或框架也會逼著你學嘛!

簡單來說,函數式的思考出發點是〈代數資料型態〉,其優先思考資料該具有什麼樣的結構,方才不是談到資料載體就是要揭露擁有的結構嘛!在函數式中,資料的結構是公開的,處理資料的方式往往就是先比對結構來取得需要的資料…這就是模式比對之目的。

只不過,Java 沒有模式比對的直接支援的話,實作時又要看來簡潔一些的話,方式之一就是朝著 Visitor 模式的方向思考。

如果你使用 Java 17 的話,倒是可以更簡單地實現模式比對,資料載體的部份,可以使用 record 類別(在網路上可以找到不少 record 類別的語法與特性說明,這邊就不特別介紹了):

record Point(double x, double y) {}

interface Shape {
    List<Point> polygon();
}
record Rectangle(Point center, double width, double height) implements Shape {
    @Override
    public List<Point> polygon() {
        // 實作…逆時針建立長方形的四個點 …
    }
}
record Circle(Point center, double radius) implements Shape {
    @Override
    public List<Point> polygon() {
        // 實作…逆時針建立 96 邊形的 96 個點 … 96 邊形在人眼看來就像個圓了
    }
}

Java 17 已經正式具備 instanceof 模式比對的功能,寫來會是長這樣:

static double area(Shape shape) {
    if(shape instanceof Rectangle rect) {
        return rect.width() * rect.height();
    }
    else if(shape instanceof Circle circle) {
        return circle.radius() * circle.radius() * Math.PI;
    }
    ...
}   

大多數的情況下,用到 instanceof 都會叫你要三思一下,不過,如果是具備以上情境,使用到 instanceof 合情合理,在沒有模式比對的 Java 版本中,使用一開始的 instanceof 寫法,完全也是沒有錯的喔!反而是硬是不想寫 instanceof,還讓程式變得複雜了。

如果你真的不想用 instanceof,其實 switch 已經實驗性地支援模式比對,在 Java 17 的話還得開啟 Preview 就是了,以下是使用 switch 的版本:

public class Main {
    static double area(Shape shape) {
        return switch(shape) {
            case Rectangle rect -> rect.width() * rect.height();
            case Circle circle -> circle.radius() * circle.radius() * Math.PI;
            ...
        };
    }
  
    public static void main(String[] args) {
        var rect = new Rectangle(new Point(0, 0), 10, 20);
        var circle = new Circle(new Point(0, 0), 10);
        System.out.println(area(rect));
        System.out.println(area(circle));
    }
}

是不是簡單多了,之後 switch 模式比對的功能還會增強,像是在模式比對時指定 centerwidth 之類的,只不過目前語法還未定,只能先期待了。

也就是說,在不具備模式比對的語言中,若想在模式比對時看來簡潔一些,Visitor 模式可以說是不得已而為之的方式(或說是 hack),如果語言支援模式比對等相關的元素,Visitor 模式的概念,基本上就不需要了。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter