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 圖形的幾何資訊:

abstract class Shape {
    final Point center;
    Shape(Point center) {
        this.center = center;
    }
}

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

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

在你的設計中,每個 2D 圖案都會有個幾何中心,這定義在 Shape,你會提供固定的幾個基本 2D 圖案幾何資訊,也許目前是五個吧!只不過上面只列出了其中兩個 RectangleCircle,總之,這些類別只用來封裝幾何資訊,沒有提供方法操作,因為你也不知道使用者會用這些資訊做哪些計算。

你也希望使用者就單純地將這些類別拿來封裝幾何資訊,因此你不允許使用者擴充 Shape,當然,以 Java 來說,以上的寫法是無法阻止使用者擴充 Shape,目前先設定這樣的情境,也能讓你知道,稍後要用的 Java 17 新特性 sealed 類別,到底是什麼東西。

面積/周長

如果使用者要計算 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 耶!不好的訊號!沒辦法,因為你告訴使用者,絕不能擴充 Shape,只能拿到幾何資訊後,做他想做的事!

或許使用者又想計算周長了,因此自定義了一個 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)

abstract class Shape {
    final Point center;
    Shape(Point center) {
        this.center = center;
    }
    
    abstract double apply(Func func);
}

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

    @Override
    double apply(Func func) {
        return func.apply(this);
    }
}

class Circle extends Shape {
    final double radius;
    
    Circle(Point center, double radius) {
        super(center);
        this.radius = radius;
    }
    
    @Override
    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 等類別只用來封裝幾何資訊,沒有提供方法操作,因為你也不知道使用者會用這些資訊做哪些計算,你也希望使用者就單純地將這些類別拿來封裝幾何資訊,因此你不允許使用者擴充 Shape

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

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

有時候,你會想要掌握程式庫或應用程式中,某型態的子型態就是你定義的那幾個,因為你很清楚商務領域中就只會用到這幾個型態,也就是說,若領域中建模對象的型態邊界已知,阻止其他擴充可能性,就會是個需求,這也是為何要限制使用者不得擴充 Shape 的原因。

在這類需求下,使用者運用你提供的資料載體,就會出現根據實際型態,取得其中承載的資料之需求,這就是為何一開始的範例,會出現類似實作流程的原因。

其實一開始寫的那些 staticareaperimeter,並沒有什麼不好,就是實作上出現類似流程,讓人看了厭煩罷了,如果你真的想去之而後快,那麼 Visitor 模式會是個思考的方向。

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

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

那麼,到底 Visitor 想解決什麼?剛才談到的「使用者運用你提供的資料載體,就會出現根據實際型態,取得其中承載的資料之需求」…也就是模式比對(pattern matching)。

模式比對

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

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

只不過,Java 沒有模式比對的直接支援的話,實作時要長得像模式比對,方式之一就是朝著 Visitor 模式的方向思考。

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

record Point(double x, double y) {}

abstract sealed interface Shape permits Rectangle, Circle {}
record Rectangle(Point center, double width, double height) implements Shape {}
record Circle(Point center, double radius) implements Shape {}

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 模式的概念,基本上就不需要了。