編碼 · 設計模式
訪問者模式(Visitor)詳解與範例
簡答
訪問者(Visitor)是一種行為型設計模式,讓你在不修改一組相關類別的情況下為它們新增操作。每個操作寫成一個訪問者物件,針對每種元素型別各有一個方法,而每個元素都有一個 accept(visitor) 方法,用來呼叫對應的訪問者方法。這種技術稱為雙重分派,會同時依據元素型別與訪問者型別來決定要執行的程式碼。編譯器、linter 和程式碼轉換工具都用訪問者來走訪語法樹。
意圖與要解決的問題
意圖:表示一個要對物件結構中各元素執行的操作。訪問者讓你在不更動元素所屬類別的情況下定義新的操作。它是《設計模式》(Gang of Four,簡稱 GoF)一書中的行為型模式之一。
假設一個繪圖程式有三個圖形類別:Circle、Rectangle 和 Group。隨著時間推移,你需要對它們做許多操作:計算總面積、匯出成 SVG、匯出成 JSON、計算圖形數量、檢查是否重疊。如果每個操作都成為每個圖形類別上的方法,圖形類別就會塞滿與「身為圖形」無關的程式碼,而每個新操作都得改動所有類別。
有了訪問者,每個操作都成為一個類別(面積訪問者、SVG 訪問者),裡面包含處理每種圖形型別的程式碼。圖形只需要一個通用的方法 accept。新增操作就是寫一個新的訪問者;圖形類別維持不變。
結構與參與者
| 角色 | 職責 |
|---|---|
| 訪問者 | 針對每種具體元素型別各有一個 visit 方法的介面,例如 visitCircle 和 visitRectangle |
| 具體訪問者 | 為所有元素型別實作一個操作,並可累積結果 |
| 元素 | 帶有 accept(visitor) 方法的介面 |
| 具體元素 | 透過呼叫自己型別對應的 visit 方法並傳入自身,來實作 accept |
| 物件結構 | 由元素組成的集合或樹,例如文件或語法樹,訪問者會在其上走訪 |
為什麼需要 accept:在 TypeScript、Java 和 C# 中,方法呼叫是依據被呼叫物件的型別來選擇實作(單一分派)。accept 呼叫依元素型別分派,而其內部的呼叫,例如 visitor.visitCircle(this),則依訪問者型別分派。兩者合起來就形成了雙重分派。
TypeScript 實作
訪問者介面的回傳型別是泛型,因此一個訪問者可以計算數字,另一個可以產生字串。
interface ShapeVisitor<R> {
visitCircle(c: Circle): R;
visitRectangle(r: Rectangle): R;
visitGroup(g: Group): R;
}
interface Shape {
accept<R>(visitor: ShapeVisitor<R>): R;
}
class Circle implements Shape {
constructor(readonly radius: number) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitCircle(this); }
}
class Rectangle implements Shape {
constructor(readonly width: number, readonly height: number) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitRectangle(this); }
}
class Group implements Shape {
constructor(readonly children: Shape[]) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitGroup(this); }
}
class AreaVisitor implements ShapeVisitor<number> {
visitCircle(c: Circle) { return Math.PI * c.radius ** 2; }
visitRectangle(r: Rectangle) { return r.width * r.height; }
visitGroup(g: Group): number {
return g.children.reduce((sum, child) => sum + child.accept(this), 0);
}
}
class SvgVisitor implements ShapeVisitor<string> {
visitCircle(c: Circle) { return '<circle r="' + c.radius + '"/>'; }
visitRectangle(r: Rectangle) { return '<rect width="' + r.width + '" height="' + r.height + '"/>'; }
visitGroup(g: Group): string { return "<g>" + g.children.map((c) => c.accept(this)).join("") + "</g>"; }
}
const drawing = new Group([new Circle(1), new Rectangle(2, 3), new Group([new Rectangle(1, 1)])]);
console.log(drawing.accept(new AreaVisitor()).toFixed(2)); // 10.14
console.log(drawing.accept(new SvgVisitor()));
// <g><circle r="1"/><rect width="2" height="3"/><g><rect width="1" height="1"/></g></g>第三個操作,例如計算圖形數量,就是實作 ShapeVisitor<number> 的新類別。所有圖形類別都不必更動。
TypeScript 的替代方案:聯集型別與 switch
在 TypeScript 中,可辨識聯集(discriminated union)搭配窮盡式 switch,能以更少的程式碼獲得許多相同的好處。如果漏掉某個情況,編譯器會回報錯誤,取代了訪問者介面所提供的安全性:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "group"; children: Shape[] };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.width * s.height;
case "group": return s.children.reduce((sum, c) => sum + area(c), 0);
default: {
const unreachable: never = s; // compile error if a kind is not handled
return unreachable;
}
}
}
console.log(area({ kind: "group", children: [{ kind: "rect", width: 2, height: 3 }] })); // 6每個新操作就是一個新函式。這種風格適合像 JSON 或語法樹這樣的純資料,也是許多 TypeScript 程式碼庫用來取代類別式訪問者的做法。
何時使用、何時不用
- 當元素類別的集合穩定,而你需要對它們進行許多互不相關的操作時使用,例如對語法樹進行求值、列印、型別檢查和最佳化。
- 用來把操作的程式碼放在一起:所有 SVG 匯出都在同一個訪問者中,而不是散落在許多類別。
- 當訪問者需要在走訪結構時累積狀態時使用,例如符號表或累計總和。
- 元素型別經常改變時不要使用;每次變更都會波及所有訪問者。
- 元素很少、操作又簡單時不要使用。類別上的方法,或帶有
switch的函式,更容易閱讀。 - 請注意,訪問者通常需要存取元素的資料,這可能迫使你把欄位改為公開。
實際應用
- Babel 外掛就是訪問者:一個以節點型別命名方法的物件,例如
Identifier或CallExpression,由@babel/traverse在走訪語法樹時呼叫。 - ESLint 規則回傳一個以節點型別為鍵的訪問者方法物件,ESLint 在走訪期間呼叫它們。
- Python 的
ast.NodeVisitor與ast.NodeTransformer類別會呼叫以節點類別命名的visit_方法,例如visit_Name。 - Java 的
java.nio.file.FileVisitor搭配Files.walkFileTree使用,會針對檔案樹中的每個目錄與檔案接收回呼。
相關模式
| 模式 | 與訪問者的關係 |
|---|---|
| 組合 | 訪問者常套用在組合結構上;群組的 visit 方法會造訪它的子節點,如範例所示。 |
| 直譯器 | 直譯器對語法樹求值;訪問者可以實作該求值,以及對同一棵樹的其他操作。 |
| 迭代器 | 迭代器負責走訪元素;訪問者定義對每個元素的操作。兩者可以結合。 |
| 策略 | 策略為一個情境改變一種演算法;訪問者則把針對多種元素型別的一個操作整合在一起。 |
常見問題
什麼是雙重分派?
雙重分派是指所執行的程式碼取決於兩個物件的型別。在訪問者模式中,element.accept(visitor) 選出元素型別,而其內部的 visitor.visitX(this) 選出訪問者型別。
什麼時候不該用訪問者模式?
經常新增元素型別時應避免使用,因為每個新型別都需要修改所有訪問者。對於少數簡單的操作,它也會增加不值得的繁瑣程度。
訪問者在 TypeScript 中有用嗎?
對基於類別的物件結構很有用。對於純資料,可辨識聯集搭配窮盡式 switch 通常能以更少的程式碼和完整的型別檢查達到相同效果。
為什麼 Babel 和 ESLint 使用訪問者?
兩者都處理含有許多節點型別的語法樹,並支援許多獨立的外掛或規則。訪問者讓每個外掛或規則只處理自己關心的節點型別,而走訪則由工具負責。