🎯 什麼情境該想到我

當你「有一個穩定的物件結構(如 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 介面(回傳具體型別)來避開,但程式碼會更多。

🔗 相關工具