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

Encapsulate Collection

เมื่อ object หนึ่งเป็นเจ้าของคอลเลกชัน — ลิสต์ เซ็ต แมป — อย่าเปิดเผยคอนเทนเนอร์ดิบ ๆ ออกไป ให้คืนค่าเป็นสำเนาหรือ view แบบอ่านอย่างเดียวจาก getter และจัดเตรียม method add กับ remove เฉพาะสำหรับการเปลี่ยนแปลง เจ้าของจะกลายเป็นผู้มีอำนาจเพียงหนึ่งเดียวเหนือเนื้อหาของตัวเอง อิสระที่จะบังคับค่าคงตัว (invariant) ในทันทีที่มีอะไรถูกเพิ่มเข้าหรือลบออก

smell คือ getter ที่คืน collection ตัวจริงที่แก้ไขได้ ออกไปตรง ๆ object เจ้าของนึกว่าตัวเองคุมเนื้อหาอยู่ แต่จริง ๆ caller ตัวไหนก็เก็บ reference นั้นไว้แล้ว push, splice, clear หรือจัดเรียงใหม่ลับหลังได้หมด invariant จึงค่อย ๆ ผุพังอย่างเงียบ ๆ class ที่ประกาศว่า “ยอดรวมของ order ตรงกับรายการสินค้าเสมอ” รักษาคำพูดนั้นไม่ได้เลย ถ้าคนนอกแก้ list รายการสินค้าได้โดยตรง

Order เปิดเผย list items ออกมาดิบ ๆ caller จึงแก้ไขได้ตามใจ หลัง refactor list กลายเป็น private การอ่านคืนสำเนาเชิงป้องกัน และการแก้ไขต้องผ่าน method ที่รักษายอดรวม (derived total) ให้ถูกต้องได้

// Before
class Order {
items: LineItem[] = [];
}
const order = new Order();
order.items.push(item); // bypasses the owner entirely
// After
class Order {
#items: LineItem[] = [];
get items(): readonly LineItem[] {
return [...this.#items]; // defensive copy
}
addItem(item: LineItem): void {
this.#items.push(item);
}
removeItem(item: LineItem): void {
this.#items = this.#items.filter((i) => i !== item);
}
get total(): number {
return this.#items.reduce((sum, i) => sum + i.price, 0);
}
}
flowchart LR
  subgraph Before["Before"]
    A["caller"] -->|"gets raw list"| B[("items[]")]
    A -->|"push() / splice() directly"| B
  end
  subgraph After["After"]
    C["caller"] -->|"items() → read-only copy"| D[("items[] (private)")]
    C -->|"addItem() / removeItem()"| D
  end
  Before -.->|"Encapsulate Collection"| After
ลิสต์ดิบที่เปลี่ยนแปลงได้กลายเป็นคอลเลกชันที่ถูกป้องกันด้วย method add และ remove
  1. เพิ่ม method add และ remove (หรือชื่อเทียบเท่า) ให้ collection อย่างชัดเจน โดยข้างใน method แก้ field ที่เป็น private
  2. หาทุกจุดภายนอกที่เปลี่ยนแปลงคอลเลกชันโดยตรง แล้วเปลี่ยนเส้นทางให้ผ่าน method ใหม่ ทำทีละกลุ่มเล็ก ๆ
  3. รัน test หลังจากแต่ละกลุ่ม
  4. เปลี่ยน getter ให้ไม่คืนค่าคอนเทนเนอร์สดอีกต่อไป คืนค่าเป็นสำเนา view แบบอ่านอย่างเดียว หรือ iterator — อะไรก็ตามที่ภาษาของคุณมีให้เพื่อป้องกันการเปลี่ยนแปลงจากภายนอก
  5. ทำให้ field เองเป็น private หรือ unexported
  6. รัน test อีกครั้ง เมื่อทุกการเปลี่ยนแปลงผ่าน method อยู่แล้ว ไม่ควรมีอะไรพัง — และตอนนี้เจ้าของก็สามารถบังคับค่าคงตัวใน method เหล่านั้นได้

ใช้ท่านี้ทุกครั้งที่ class เป็นเจ้าของ collection ที่เป็นส่วนหนึ่งของ state โดยเฉพาะเมื่อ collection นั้นมีส่วนในการรักษา invariant การห่อไว้ทำให้เจ้าของ validate ตอนเพิ่มได้ ปฏิเสธรายการซ้ำได้ รักษาค่าที่คำนวณต่อ เช่น ยอดรวมสะสม ให้ถูกต้องได้ และกันความเสียหายเงียบ ๆ จาก caller ที่อยู่ไกลออกไป

ข้อแลกเปลี่ยนคือต้นทุนของการคัดลอกในทุกการอ่าน และ interface ที่ใหญ่ขึ้นเล็กน้อย สำหรับคอลเลกชันขนาดเล็กที่อ่านนาน ๆ ครั้ง การคัดลอกเป็นประกันที่ไม่สำคัญ สำหรับคอลเลกชันที่ใหญ่มากหรืออยู่ใน hot-path ให้เลือกใช้ view แบบอ่านอย่างเดียวแทนสำเนาเต็มในที่ที่ภาษาของคุณรองรับ เพื่อให้คุณหลีกเลี่ยงการ allocate ได้ แต่ยังปิดกั้นการเปลี่ยนแปลงได้เหมือนเดิม

ใช้ Encapsulate Collection เมื่อหลีกเลี่ยงเมื่อ
client สามารถ mutate collection โดยตรงโดยไม่ผ่าน classclass นั้นเป็น simple data holder ที่ไม่มี invariant
ต้องการ enforce business rule เมื่อ add/removeoverhead ของ getter/setter ไม่คุ้มสำหรับ internal data
ต้องการ observe การเปลี่ยนแปลง collectioncollection เป็น read-only อยู่แล้ว

⚠️ ไม่ควร Encapsulate Collection เมื่อ:

  • collection เป็น private และไม่มีทางที่ external code จะ access โดยตรง
  • class เป็นแค่ DTO — ไม่มี business logic ที่ต้องปกป้อง
  • ทำเพื่อ OOP แต่ไม่มี invariant จริง ๆ ที่ต้องรักษา
กฎหลักของ Encapsulate Collection คืออะไร?
ทำไมการคืนค่าคอลเลกชันสดจาก getter จึงอันตราย?
method add และ remove ควรทำให้อะไรเป็นไปได้?
สำหรับคอลเลกชันที่ใหญ่มากและอยู่ใน hot-path อะไรมักดีกว่าสำเนาเต็มในตอนอ่าน?