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 圖案最後都會轉換為多邊形,Shape
的 polygon
方法要傳回一組逆時針順序的點,代表轉換後的多邊形,你會提供固定的幾個基本 2D 圖案幾何資訊,也許目前是五個吧!只不過上面只列出了其中兩個 Rectangle
與 Circle
,總之,這些類別只用來封裝幾何資訊,除了規範的 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
耶!不好的訊號!沒辦法,因為 Circle
、Rectangle
只作為純綷的資料載體,而且程式庫是由你設計的,使用者不能變動原始碼,只能拿到幾何資訊後,做他想做的事!
或許使用者又想計算周長了,因此自定義了一個 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)
方法,其中 Rectangle
、Circle
在實作時,都是 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
實例在 Rectangle
、Circle
的 apply
方法中造訪了 this
,方法中的 this
本來就具備型態,編譯器知道要呼叫哪個 Func
實例的哪個 apply
,也就是這個行為是在靜態時期就決定的,其實就是 Java 的重載應用。
揭露結構/型態
好處呢?記得一開始設下的限制嗎?Shape
的實作類別只用來封裝幾何資訊,除了規範的 polygon
方法之外,沒有提供其他的方法操作,因為也不知道使用者會用這些資訊做哪些計算。
Shape
的實作類別只用來封裝幾何資訊,它們是作為純綷的資料載體(data carrier),目的就是要揭露它們擁有的資料、擁有的結構,例如,Rectangle
就是有 center
、width
、height
,Circle
就是有 center
、radius
。
因此 Func
進入 Rectangle
、Circle
的 apply
中造訪,並沒有破壞封裝,資料本來就都是公開的!
由於程式庫由你設計,使用者只能運用你提供的資料載體,這就會出現根據實際型態,取得其中承載的資料之需求,這就是為何一開始的範例,會出現比對型態、抽取資料欄位的類似流程。
其實一開始寫的那些 static
的 area
、perimeter
,並沒有什麼不好,就是實作上出現類似針對型態、欄位等模式進行比對的流程,一堆 if/else
、instanceof
、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
模式比對的功能還會增強,像是在模式比對時指定 center
、width
之類的,只不過目前語法還未定,只能先期待了。
也就是說,在不具備模式比對的語言中,若想在模式比對時看來簡潔一些,Visitor 模式可以說是不得已而為之的方式(或說是 hack),如果語言支援模式比對等相關的元素,Visitor 模式的概念,基本上就不需要了。