🎯 什麼情境該想到我
當你「有一個穩定的物件結構(如 AST),但要對它做的操作一直在增加」的時候——
編譯器、linter、序列化器、查詢建構器、文件轉換。
★ 但在 Go 和 TS 裡,通常有更簡單的替代方案——本頁後半就在講這個。
⚙️ 怎麼用
意圖
表示一個作用於某物件結構中各元素的操作。它讓你可以在不改變各元素類別的前提下,定義作用於這些元素的新操作。
★ 它真正解決的問題:表達式問題(Expression Problem)
你有兩個維度會成長:型別(AST 節點種類)與操作(要對它做的事)。
任何一種組織方式,都只能讓其中一個維度容易擴充:
| 組織方式 | 加新型別 | 加新操作 |
|---|---|---|
| 每個型別自己帶方法(OOP 傳統) | ★ 容易(加一個類別) | ❌ 難(要改所有類別) |
| ★ 訪問者 | ❌ 難(要改所有 Visitor) | ★ 容易(加一個 Visitor) |
★ 所以訪問者的適用條件很明確:型別穩定、操作一直加。
AST 正是如此——語言的語法節點幾年才改一次,但 linter 規則、優化 pass、輸出格式一直在加。★ 如果你的型別會一直加,訪問者是錯的選擇。
雙重分派(Double Dispatch)
// 經典版:每個節點有 Accept,每個訪問者有 VisitXxx
type Node interface{ Accept(Visitor) any }
type Visitor interface {
VisitNumber(*Number) any
VisitAdd(*Add) any
VisitMul(*Mul) any
}
type Number struct{ Val float64 }
func (n *Number) Accept(v Visitor) any { return v.VisitNumber(n) } // ★ 第二次分派
type Add struct{ L, R Node }
func (a *Add) Accept(v Visitor) any { return v.VisitAdd(a) }
// 新增一個操作 = 新增一個 Visitor,完全不動 Node
type Evaluator struct{}
func (e Evaluator) VisitNumber(n *Number) any { return n.Val }
func (e Evaluator) VisitAdd(a *Add) any {
return a.L.Accept(e).(float64) + a.R.Accept(e).(float64) // ★ 第一次分派
}★ 「雙重分派」= 最終執行哪個方法,取決於「節點型別」和「訪問者型別」兩者。
在只有單分派(依接收者型別)的語言裡,要靠 Accept + VisitXxx 兩次呼叫模擬出來。
★ Go 的替代方案:type switch
func Eval(n Node) float64 {
switch t := n.(type) { // ★ 一個函式搞定,不需要 Accept 和 Visitor 介面
case *Number: return t.Val
case *Add: return Eval(t.L) + Eval(t.R)
case *Mul: return Eval(t.L) * Eval(t.R)
default:
panic(fmt.Sprintf("unknown node %T", n)) // ★ 只能在執行期發現遺漏
}
}| 訪問者 | type switch | |
|---|---|---|
| 程式碼量 | 多(介面 + Accept + 每個 Visit) | ★ 少 |
| 加新操作 | 加一個 Visitor | ★ 加一個函式 |
| 漏處理某個型別 | ★ 編譯期發現(沒實作介面方法) | ❌ 執行期才發現 |
| 可讀性 | 分散在多處 | ★ 集中一處 |
★ Go 的取捨很清楚:type switch 簡單得多,但失去了「窮盡性檢查」。
補救:用一個測試窮舉所有節點型別,確保每個 switch 都處理到;
或用//go:generate產生檢查。實務建議:Go 裡先用 type switch。 只有在「操作非常多、且遺漏的代價很高」時才考慮訪問者。
(go/ast標準庫提供ast.Inspect(node, func(ast.Node) bool)——它是「走訪」而非完整的訪問者。)
★ TypeScript 的替代方案:判別聯集(有窮盡性檢查)
type Node =
| { kind: "num"; val: number }
| { kind: "add"; l: Node; r: Node }
| { kind: "mul"; l: Node; r: Node };
function evaluate(n: Node): number {
switch (n.kind) {
case "num": return n.val;
case "add": return evaluate(n.l) + evaluate(n.r);
case "mul": return evaluate(n.l) * evaluate(n.r);
default: {
const _exhaustive: never = n; // ★ 加了新 kind 卻沒處理 → 編譯錯誤
throw new Error(`unhandled: ${JSON.stringify(_exhaustive)}`);
}
}
}★ TypeScript 同時拿到兩邊的好處:
type switch 的簡潔 + 訪問者的編譯期窮盡性檢查。
在 TS 裡幾乎沒有理由實作訪問者模式(見 工具-判別聯集)。這是「語言特性讓設計模式消失」最清楚的一個例子——
訪問者模式存在,是因為 1994 年的 C++/Java 沒有代數資料型別與模式比對。
真實案例
| 案例 | 說明 |
|---|---|
| 編譯器的 pass | 型別檢查、優化、程式碼產生各是一個 visitor |
| Linter 規則 | 每條規則是一個 visitor |
| ORM 的查詢建構 | 把 AST 翻成不同方言的 SQL |
| 文件轉換 | Markdown AST → HTML / PDF / 純文字 |
| Babel / ESLint | ★ JS 生態最大的訪問者使用者 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 型別會一直增加 → 不要用訪問者(表達式問題的另一邊)。加一個節點型別要改所有 Visitor。
- ★ Go 裡預設用 type switch,訪問者只在操作極多時才划算。TS 裡幾乎永遠用判別聯集。
- 訪問者需要存取節點的內部狀態 → 節點得暴露欄位或 getter,某種程度上破壞了封裝。
- ★ 走訪順序的控制權在哪:
- 內部走訪(節點的
Accept自己遞迴子節點):簡單,但 visitor 無法控制順序或剪枝 - 外部走訪(visitor 自己決定要不要往下走):彈性高,但每個 visitor 都要處理遞迴
→ 大多數實務情境要「外部走訪」(需要提前停止、需要 pre/post order)。
- 內部走訪(節點的
- 遞迴深度:深 AST(尤其是惡意構造的巢狀輸入)會爆堆疊。不可信輸入要設深度上限(見 組合模式)。
- 不要為了「解耦」而用。訪問者不會讓程式更解耦——它只是把耦合的方向轉了 90 度。
- 回傳值型別:Go 沒有泛型介面方法,
Accept(v Visitor) any要型別斷言。
可以為每個操作定義自己的 Visitor 介面(回傳具體型別)來避開,但程式碼會更多。