昨天用 Proxy 幫會員點數查詢加了一層快取跟權限控制,今天我們繼續留在結構型 Pattern(Structural)
來聊聊 Composite 合成模式
原文的定義,一樣來自 Design Patterns: Elements of Reusable Object-Oriented Software
Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
用中文理解可以是
把物件組合成樹狀結構,用來表示「部分-整體」的階層關係,讓客戶端可以用同一種方式,對待單一物件跟一堆物件組成的群組
我們來看看 UML 圖

核心角色
Client:只認識 Component 這個介面,不用判斷「我現在拿到的到底是單一物件還是一個群組」
呼叫方式完全一樣,遞迴的事交給樹自己處理
我們來舉例的情境:
電腦裡的檔案總管:資料夾底下可以放檔案,也可以放資料夾,資料夾裡面還能再包資料夾,一直往下疊
如果要算「這個資料夾佔多少容量」,你不會先去區分「這是檔案還是資料夾」再分開處理
你只會說「把這個節點的容量加起來」,至於它底下還有沒有子節點、疊了幾層,都是這個節點自己的事,不是你要操心的
我們來依據核心角色去做區分
// 1. Component(共同介面)
public interface IFileSystemNode
{
long GetSize();
}
// 2. Leaf(檔案:沒有子節點,直接回傳自己的大小)
public class FileNode : IFileSystemNode
{
private readonly long _size;
public FileNode(long size)
{
_size = size;
}
public long GetSize() => _size;
}
// 3. Composite(資料夾:內部持有一堆子節點,可能是檔案或者資料夾)
public class FolderNode : IFileSystemNode
{
private readonly List<IFileSystemNode> _children = new();
public void Add(IFileSystemNode node) => _children.Add(node);
public long GetSize()
{
long total = 0;
foreach (var child in _children)
{
total += child.GetSize(); // 不管子節點是檔案還是資料夾,呼叫方式完全一樣
}
return total;
}
}
var photo = new FileNode(300);
var video = new FileNode(1500);
var subFolder = new FolderNode();
subFolder.Add(video);
var rootFolder = new FolderNode();
rootFolder.Add(photo);
rootFolder.Add(subFolder); // 資料夾底下又包了一個資料夾
Console.WriteLine(rootFolder.GetSize()); // 1800,呼叫端完全不用管這棵樹疊了幾層
柴咖啡展店之後,小黑在店裡加裝了一台電子看板,想讓客人一眼看清楚菜單分類:飲料底下再分冰的、熱的;點心底下再分鹹的、甜的,不要再像以前那樣,一張紙本菜單貼在收銀台後面,客人得瞇著眼睛自己找
阿柴目前資料庫裡的 MenuItem,長得很陽春,分類是塞在一個字串欄位裡
public class MenuItem
{
public string Name { get; set; }
public int Price { get; set; }
public string Category { get; set; } // 例如 "飲料-熱"、"點心-甜"
}
阿柴寫一個 MenuPrinter,把 Category 字串切開,兩層 foreach 分組印出來
public class MenuPrinter
{
public void PrintMenu(List<MenuItem> items)
{
var byBigCategory = items.GroupBy(i => i.Category.Split('-')[0]);
foreach (var bigGroup in byBigCategory)
{
Console.WriteLine($"【{bigGroup.Key}】"); // bigGroup.Key = "飲料" 或 "點心"
var bySubCategory = bigGroup.GroupBy(i => i.Category.Split('-')[1]);
// 對 bigGroup 這組裡的資料再分一次組,key 這次是 "熱"/"冰"/"鹹"/"甜"
foreach (var subGroup in bySubCategory)
{
Console.WriteLine($" 【{subGroup.Key}】");
foreach (var item in subGroup)
{
Console.WriteLine($" - {item.Name} ${item.Price}");
}
}
}
}
}
丟一份測試資料進去看看
var items = new List<MenuItem>
{
new MenuItem { Name = "拿鐵", Price = 90, Category = "飲料-熱" },
new MenuItem { Name = "美式咖啡", Price = 70, Category = "飲料-熱" },
new MenuItem { Name = "燕麥拿鐵", Price = 110, Category = "飲料-熱" },
new MenuItem { Name = "冰拿鐵", Price = 90, Category = "飲料-冰" },
new MenuItem { Name = "冰美式咖啡", Price = 70, Category = "飲料-冰" },
new MenuItem { Name = "貝果", Price = 60, Category = "點心-鹹" },
new MenuItem { Name = "蛋糕", Price = 80, Category = "點心-甜" },
new MenuItem { Name = "夾心餅乾", Price = 65, Category = "點心-甜" },
};
new MenuPrinter().PrintMenu(items);
我們來看看會印出什麼
【飲料】
【熱】
- 拿鐵 $90
- 美式咖啡 $70
- 燕麥拿鐵 $110
【冰】
- 冰拿鐵 $90
- 冰美式咖啡 $70
【點心】
【鹹】
- 貝果 $60
【甜】
- 蛋糕 $80
- 夾心餅乾 $65
看板準時上線,畫面也如預期分成「飲料 > 熱的 / 冰的」「點心 > 鹹的 / 甜的」兩大類
過沒多久,小黑又跑來: 「甜點賣得不錯,甜的底下能不能再分『蛋糕類』跟『餅乾類』,客人比較好選」
阿柴一看,這代表 Category 要多塞一層,變成 "點心-甜-蛋糕類",但飲料底下沒有第三層,Category 還是 "飲料-熱"
阿柴使用 Split('-')[1] 是假設每筆資料都乖乖只有兩段,現在有些資料變成三段、有些還是兩段
PrintMenu 裡那兩層寫死的 foreach 罩不住這種「深度不固定」的情況
得另外加一層判斷「這筆資料到底有幾段」,才知道要不要多印一層縮排,需要嗎?其實不需要
阿柴發現,主要問題是分類底下還能再包分類,這件事沒有一個統一的資料結構去表示
才會讓每加深一層,程式碼就要多一層迴圈、多一層判斷
阿柴把「品項」跟「分類」都當成同一種東西看待,一個都能被印出來的節點
差別只在於分類節點底下還可以再放別的節點
我們使用 Composite 的三個核心角色共同組成一棵樹
// 1. Component(共同介面):不管是品項還是分類,都要能被印出來
public interface IMenuComponent
{
void Display(int depth);
}
// 2. Leaf(品項:樹的最底層,沒有子節點),實作共同介面 IMenuComponent
public class MenuItem : IMenuComponent
{
private readonly string _name;
private readonly int _price;
public MenuItem(string name, int price)
{
_name = name;
_price = price;
}
public void Display(int depth)
{
Console.WriteLine($"{new string(' ', depth * 2)}- {_name} ${_price}");
}
}
// 3. Composite(分類:底下可以放品項,也可以放別的分類),實作共同介面 IMenuComponent
public class MenuCategory : IMenuComponent
{
private readonly string _name;
private readonly List<IMenuComponent> _children = new();
public MenuCategory(string name)
{
_name = name;
}
public void Add(IMenuComponent child) => _children.Add(child);
public void Display(int depth)
{
Console.WriteLine($"{new string(' ', depth * 2)}【{_name}】");
foreach (var child in _children)
{
child.Display(depth + 1); // 這裡使用遞迴
}
}
}
把菜單組成一棵樹
var hotDrinks = new MenuCategory("熱的"); // 飲料的第一個分類: 熱的
hotDrinks.Add(new MenuItem("拿鐵", 90));
hotDrinks.Add(new MenuItem("美式咖啡", 70));
hotDrinks.Add(new MenuItem("燕麥拿鐵", 110));
var icedDrinks = new MenuCategory("冰的"); // 飲料的第二個分類: 冰的
icedDrinks.Add(new MenuItem("冰拿鐵", 90));
icedDrinks.Add(new MenuItem("冰美式咖啡", 70));
var drinks = new MenuCategory("飲料"); // 將飲料大類,加進去子類(熱的/冰的)
drinks.Add(hotDrinks);
drinks.Add(icedDrinks);
var salty = new MenuCategory("鹹的"); // 點心的第一個分類: 鹹的
salty.Add(new MenuItem("貝果", 60));
var sweet = new MenuCategory("甜的"); // 點心的第二個分類: 甜的
sweet.Add(new MenuItem("蛋糕", 80));
sweet.Add(new MenuItem("夾心餅乾", 65));
var snacks = new MenuCategory("點心"); // 將點心大類,加進去子類(鹹的/甜的)
snacks.Add(salty);
snacks.Add(sweet);
var menu = new MenuCategory("柴咖啡菜單"); // 最後將整筆 MENU 加進去商品兩大分類(飲料/點心)
menu.Add(drinks);
menu.Add(snacks);
menu.Display(0);
怎麼看 Display ?
menu.Display(0) ← depth=0,根節點「柴咖啡菜單」
└─ drinks.Display(1) ← depth=1,「飲料」
└─ hotDrinks.Display(2) ← depth=2,「熱的」
└─ 拿鐵.Display(3) ← depth=3,Leaf「- 拿鐵」
【柴咖啡菜單】
【飲料】
【熱的】
- 拿鐵 $90
- 美式咖啡 $70
- 燕麥拿鐵 $110
【冰的】
- 冰拿鐵 $90
- 冰美式咖啡 $70
【點心】
【鹹的】
- 貝果 $60
【甜的】
- 蛋糕 $80
- 夾心餅乾 $65
小黑的需求: 「甜的底下再分蛋糕類、餅乾類」
現在只是在樹裡插入兩個新的 MenuCategory 節點,把原本的 Leaf 包進去
var cakeType = new MenuCategory("蛋糕類"); // 新增蛋糕類
cakeType.Add(new MenuItem("蛋糕", 80)); // 加入蛋糕
var cookieType = new MenuCategory("餅乾類"); // 新增餅乾類
cookieType.Add(new MenuItem("夾心餅乾", 65));// 加入夾心餅乾
var sweet = new MenuCategory("甜的");
sweet.Add(cakeType); // 把新增的類別加進去甜的類別,並延伸蛋糕類
sweet.Add(cookieType); // 把新增的類別加進去甜的類別,並延伸餅乾類
就會變成這樣
【柴咖啡菜單】
【飲料】
【熱的】
- 拿鐵 $90
- 美式咖啡 $70
- 燕麥拿鐵 $110
【冰的】
- 冰拿鐵 $90
- 冰美式咖啡 $70
【點心】
【鹹的】
- 貝果 $60
【甜的】
【蛋糕類】
- 蛋糕 $80
【餅乾類】
- 夾心餅乾 $65
不管樹疊了幾層,最上層呼叫 menu.Display(0) ,MenuCategory.Display() 自己會遞迴往下處理
我們再次釐清這三個角色在 composite 的用意
| 角色 | 在樹上的位置 | 職責 |
|---|---|---|
| Component | 不是樹上的節點,是「規格」 | 定義一份「不管你是葉子還是分支,都要遵守的介面」,這裡是 Display(int depth) |
| Leaf | 樹的末端節點,沒有子節點 | 只負責處理自己(MenuItem 印出自己的名字、價格),遞迴到這裡就停止 |
| Composite | 樹的中間/分支節點,可以有子節點 | 除了處理自己(印出分類名稱),還要對每個子節點呼叫同一個方法,讓遞迴繼續往下走(MenuCategory.Display() 裡的 foreach (var child in _children) child.Display(depth+1)) |
我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現

朋友如果你第一次看 Mermaid 架構圖,這是小指南
方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / + 代表 private / public)
下層:對外提供的方法 (- / + 代表 private / public)
箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考
今天講的樹狀結構,會有點小抽象,如果將柴咖啡的例子套成物件實例樹狀圖,可以像這樣

Composite 要解決的問題:
如何讓客戶端以一致的方式來處理「單一物件」與「由多個物件組成的群體(如樹狀結構)」,讓使用者不需要寫一堆條件判斷來區分兩者
優點:
缺點:
明天來聊柴咖啡的另一個維度:報表的「種類」跟「匯出格式」,兩者要能自由搭配、各自獨立變化,這種情境輪到 Bridge 橋接模式上場
深入淺出設計模式(Design Pattern)-組合模式(11-Composite Pattern)
Design Pattern: Structural Patterns — Composite Pattern (組合模式)
[Design Pattern] 掌握設計模式:Composite Pattern原理與應用