iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 19

Day 19|Composite (合成模式 ) 深度不固定怎麼辦?把遞迴邏輯藏進節點的物件導向魔法

  • 分享至 

  • xImage
  •  

昨天用 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 圖

https://ithelp.ithome.com.tw/upload/images/20260917/20183470et6XCMk8wi.jpg

核心角色

  1. Component(共同介面):Leaf 跟 Composite 都要實作的介面,定義一套「不管你是單一物件還是一堆物件,都該支援的操作」
  2. Leaf(葉節點):樹的最底層,沒有子節點,直接實作 Component 介面,處理自己的部分就好
  3. Composite(組合節點):本身也實作 Component 介面,但內部持有一份子節點清單(可能是一堆 Leaf,也可能還包著別的 Composite)。實作介面方法的方式通常是:對每一個子節點呼叫同一個方法,再往下遞迴

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 上場

阿柴把「品項」跟「分類」都當成同一種東西看待,一個都能被印出來的節點

差別只在於分類節點底下還可以再放別的節點

我們使用 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)會怎麼呈現

https://ithelp.ithome.com.tw/upload/images/20260917/20183470SbL1ox2Unn.png

朋友如果你第一次看 Mermaid 架構圖,這是小指南

方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / +  代表 private / public)
下層:對外提供的方法 (- / +  代表 private / public)

箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考

今天講的樹狀結構,會有點小抽象,如果將柴咖啡的例子套成物件實例樹狀圖,可以像這樣

https://ithelp.ithome.com.tw/upload/images/20260917/20183470cyvrtCCbjI.png

今天學到的事

Composite 要解決的問題:

如何讓客戶端以一致的方式來處理「單一物件」與「由多個物件組成的群體(如樹狀結構)」,讓使用者不需要寫一堆條件判斷來區分兩者

優點

  • 新增節點類型(新的 Leaf 變形,或是像今天這樣讓樹再長深一層),呼叫端程式碼完全不用改
  • 遞迴走訪的邏輯收斂在 Composite 節點自己身上,不會散落在每個要走訪這棵樹的呼叫端,各自重寫一次判斷型別、手動遞迴的邏輯

缺點

  • 走訪整棵樹是遞迴呼叫,樹如果疊得夠深,除了效能開銷,也可能有堆疊溢位的風險
  • 型別限制較難,為了讓 Component 介面足夠通用,通常很難在編譯期限制『哪種節點底下只能放哪種子節點』

明天來聊柴咖啡的另一個維度:報表的「種類」跟「匯出格式」,兩者要能自由搭配、各自獨立變化,這種情境輪到 Bridge 橋接模式上場

參考資料

Refactoring Guru - Composite

深入淺出設計模式(Design Pattern)-組合模式(11-Composite Pattern)

Design Pattern: Structural Patterns — Composite Pattern (組合模式)

[Design Pattern] 掌握設計模式:Composite Pattern原理與應用


上一篇
Day 18|Proxy (代理模式) 天啊!是替身攻擊!
下一篇
Day 20|Bridge (橋接模式 ) 什麼是橋?用「組合取代繼承」解開多維度耦合
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言