編碼 · 設計模式
組合模式詳解與範例
簡答
組合是一種結構型設計模式,讓你把物件組合成樹狀結構,然後以相同的方式處理單一物件與物件群組。葉節點(例如檔案)與組合節點(例如包含檔案和其他資料夾的資料夾)實作同一個介面,例如 size()。組合節點透過對它的子節點呼叫同一個操作來完成實作,因此用戶端程式碼可以直接處理整棵樹,而不必檢查節點是哪一種。
意圖
把物件組合成樹狀結構,以表示「部分-整體」的階層。組合讓用戶端能一致地對待單一物件與物件的組合。它是《設計模式》(Gang of Four,簡稱 GoF)一書中七種結構型模式之一。
它解決的問題
想像一個儲存空間應用程式,必須能顯示任何項目的總大小。檔案有大小;資料夾的大小則是裡面所有東西的總和,而資料夾可以包含檔案和其他資料夾,巢狀的深度不受限制。
如果沒有共同的介面,每一段走訪這棵樹的程式碼,都必須問「這是檔案還是資料夾?」並分別處理,通常還要一遍又一遍地重寫遞迴。新增一種節點類型,例如捷徑或壓縮檔,就得找出並更新每一處這樣的檢查。組合模式把遞迴放進節點本身。
結構與參與者
| 參與者 | 職責 | 範例中的對應 |
|---|---|---|
| 元件(Component) | 為樹中所有物件宣告共同的介面。 | FsNode |
| 葉節點(Leaf) | 沒有子節點的物件;直接實作操作。 | FileNode |
| 組合節點(Composite) | 儲存子元件,並透過委派給它們來實作操作。 | Folder |
| 用戶端 | 透過元件介面處理任何節點。 | report |
TypeScript 實作
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report 完全不檢查節點的類型。新增一種節點,例如回報大小為零的 Symlink,只需要實作 FsNode 即可。
設計取捨:透明性與安全性
像 add() 和 remove() 這類管理子節點的方法,應該放在哪裡?GoF 一書描述了其中的取捨:
| 選擇 | 意義 | 取捨 |
|---|---|---|
| 安全性 | 只有 Composite 擁有 add() 和 remove(),如上面的範例所示。 | 型別安全:你無法對檔案新增子節點。但用戶端必須知道自己何時拿到的是組合節點。 |
| 透明性 | Component 宣告 add() 和 remove(),葉節點則拋出例外或忽略它們。 | 對用戶端而言所有節點看起來都一樣,但錯誤只會在執行時期才出現。 |
在 TypeScript 中,安全性的做法通常效果更好,因為型別檢查器能在編譯時期就抓到錯誤。
何時使用、何時不該用
在以下情況使用組合:
- 你的資料本來就是樹狀結構:檔案與資料夾、容器裡的 UI 元件、選單與子選單、組織圖、繪圖的形狀與群組。
- 用戶端應該能忽略單一物件與物件群組之間的差別。
在以下情況應避免使用:
- 結構是扁平的,或永遠只有一層。普通的陣列更簡單。
- 葉節點與群組需要的操作差異很大。硬把它們塞進同一個介面,會產生對某些節點毫無意義的方法。
- 你需要限制組合節點可以持有哪些子節點,例如只允許某些節點類型。統一的介面會讓這類規則更難在型別系統中強制執行。
實際應用
- DOM:每個元素和文字節點都是
Node。元素可以包含其他節點,許多操作(例如textContent或cloneNode(true))對單一節點與整棵子樹的運作方式都相同。 - React、Vue 和 Angular 這類 UI 元件框架,把介面建構成樹,其中一個元件可以渲染其他元件。
- Java AWT 和 Swing:
Container是Component的子類別,並持有其他元件,所以裝滿按鈕的面板本身也是一個元件。 - 編譯器中的抽象語法樹、遊戲引擎中的場景圖,以及巢狀選單,全都遵循同樣的概念。
相關模式
- 迭代器:走訪組合節點的各個節點,而不暴露子節點是如何儲存的。
- 訪問者:在不修改節點類別的前提下,為組合樹新增操作(匯出、驗證、統計)。
- 裝飾者:同樣有元件介面,也會遞迴地包裝物件,但裝飾者恰好只有一個子節點並新增行為,而組合節點有許多子節點並合併它們的結果。
- 建造者:常用來一步一步建構複雜的組合樹。
- 責任鏈:常會使用組合節點的父節點連結,把請求從子節點往上傳給它的父節點,就像瀏覽器事件沿著 DOM 向上冒泡一樣。
常見問題
葉節點和組合節點有什麼差別?
葉節點沒有子節點,自己完成工作。組合節點持有子元件,通常是透過對每個子節點呼叫同一個操作,再合併結果來完成工作。
組合模式只適用於樹嗎?
它是為樹狀的「部分-整體」階層而設計的。含有循環的一般圖需要額外的處理,例如記錄已走訪的節點,以避免無限遞迴。
組合和裝飾者有什麼差別?
兩者都使用共同的介面與遞迴的組合。裝飾者只包裝一個物件來新增行為,而組合節點則是把許多子節點集合起來,當成一個來處理。
葉節點類別也應該有 add() 嗎?
只有在你想要完全的透明性,並接受有人對葉節點新增子節點時會發生執行時期錯誤,才需要這樣做。在有型別的語言中,通常只把 add() 放在組合節點上會更安全。