ข้าม​ไป​ยัง​เนื้อหา

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 ที่​ต้อง​เลือก​ตาม​บริบท​ของ​ทีม ไม่มี​คำ​ตอบ​ที่​ถูก​เสมอ​ไป

// 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