Encapsulate Collection
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”เมื่อ object หนึ่งเป็นเจ้าของคอลเลกชัน — ลิสต์ เซ็ต แมป — อย่าเปิดเผยคอนเทนเนอร์ดิบ ๆ ออกไป ให้คืนค่าเป็นสำเนาหรือ view แบบอ่านอย่างเดียวจาก getter และจัดเตรียม method add กับ remove เฉพาะสำหรับการเปลี่ยนแปลง เจ้าของจะกลายเป็นผู้มีอำนาจเพียงหนึ่งเดียวเหนือเนื้อหาของตัวเอง อิสระที่จะบังคับค่าคงตัว (invariant) ในทันทีที่มีอะไรถูกเพิ่มเข้าหรือลบออก
Code Smell
หัวข้อที่มีชื่อว่า “Code Smell”smell คือ getter ที่คืน collection ตัวจริงที่แก้ไขได้ ออกไปตรง ๆ object เจ้าของนึกว่าตัวเองคุมเนื้อหาอยู่ แต่จริง ๆ caller ตัวไหนก็เก็บ reference นั้นไว้แล้ว push, splice, clear หรือจัดเรียงใหม่ลับหลังได้หมด invariant จึงค่อย ๆ ผุพังอย่างเงียบ ๆ class ที่ประกาศว่า “ยอดรวมของ order ตรงกับรายการสินค้าเสมอ” รักษาคำพูดนั้นไม่ได้เลย ถ้าคนนอกแก้ list รายการสินค้าได้โดยตรง
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”Order เปิดเผย list items ออกมาดิบ ๆ caller จึงแก้ไขได้ตามใจ หลัง refactor list กลายเป็น private การอ่านคืนสำเนาเชิงป้องกัน และการแก้ไขต้องผ่าน method ที่รักษายอดรวม (derived total) ให้ถูกต้องได้
// Beforeclass Order { items: LineItem[] = [];}const order = new Order();order.items.push(item); // bypasses the owner entirely
// Afterclass 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); }}# Beforeclass Order: def __init__(self): self.items = []order = Order()order.items.append(item) # bypasses the owner entirely
# Afterclass Order: def __init__(self): self._items = []
@property def items(self): return tuple(self._items) # read-only snapshot
def add_item(self, item): self._items.append(item)
def remove_item(self, item): self._items.remove(item)
@property def total(self): return sum(i.price for i in self._items)// Beforetype Order struct { Items []LineItem // exported: anyone can mutate}order.Items = append(order.Items, item) // bypasses the owner
// Aftertype Order struct { items []LineItem // unexported}
func (o *Order) Items() []LineItem { out := make([]LineItem, len(o.items)) copy(out, o.items) // defensive copy return out}
func (o *Order) AddItem(item LineItem) { o.items = append(o.items, item)}
func (o *Order) Total() float64 { var sum float64 for _, i := range o.items { sum += i.Price } return sum}// Beforepub struct Order { pub items: Vec<LineItem>, // public: anyone can mutate}// order.items.push(item); // bypasses the owner
// Afterpub struct Order { items: Vec<LineItem>, // private}
impl Order { pub fn items(&self) -> &[LineItem] { &self.items // read-only borrow, not a mutable handle }
pub fn add_item(&mut self, item: LineItem) { self.items.push(item); }
pub fn remove_item(&mut self, index: usize) { self.items.remove(index); }
pub fn total(&self) -> f64 { self.items.iter().map(|i| i.price).sum() }}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(หรือชื่อเทียบเท่า) ให้ collection อย่างชัดเจน โดยข้างใน method แก้ field ที่เป็น private - หาทุกจุดภายนอกที่เปลี่ยนแปลงคอลเลกชันโดยตรง แล้วเปลี่ยนเส้นทางให้ผ่าน method ใหม่ ทำทีละกลุ่มเล็ก ๆ
- รัน test หลังจากแต่ละกลุ่ม
- เปลี่ยน getter ให้ไม่คืนค่าคอนเทนเนอร์สดอีกต่อไป คืนค่าเป็นสำเนา view แบบอ่านอย่างเดียว หรือ iterator — อะไรก็ตามที่ภาษาของคุณมีให้เพื่อป้องกันการเปลี่ยนแปลงจากภายนอก
- ทำให้ field เองเป็น private หรือ unexported
- รัน test อีกครั้ง เมื่อทุกการเปลี่ยนแปลงผ่าน method อยู่แล้ว ไม่ควรมีอะไรพัง — และตอนนี้เจ้าของก็สามารถบังคับค่าคงตัวใน method เหล่านั้นได้
ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน”ใช้ท่านี้ทุกครั้งที่ class เป็นเจ้าของ collection ที่เป็นส่วนหนึ่งของ state โดยเฉพาะเมื่อ collection นั้นมีส่วนในการรักษา invariant การห่อไว้ทำให้เจ้าของ validate ตอนเพิ่มได้ ปฏิเสธรายการซ้ำได้ รักษาค่าที่คำนวณต่อ เช่น ยอดรวมสะสม ให้ถูกต้องได้ และกันความเสียหายเงียบ ๆ จาก caller ที่อยู่ไกลออกไป
ข้อแลกเปลี่ยนคือต้นทุนของการคัดลอกในทุกการอ่าน และ interface ที่ใหญ่ขึ้นเล็กน้อย สำหรับคอลเลกชันขนาดเล็กที่อ่านนาน ๆ ครั้ง การคัดลอกเป็นประกันที่ไม่สำคัญ สำหรับคอลเลกชันที่ใหญ่มากหรืออยู่ใน hot-path ให้เลือกใช้ view แบบอ่านอย่างเดียวแทนสำเนาเต็มในที่ที่ภาษาของคุณรองรับ เพื่อให้คุณหลีกเลี่ยงการ allocate ได้ แต่ยังปิดกั้นการเปลี่ยนแปลงได้เหมือนเดิม
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”| ใช้ Encapsulate Collection เมื่อ | หลีกเลี่ยงเมื่อ |
|---|---|
| client สามารถ mutate collection โดยตรงโดยไม่ผ่าน class | class นั้นเป็น simple data holder ที่ไม่มี invariant |
| ต้องการ enforce business rule เมื่อ add/remove | overhead ของ getter/setter ไม่คุ้มสำหรับ internal data |
| ต้องการ observe การเปลี่ยนแปลง collection | collection เป็น read-only อยู่แล้ว |
⚠️ ไม่ควร Encapsulate Collection เมื่อ:
- collection เป็น private และไม่มีทางที่ external code จะ access โดยตรง
- class เป็นแค่ DTO — ไม่มี business logic ที่ต้องปกป้อง
- ทำเพื่อ OOP แต่ไม่มี invariant จริง ๆ ที่ต้องรักษา