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

Composite

Composite ให้คุณสร้างโครงสร้างต้นไม้แบบ part-whole แล้วปฏิบัติต่อ object เดี่ยวกับกลุ่ม object ผ่าน interface เดียวกัน ฝั่ง client จึงไม่ต้องรู้เลยว่าสิ่งที่ถืออยู่เป็นใบหรือกิ่งทั้งกิ่ง

ลองนึกถึงระบบไฟล์ ไฟล์มีขนาดของตัวเอง ส่วนโฟลเดอร์บรรจุไฟล์และโฟลเดอร์อื่นต่อ ๆ กันไป ขนาดของโฟลเดอร์คือผลรวมของทุกอย่างข้างใน ถ้าคุณ model ไฟล์กับโฟลเดอร์เป็นคนละ type ที่ไม่เกี่ยวกัน code ฝั่ง client ทุกจุดที่เดินสำรวจต้นไม้ต้องถามก่อนว่า “นี่ไฟล์หรือโฟลเดอร์?” แล้วค่อยแยกทาง เงื่อนไขแบบนี้จะกระจายไปทั่วทั้งระบบ และพังทันทีที่คุณเพิ่ม node ชนิดที่สาม

Composite ตัดคำถามนั้นทิ้งไปเลย ทั้งใบและตัวบรรจุต่างก็ implement interface ร่วมตัวเดียวกัน เช่น node ที่มี method size() ใบคืนค่าของตัวเอง ส่วนตัวบรรจุ delegate ลงไปหาลูกแล้วรวมผลลัพธ์ ทำแบบนี้ซ้ำลึกลงไปเท่าที่ต้นไม้จะลึก

ฝั่ง client แค่เรียก size() ที่ root ก็ได้ยอดรวม โดยไม่ต้องสนใจรูปร่างข้างใต้เลย และการเพิ่ม node ชนิดใหม่ก็แค่ implement interface เดิม ไม่ต้องไล่แก้ทุกจุดที่เดินสำรวจต้นไม้

classDiagram
  class Node {
    <<interface>>
    +size() int
    +name() string
  }
  class File {
    -bytes: int
    +size() int
  }
  class Folder {
    -children: Node[]
    +add(child)
    +size() int
  }
  Node <|.. File
  Node <|.. Folder
  Folder o--> Node : contains children
ใบและตัวบรรจุใช้ interface เดียวกัน ตัวบรรจุถือลูก ๆ ไว้
  • Component (Node) — interface ร่วมสำหรับทุกอย่างในต้นไม้ ประกาศการดำเนินการที่ client เรียกใช้
  • Leaf (File) — node ที่ไม่มีลูก implement การดำเนินการเองโดยตรง
  • Composite (Folder) — node ที่ถือ component ลูกไว้ และ implement การดำเนินการด้วยการ delegate ลงไปหาลูกแล้วรวมผลลัพธ์
  • Client — ทำงานอิงกับ component interface และปฏิบัติต่อใบและ composite แบบเดียวกัน

ต้นไม้ของระบบไฟล์ที่ทั้งไฟล์และโฟลเดอร์เปิด size() ออกมาเหมือนกัน ขนาดของโฟลเดอร์คือผลรวมของลูกทั้งหมด คำนวณแบบ recursive

interface Node {
size(): number;
}
class File implements Node {
constructor(private readonly bytes: number) {}
size = () => this.bytes;
}
class Folder implements Node {
private children: Node[] = [];
add(child: Node): this {
this.children.push(child);
return this;
}
size = () => this.children.reduce((sum, c) => sum + c.size(), 0);
}
const root = new Folder()
.add(new File(100))
.add(new Folder().add(new File(20)).add(new File(30)));
console.log(root.size()); // 150
  • ข้อดี: client ปฏิบัติต่อ object เดี่ยวและ subtree ทั้งกิ่งได้แบบเดียวกัน ขจัดเงื่อนไขการตรวจสอบ type
  • ข้อดี: การเพิ่มชนิด component ใหม่ปลายเปิด คือเพียง implement interface ร่วม
  • ข้อดี: การดำเนินการแบบวนซ้ำ เช่น การหายอดรวม การ render หรือการค้นหา เกิดขึ้นอย่างเป็นธรรมชาติจากโครงสร้าง
  • ข้อเสีย: component interface ที่กว้างเกินไปจะบังคับให้ใบต้อง implement การดำเนินการเกี่ยวกับลูก ทั้งที่ใบไม่มีลูก
  • ข้อเสีย: ความเป็นเอกภาพอาจซ่อนต้นทุนไว้ คือการเรียกเพียงครั้งเดียวที่ดูไร้พิษภัยอาจวนซ้ำไปทั่วต้นไม้ขนาดมหึมา
  • Decorator ก็อาศัย composition แบบ recursive เหมือนกัน แต่เพิ่มพฤติกรรมให้ object ที่ห่อไว้ตัวเดียว ไม่ได้รวมผลจากลูกหลายตัว
  • Builder ใช้ประกอบต้นไม้ composite ที่ซับซ้อนทีละขั้นได้ดี
CompositeDecoratorIterator
จุดประสงค์จัดโครงสร้างแบบ tree เพื่อ treat leaf = branchเพิ่ม behavior โดยไม่แก้ classวนซ้ำ collection ด้วย interface เดียว
โครงสร้างtree (ลูกหลายตัว)chain (ห่อชั้นเดียว)external cursor
client รู้ความต่างไม่รู้ (interface เดียวกัน)ไม่รู้ไม่รู้
เมื่อใช้file system, DOM, org chartmiddleware, logging, cachinglist, tree, stream

💡 หมายเหตุสำหรับ developer

Pattern นี้พบได้บ่อยใน:

  • DOM tree — element ทุกตัวไม่ว่าจะเป็น leaf หรือ container ถูกเข้าถึงผ่าน interface เดียวกัน (Node)
  • React component tree — component ประกอบกันเป็น tree โดย parent ไม่ต้องรู้ว่า child เป็น leaf หรือ composite
  • File system — folder และ file ถูกเข้าถึงผ่าน operation ร่วมกัน (ls, size, path)
Composite pattern สร้างโครงสร้างแบบใด?
composite (ตัวบรรจุ) implement การดำเนินการร่วมอย่างไร?
การปฏิบัติต่อใบและ composite แบบเดียวกันให้ประโยชน์อะไรกับ client?
ข้อเสียของ component interface ที่ทั่วไปมาก ๆ คืออะไร?