技術筆記 Visitor Pattern
GoF · 行為型設計模式

Visitor Pattern

雙重分派accept / visit開閉原則零 instanceof

把「操作邏輯」從「資料結構」中完全抽離,讓你在不修改原始類別的前提下隨時加入新的操作。一句話記住:讓操作者去找資料,而不是讓資料知道所有操作——資料類別只需要開一扇門(accept),外來的 Visitor 進門後自己決定要做什麼。

GoF
行為型模式
2
核心角色
OCP
完美符合開閉原則
0
instanceof 需求

兩個生活比喻

稅務員拜訪工廠
稅務員(Visitor)拜訪各種工廠(Element)。每間工廠都接待(accept)稅務員,稅務員根據工廠種類執行不同計稅邏輯——工廠完全不需要知道計算公式。稅法修改時只需換一位稅務員,工廠不動。
醫師巡房
外科、內科、牙科醫師(不同 Visitor)主動前往病患(Element)進行各自的診療。新增牙科醫師時不需要修改任何病患的資料結構——資料不動,新增操作只需新增角色。

痛點:為什麼不直接用 if-else?

下面兩段程式碼結構一模一樣,只差在呼叫的方法名——這就是問題本身。

// 存檔邏輯
for (Setting s : settings) {
  if (s instanceof VipSetting) {
    saveVip((VipSetting) s);
  } else if (s instanceof PfamSetting) {
    savePfam((PfamSetting) s);
  }
}

// 新增「匯出」功能?整段複製!
for (Setting s : settings) {
  if (s instanceof VipSetting) {
    exportVip((VipSetting) s);
  } else if (s instanceof PfamSetting) {
    exportPfam((PfamSetting) s);
  }
}
改動點數量(操作種類 × 型別數量)

if-else 的改動點是乘積——每多一種操作,就要為每個型別各寫一個分支;Visitor 是相加,新增操作只多一個類別。

違反開閉原則
每次新增功能,原有程式碼就必須打開修改,不符合「對修改封閉」的 OCP 精神。
執行期才出錯
新增子類別後忘了補 else if,編譯照樣通過,但執行期靜悄悄跳過——這種不會報錯的 bug 最難察覺。
重複程式碼膨脹
存檔、匯出、驗證各需要一套結構完全相同的 if-else,散落各處難以統一維護。

Java 天生只支援單一分派——方法呼叫只根據「呼叫者」的型別決定。Visitor 透過兩次呼叫,讓「呼叫者型別」與「參數型別」都能影響最終執行的方法,這就是零 instanceof 的來源。

呼叫鏈演練

點播放,看呼叫如何從 Client 一路跳到精確的 visit(VipSetting)。

Clients.accept(visitor)
→
Element 子類別VipSetting
.accept(visitor)
→
傳回 Visitorvisitor
.visit(this)
→
Visitor 多載visit(VipSetting)
精確命中
點「播放動畫」觀察雙重分派的執行順序。
第一次分派 — 多型 s.accept(visitor)
Java 根據 s 的執行期實際型別(VipSetting / PfamSetting)選擇要呼叫哪個 accept() 實作。這是標準的 Java 多型行為。
第二次分派 — 多載 visitor.visit(this)
在 accept() 內部,this 的靜態型別就是子類別(VipSetting),Java 的多載解析在編譯期即可精確匹配到 visit(VipSetting)。
為什麼不能讓 Client 直接呼叫 visitor.visit(s)?因為 s 的靜態型別是基底介面 Setting,而 Java 的多載在編譯期就固定解析,找不到對應的多載。必須靠 this 在子類別內部傳遞精確型別,才能觸發正確的多載。

UML 類別結構

虛線框是介面,實線框是實作。左邊是資料體系,右邊是操作體系,兩邊只靠 accept/visit 相接。

