Composite
ประกอบ object เป็นโครงสร้างต้นไม้ ปฏิบัติต่อใบและกิ่งอย่างเดียวกัน
หลายระบบมีข้อมูลที่เป็นลำดับชั้นแบบ part-whole โดยธรรมชาติ — ระบบ file ที่มี file และ folder, เมนูที่มีเมนูย่อย, โครงสร้างองค์กรที่มีพนักงานและทีม, หรือใบเสนอราคาที่มีสินค้าและกล่องสินค้าซ้อนกัน ปัญหาคือ code ฝั่ง client มักต้อง if (item is Leaf) ... else if (item is Container) ... วนซ้ำไปเรื่อย ๆ ทุกครั้งที่ต้องเดินผ่านต้นไม้ ทำให้ code เปราะและผิด OCP เมื่อเพิ่มชนิด node ใหม่
Composite แก้ปัญหานี้ด้วยแนวคิดเดียว: ให้ leaf และ composite (container) implement interface เดียวกัน แล้วให้ composite เก็บลูกเป็น interface นั้นและ “ส่งต่อ” (delegate) การเรียกใช้งานไปยังลูกทุกตัวแบบ recursive จาก client มองเห็นแค่ IComponent เดียว ไม่ว่าจะเป็นใบเดี่ยว ๆ หรือกิ่งที่มีลูกซ้อนกันลึกกี่ชั้นก็ตาม — นี่คือ GoF ใช้คำว่า “compose objects into tree structures to represent part-whole hierarchies… treat individual objects and compositions of objects uniformly”
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”classDiagram
class IComponent {
<<interface>>
+Total() decimal
+Display(indent int)
}
class Product {
-decimal Price
+Total() decimal
+Display(indent int)
}
class Box {
-List~IComponent~ items
+Add(c IComponent)
+Remove(c IComponent)
+Total() decimal
+Display(indent int)
}
class Client {
+Process(c IComponent)
}
IComponent <|.. Product
IComponent <|.. Box
Box o-- IComponent
Client --> IComponent
- Component (
IComponent) — interface หรือ abstract class ที่ประกาศ operation ร่วมของทั้งใบและกิ่ง - Leaf (
Product) — node ปลายทางที่ไม่มีลูก ทำงานจริง (คำนวณราคาตัวเอง) - Composite (
Box) — node กิ่งที่ถือ collection ของIComponent(ซึ่งอาจเป็น leaf หรือ composite ซ้อนกันอีกก็ได้) แล้ว delegate การเรียกไปยังลูกทุกตัว - Client — เรียกใช้งานผ่าน
IComponentเท่านั้น ไม่สนใจว่าเป็นใบหรือกิ่ง
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”เมื่อ client เรียก Total() บน Box, composite จะวนลูกทุกตัวและเรียก Total() ของแต่ละตัว — ถ้าลูกเป็น Product (leaf) ก็คืนราคาตรง ๆ ถ้าลูกเป็น Box (composite) อีกชั้น มันก็จะวน recursive ลงไปอีก การเรียกจึงไหลลงไปจนสุดใบทุกใบโดยที่ client ไม่ต้องรู้ความลึกของต้นไม้เลย
sequenceDiagram
participant Client
participant OuterBox as Box outer
participant InnerBox as Box inner
participant Product
Client->>OuterBox: Total()
OuterBox->>Product: Total()
Product-->>OuterBox: 100
OuterBox->>InnerBox: Total()
InnerBox->>Product: Total()
Product-->>InnerBox: 50
InnerBox-->>OuterBox: 50
OuterBox-->>Client: 150
จุดออกแบบสำคัญ — Transparency vs Safety GoF ชี้ประเด็นนี้ไว้ชัดเจน: จะวาง Add()/Remove() ไว้ตรงไหน?
- Transparent — ใส่
Add()/Remove()ไว้ในIComponentเอง ทำให้ client เรียกAdd()บน object ใดก็ได้แบบเดียวกันหมด (โปร่งใสสุด) แต่แลกมาด้วยการที่Product.Add()ต้อง throw exception หรือ no-op เพราะ leaf ไม่มีลูกให้เพิ่มจริง ๆ — ละเมิด LSP ทางอ้อม - Safe — ใส่
Add()/Remove()เฉพาะในBox(composite) เท่านั้น client ต้อง cast หรือแยกกรณีก่อนเรียก แต่ type-safe กว่า ไม่มี method ที่ทำงานไม่ได้โผล่ในหน้า interface
ตัวอย่างด้านล่างเลือกแนวทาง safe เพราะสอดคล้องกับ ISP และลด surface ของ bug ที่ผิดจนกว่าจะ runtime — เป็น trade-off ที่ต้องเลือกตามบริบทของทีม ไม่มีคำตอบที่ถูกเสมอไป
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// Component: interface ร่วมของใบและกิ่งpublic interface IComponent{ decimal Total(); void Display(int indent = 0);}
// Leaf: สินค้าชิ้นเดียว ไม่มีลูกpublic class Product : IComponent{ public string Name { get; init; } = ""; public decimal Price { get; init; }
public decimal Total() => Price;
public void Display(int indent = 0) => Console.WriteLine($"{new string(' ', indent)}- {Name}: {Price:C}");}
// Composite: กล่องที่บรรจุ IComponent ได้ทั้งสินค้าเดี่ยวและกล่องย่อยpublic class Box : IComponent{ private readonly List<IComponent> _items = new(); public string Name { get; init; } = "";
// child-management operation อยู่ใน composite เท่านั้น (แนวทาง safe) public void Add(IComponent component) => _items.Add(component); public void Remove(IComponent component) => _items.Remove(component);
// delegate ไปยังลูกทุกตัวแบบ recursive — ไม่สนว่าลูกเป็นใบหรือกิ่ง public decimal Total() => _items.Sum(item => item.Total());
public void Display(int indent = 0) { Console.WriteLine($"{new string(' ', indent)}+ {Name} ({Total():C})"); foreach (var item in _items) item.Display(indent + 2); }}
// การใช้งานvar laptop = new Product { Name = "Laptop", Price = 25000m };var mouse = new Product { Name = "Mouse", Price = 350m };
var accessories = new Box { Name = "Accessories" };accessories.Add(mouse);accessories.Add(new Product { Name = "Keyboard", Price = 890m });
var order = new Box { Name = "Order #1024" };order.Add(laptop);order.Add(accessories); // composite ซ้อนใน composite ได้อีก
order.Display();Console.WriteLine($"ยอดรวมทั้งหมด: {order.Total():C}");// client (Display/Total) ไม่ต้องรู้เลยว่ากำลังเดินผ่านใบหรือกิ่งอยู่เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- ข้อมูลหลักของระบบมีธรรมชาติเป็นต้นไม้แบบ part-whole อยู่แล้ว (ระบบ file, เมนู UI, องค์กร, ใบเสนอราคา, AST)
- ต้องการให้ client เขียน code ที่ทำงานกับ object เดี่ยวและกลุ่มของ object แบบเดียวกัน โดยไม่ต้องแยกกรณีด้วย
if/switchตามชนิด - ต้องเดิน recursive operation ผ่านโครงสร้างที่ความลึกไม่แน่นอนหรือเปลี่ยนแปลงได้ (เพิ่มชั้นซ้อนได้เรื่อย ๆ โดยไม่แก้ client)
- อยากเพิ่มชนิด component ใหม่ได้โดยไม่แก้ code เดิม (สนับสนุน OCP)
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- โครงสร้างข้อมูลไม่ใช่ต้นไม้จริง ๆ (เช่น เป็น graph ทั่วไปที่มี cycle) — Composite ตั้งอยู่บนสมมติฐานว่าไม่มี cycle
- leaf กับ composite มีพฤติกรรมต่างกันมากจนต้องบังคับให้ interface กว้างเกินจำเป็น (fat interface) จนเกิด method ที่ leaf ต้อง throw
NotSupportedException - มีแค่ระดับชั้นเดียวตายตัว ไม่มีการซ้อนจริง — ใช้ collection ธรรมดาก็พอ ไม่คุ้มความซับซ้อนที่เพิ่มมา (ระวัง YAGNI)
- ต้องการ type safety สูงสุดโดยไม่ยอมแลกกับความโปร่งใสของ interface เลย — ต้องคิดให้ดีว่าจะเลือก transparent หรือ safe variant ก่อนเริ่ม
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | รายละเอียด |
|---|---|
| ข้อดี | client ปฏิบัติต่อ object เดี่ยวและกลุ่มอย่างเดียวกัน ลด if/switch ตามชนิด |
| ข้อดี | เพิ่มชนิด component ใหม่ได้ง่ายโดยไม่กระทบ client เดิม (เปิดรับส่วนขยาย ตาม OCP) |
| ข้อดี | โครงสร้าง recursive ทำให้ operation ที่ซับซ้อน (sum, render, validate) เขียนสั้นและอ่านง่าย |
| ข้อเสีย | ถ้าเลือกแนวทาง transparent จะทำให้ interface กว้างเกินจำเป็นสำหรับ leaf และเสี่ยง runtime error |
| ข้อเสีย | ออกแบบ interface ร่วมให้ “พอดี” ยากขึ้นเมื่อ leaf กับ composite มีพฤติกรรมต่างกันมาก |
| ข้อเสีย | debug ยากขึ้นเมื่อโครงสร้างลึกมาก เพราะ error อาจเกิดในชั้นที่ลึกและ stack trace ยาว |
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Interpreter — ใช้โครงสร้างแบบ Composite (AST) เป็นฐาน แล้วเพิ่ม operation ตีความทับ
- Visitor — มักใช้คู่กับ Composite เพื่อเพิ่ม operation ใหม่บนต้นไม้โดยไม่แก้ class ของ node
- Decorator — โครงสร้าง recursive คล้ายกันมาก แต่เจตนาต่างกัน: Decorator เน้น “แต่งเติมพฤติกรรม” ส่วน Composite เน้น “แทนโครงสร้าง part-whole”
- Iterator — ใช้เดินผ่าน (traverse) โครงสร้างต้นไม้ของ Composite ได้อย่างเป็นระบบ
- Open-Closed Principle — เหตุผลหลักที่ Composite ช่วยให้เพิ่มชนิด node ใหม่ได้โดยไม่แก้ client
- Liskov Substitution Principle — เกี่ยวข้องโดยตรงกับ trade-off ระหว่าง transparent กับ safe composite