🎯 什麼情境該想到我

當你「有一個樹狀結構,而且希望『葉節點』和『容器』能用同一種方式對待」的時候——
檔案系統、UI 元件樹、選單、組織架構、運算式 AST、巢狀的權限群組。

⚙️ 怎麼用

意圖

把物件組合成樹狀結構以表示「部分—整體」的階層。組合模式讓客戶端對「單一物件」和「組合物件」的使用具有一致性。

Component(介面)
    ▲
    ├── Leaf(葉:實際做事)
    └── Composite(容器:持有一堆 Component)
              └──▶ children []Component      ★ 遞迴結構

★ 關鍵:Composite 持有的是 Component,而它自己也是 Component —— 所以可以無限巢狀。

★ Go 的長相

type Node interface {
    Size() int64
    Name() string
}
 
// ── 葉 ──
type File struct {
    name string
    size int64
}
func (f File) Size() int64  { return f.size }
func (f File) Name() string { return f.name }
 
// ── 容器 ──
type Dir struct {
    name     string
    children []Node          // ★ 持有 Node,而 Dir 自己也是 Node
}
func (d Dir) Name() string { return d.name }
func (d Dir) Size() int64 {
    var total int64
    for _, c := range d.children {
        total += c.Size()    // ★ 遞迴 —— 不需要知道 c 是檔案還是目錄
    }
    return total
}
 
// 使用端完全不區分
func report(n Node) {
    fmt.Printf("%s: %d bytes\n", n.Name(), n.Size())
}

★ 價值就在最後那個 report:它不需要 if isDir(...)。
「消掉呼叫端的型別判斷」是組合模式唯一的目的。

★ 「透明」vs「安全」:GoF 沒有給出答案的取捨

問題:Add(child)、Remove(child) 這些方法該放在哪?

作法放哪優點缺點
透明(transparent)放在 Component 介面上,葉節點也要實作客戶端完全不用區分★ 葉節點的 Add 只能回錯誤或 panic——型別系統騙了你
安全(safe)只放在 Composite 上★ 型別安全,葉節點不會有無意義的方法客戶端要做型別斷言才能加子節點

Go 的建議做法:安全版 + 小介面

type Node interface{ Size() int64; Name() string }   // ★ 只放共通的
 
type Container interface {                            // ★ 容器能力另外一個介面
    Node
    Add(Node)
}
 
// 需要時才斷言
if c, ok := n.(Container); ok {
    c.Add(File{"x.txt", 100})
}

★ 這符合 Go 的 「介面小而隱式」(見 工具-Go介面小而隱式)——
不要為了「統一」而讓葉節點背負它做不到的方法。

TypeScript 的長相

type Node =
  | { kind: "file"; name: string; size: number }
  | { kind: "dir";  name: string; children: Node[] };
 
// ★ TS 更自然的做法是 discriminated union + 遞迴函式
function size(n: Node): number {
  switch (n.kind) {
    case "file": return n.size;
    case "dir":  return n.children.reduce((s, c) => s + size(c), 0);
  }
}

★ TS 的判別聯集 + 純函式,常常比 class 階層更好用:

  • switch 有窮盡性檢查(加了新的 kind 會編譯錯,見 工具-判別聯集)
  • 新增操作(size、count、render)只要加一個函式,不用改任何型別
    ← 這其實是 訪問者模式 想解決的問題,而 TS 用語言特性直接解掉了

真實案例

案例葉 / 容器
檔案系統檔案 / 目錄
DOM、React 元件樹文字節點 / 元素節點
運算式 AST字面量 / 運算子節點
fs.WalkDir(Go 1.16+)★ 標準庫對檔案樹的遞迴走訪
巢狀權限群組使用者 / 群組

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 遞迴深度。深樹(或惡意輸入構造的深巢狀 JSON/XML)會爆堆疊。
    → 對不可信輸入一定要設深度上限,或改用顯式堆疊(見 深度優先搜尋)。
    這是真實的 DoS 攻擊面。
  • ★ 環會造成無限遞迴。「樹」的前提是無環,但如果結構可以被任意組裝(A 包含 B,B 又包含 A),
    Size() 會無限遞迴直到堆疊爆。→ 加入時檢查,或走訪時記錄 visited。
  • 遞迴計算可能很貴。每次 Size() 都走全樹 → 大樹上 且重複。
    → 需要頻繁查詢就快取結果並在修改時失效(但那會引入一致性問題)。
  • ★ 「過度統一」是常見錯誤。硬把不相似的東西塞進同一個 Component 介面,
    結果介面變成所有方法的聯集,每個實作都有一半方法回 ErrNotSupported。
    → 介面只放真正共通的東西。
  • 父指標要不要有:需要往上走(找路徑、冒泡事件)就要 parent,但會產生循環參照
    (Go 的 GC 沒問題,但序列化會無限迴圈——記得在 JSON tag 上 -)。
  • 不需要遞迴結構就別用。只是「一個 list」不是 Composite。

🔗 相關工具