«interface»
Setting
+ accept(v: Visitor<R>): R
呼叫 →
«interface»
SettingVisitor<R>
+ visit(s: VipSetting): R
+ visit(s: PfamSetting): R
VipSetting
tierLevel: String
maxSeats: int
accept(v)
PfamSetting
allowBulk: boolean
 
accept(v)
SaveVisitor
visit(VipSetting)
visit(PfamSetting)
ValidateVisitor
visit(VipSetting)
visit(PfamSetting)
Element 的 accept() 呼叫 Visitor 的 visit(this) —— 兩者交互構成雙重分派

常見疑問

不對。Java 的多載(Overloading)在編譯期根據靜態型別解析;多型(Polymorphism / Override)才在執行期根據動態型別解析。這也是為什麼在子類別 accept() 內呼叫 visitor.visit(this) 時,this 的靜態型別是子類別,多載可以精確命中正確的 visit() 多載版本。
正確,這看起來像是重複,但其實是刻意的設計。每個子類別的 return visitor.visit(this) 程式碼文字相同,但 this 的靜態型別不同,所以實際觸發的 visit() 多載各不相同。這就是 Visitor Pattern 運作的核心機制。
Strategy 替換「單一物件」的演算法(例如排序策略),操作的對象是單一型別。Visitor 則對「多種不同型別」組成的物件結構執行操作,並透過雙重分派自動選擇對應的處理方法。當你的資料結構有多個子類別、需要對它們執行多種操作時,才是 Visitor 的舞台。

場景:VipSetting 和 PfamDeleteSetting 兩種設定,需要支援「儲存」與「驗證」兩種操作,未來還可能新增「匯出 CSV」「API 序列化」。以下是四個建立步驟。

1. 定義 Visitor 介面(操作者的契約)

宣告每個子類別對應的 visit() 多載。泛型 R 讓儲存回傳 Void、驗證回傳 List<String>,同一個介面通吃不同回傳型別。

public interface SettingVisitor<R> {
    R visit(VipSetting setting);
    R visit(PfamDeleteSetting setting);
    // 新增 Setting 子類別 → 在這裡補一行
    // 編譯器會強制所有 Visitor 實作都跟著更新
}

2. Setting 介面與子類別加上 accept()

每個子類別的 accept() 程式碼文字完全相同,但 this 的靜態型別不同——這正是雙重分派的核心。

public interface Setting {
    <R> R accept(SettingVisitor<R> visitor);
}

public class VipSetting implements Setting {
    private String tierLevel;
    private int    maxSeats;
    // getters...

    @Override
    public <R> R accept(SettingVisitor<R> visitor) {
        return visitor.visit(this); // this = VipSetting,型別精確
    }
}

public class PfamDeleteSetting implements Setting {
    private boolean allowBulkDelete;
    // getters...

    @Override
    public <R> R accept(SettingVisitor<R> visitor) {
        return visitor.visit(this); // this = PfamDeleteSetting
    }
}

3. 實作具體 Visitor(每種操作一個類別)

每種操作獨立成類,彼此互不干擾。未來新增「匯出 JSON」只需新增一個類別,其他程式碼完全不動。

// 操作一:儲存
public class SaveVisitor implements SettingVisitor<Void> {
    private final SeatSettingRepository repo;

    @Override
    public Void visit(VipSetting s) {
        repo.saveVip(s.getTierLevel(), s.getMaxSeats());
        return null;
    }
    @Override
    public Void visit(PfamDeleteSetting s) {
        repo.savePfam(s.isAllowBulkDelete());
        return null;
    }
}

// 操作二:驗證(只需新增一個類別,零改動原有程式碼)
public class ValidateVisitor implements SettingVisitor<List<String>> {
    @Override
    public List<String> visit(VipSetting s) {
        List<String> errors = new ArrayList<>();
        if (s.getMaxSeats() <= 0)
            errors.add("maxSeats 必須大於 0");
        return errors;
    }
    @Override
    public List<String> visit(PfamDeleteSetting s) {
        return List.of(); // 此型別無需驗證
    }
}

4. 使用端(零 instanceof、零強轉型)

List<Setting> settings = List.of(
    new VipSetting("GOLD", 4),
    new PfamDeleteSetting(true)
);

// 儲存
SaveVisitor saver = new SaveVisitor(repo);
settings.forEach(s -> s.accept(saver));

// 驗證
ValidateVisitor validator = new ValidateVisitor();
List<String> allErrors = settings.stream()
    .flatMap(s -> s.accept(validator).stream())
    .collect(Collectors.toList());

// 未來新增「匯出 JSON」→ 只加 JsonExportVisitor,其他全部不動
OCP 在此體現:新增操作 → 只加一個 Visitor 類別。新增子型別 → 在 Visitor 介面加一行,編譯器強制所有 Visitor 實作跟著補上,不會遺漏。

優缺點比較

Visitor 不是萬用解法,兩欄要一起看:左邊成立的前提,正好是右邊不成立的時候。

優點(適合用的情境) 缺點(應避開的情境)
輕鬆新增操作新功能只需新增一個 Visitor 類別,所有 Element 程式碼完全不動,完美符合 OCP。 Element 子類別常變動時成本高每新增一個 Setting 子類別,就必須改 Visitor 介面 + 所有 Visitor 實作,改動點反而更多。
強型別,消滅 instanceof編譯期即可確認你處理了所有子類別,遺漏任何 visit() 就會編譯失敗。 可能破壞封裝Visitor 需要讀取 Element 的內部狀態,可能被迫開放原本不該對外公開的 getter。
讓 Domain Class 保持純粹Setting 只存資料,複雜的業務邏輯(儲存、驗證、序列化)全部交給 Visitor,職責單一清晰。 操作種類少時過度設計只有一兩種操作卻引入 Visitor,等於憑空增加介面與類別,把簡單問題複雜化。
Visitor 可以累積狀態Visitor 實例持有欄位,可在遍歷過程中累積中間結果(統計總價、收集錯誤清單等)。 泛型語法不直覺SettingVisitor<R> 的泛型宣告對初學者較陌生,還需要理解 Java 的多載解析規則。

使用決策指引

操作種類是否可能持續增加?
是(存檔、匯出、驗證、API 轉換…)→ Visitor 優勢明顯,值得採用。
否(只有 1–2 種)→ if-else 或 Strategy 就夠了。
Element 子類別結構是否穩定?
是(不常新增型別)→ 適合。
否(常新增子類別)→ 每加一個型別就要動所有 Visitor,考慮 Map 分派或讓子類別自帶方法。
需要編譯期保證「所有型別都被處理」?
是 → Visitor 介面強制實作所有 visit(),遺漏就編譯失敗,安全性最高。這通常是選它最實際的理由。

實際應用場景

編譯器 / 直譯器
AST(抽象語法樹)節點穩定,但需要「語義分析」「程式碼生成」「格式化」等多種操作。Visitor 是編譯器設計的標準方案。
報表 / 匯出系統
同一份資料結構要匯出 CSV、生成 PDF、發送 email、存入資料庫——多種輸出操作,資料結構不動。
複雜驗證邏輯
不同子類別需要各自的驗證規則,且驗證邏輯可能隨業務需求獨立演進,不應該混進 Domain Class。
GUI 元件樹
對複雜 UI 元件樹執行「渲染」「序列化狀態」「無障礙掃描」等操作,元件結構穩定但操作持續增加。

練習題 共 4 題,選完會顯示解析

0 / 4
01 / 4
在 Visitor Pattern 中,「雙重分派」指的是什麼?
A 同一個方法被呼叫兩次
B ① 多型根據 Element 執行期型別選擇 accept(),② 多載根據 this 靜態型別選擇 visit()
C 使用兩個不同的 Visitor 物件
D Element 繼承兩層父類別
解析
第一次是 s.accept(visitor),多型根據 s 的執行期型別(VipSetting)選擇 accept() 實作;第二次是 visitor.visit(this),多載根據 this 的靜態型別(VipSetting)精確匹配 visit(VipSetting)。兩次分派共同決定最終行為。
02 / 4
為什麼 accept() 裡必須寫 visitor.visit(this),而不能讓 Client 直接呼叫 visitor.visit(s)?
A 為了封裝,不讓外部直接呼叫 visit()
B s 的靜態型別是基底介面,Java 多載在編譯期就固定,無法匹配到子類別的 visit() 多載
C 效能考量,減少一次虛擬方法查找
D Java 不支援將 interface 型別傳入多載方法
解析
Java 多載(overloading)在編譯期根據靜態型別解析。當 Client 持有的 s 靜態型別是 Setting 介面,編譯器只能找到 visit(Setting)(若存在),無法選擇 visit(VipSetting)。在 accept() 內部,this 的靜態型別是 VipSetting,才能精確匹配。
03 / 4
下列哪個場景「最不適合」使用 Visitor Pattern?
A 編譯器 AST 需要「語義分析」「程式碼生成」「格式化輸出」,且 AST 節點種類穩定
B 電商商品種類每月新增,只需要計算訂單總金額這一種操作
C 穩定的財務資料結構需要「匯出 CSV」「存入資料庫」「發送郵件」
D 穩定的座位設定需要存檔、驗證、API 序列化多種操作
解析
答案 B 同時命中兩個反指標:① 操作只有一種(計算總金額),不需要 Visitor 的分離優勢;② Element 子類別(商品種類)每月新增,每次都要修改 Visitor 介面和所有實作,維護成本遠大於收益。此場景更適合在 Product 基底類別定義 getPrice() 抽象方法。
04 / 4
現有 SaveVisitor 和 ValidateVisitor。要新增「匯出 JSON」功能,正確步驟是?
A 修改 Setting 介面、修改所有 Setting 子類別、新增 JsonExportVisitor
B 修改 Visitor 介面,新增 exportJson() 方法
C 只需新增一個 JsonExportVisitor 實作現有 SettingVisitor 介面,其他完全不動
D 修改 SaveVisitor,在其中加入 JSON 匯出邏輯
解析
新增「操作」是 Visitor Pattern 的強項——只需新增一個 Visitor 類別,實作已有的 SettingVisitor<R> 介面即可。Setting 介面、Setting 子類別、SaveVisitor、ValidateVisitor 全部不需要修改。這正是開閉原則(對擴充開放,對修改封閉)的具體體現。

複習閃卡

點卡片翻面看答案(共 6 張)

QUESTION
Visitor Pattern 解決什麼核心問題?
點擊翻面
ANSWER
將「操作邏輯」從「資料結構」抽離,讓新增操作不需修改原有類別(符合 OCP)
點擊翻回
QUESTION
雙重分派的兩次分派分別靠什麼完成?
點擊翻面
ANSWER
① accept() 靠多型(執行期型別)② visit(this) 靠多載(編譯期靜態型別)
點擊翻回
QUESTION
accept() 內部為何寫 visitor.visit(this)?
點擊翻面
ANSWER
this 的靜態型別是子類別,Java 多載才能在編譯期精確匹配到正確的 visit() 方法
點擊翻回
QUESTION
何時「不」該用 Visitor Pattern?
點擊翻面
ANSWER
① Element 子類別常新增 ② 操作只有 1-2 種 ③ 結構簡單、if-else 更直覺
點擊翻回
QUESTION
Visitor 如何體現開閉原則?
點擊翻面
ANSWER
新增操作 = 新增 Visitor 類別(對擴充開放);原有 Setting 和 Visitor 完全不動(對修改封閉)
點擊翻回
QUESTION
Visitor vs Strategy Pattern 差在哪?
點擊翻面
ANSWER
Strategy 替換「單一物件」的演算法;Visitor 對「多種不同型別」的結構執行操作
點擊翻回