Replace Inheritance with Delegation
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”Replace Inheritance with Delegation ปลด subclass ออกจาก superclass แล้วให้ถือ field ที่เก็บ instance ของ superclass เดิมไว้แทน จากนั้น class จะ delegate เฉพาะการเรียกที่ต้องใช้จริง ส่วนที่เหลือก็ไม่ต้องสนใจ ใช้ท่านี้เมื่อ subclass ใช้ interface ของ superclass แค่เสี้ยวเดียว หรือเมื่อ inheritance ที่เขียนไว้จริง ๆ แล้วคือความสัมพันธ์ “has-a” ที่แต่งตัวมาเป็น “is-a”
Code Smell
หัวข้อที่มีชื่อว่า “Code Smell”อาการคลาสสิกคือ Refused Bequest เช่น Stack extends List ทั้งที่ stack ไม่ควรเปิดให้ caller insertAt ตรงกลางหรือ remove จากก้นได้ การ inherit ทำให้ Stack เปิดเผย interface ทั้งชุดของ List แล้วทำลาย invariant ของตัวเองอย่างเงียบ ๆ ความสัมพันธ์ที่วางไว้จึงผิด เพราะ stack มี list ไม่ได้ เป็น list พอเปลี่ยนมาใช้ delegation แทน Stack ก็เปิดเผยแค่ push กับ pop และเก็บ list ไว้เป็น private
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”Stack ที่สร้างด้วยการ extend List จะปล่อยให้ operation ของ list รั่วออกไปหมด หลัง refactor จะเปลี่ยนเป็น ถือ list ไว้ แล้วเปิดเผยเฉพาะ operation ของ stack
// Before — Stack IS-A List, leaking add/get/removeAt to callersclass List<T> { private items: T[] = []; add(item: T): void { this.items.push(item); } removeLast(): T | undefined { return this.items.pop(); } size(): number { return this.items.length; }}
class Stack<T> extends List<T> { push(item: T): void { this.add(item); } pop(): T | undefined { return this.removeLast(); }}
// After — Stack HAS-A List and delegates only what it needsclass Stack<T> { private list = new List<T>(); push(item: T): void { this.list.add(item); } pop(): T | undefined { return this.list.removeLast(); } size(): number { return this.list.size(); }}# Before — Stack IS-A list, leaking insert/remove/index to callersclass Stack(list): def push(self, item): self.append(item)
def pop_top(self): return self.pop()
# After — Stack HAS-A list and delegates only what it needsclass Stack: def __init__(self): self._items = []
def push(self, item): self._items.append(item)
def pop_top(self): return self._items.pop()
def size(self): return len(self._items)// Go favours composition by default. The "before" abuses embedding so a// Stack promotes every List method. The "after" holds the list as an// unexported field and exposes only Push/Pop — the idiomatic Go style.
// Before — embedding promotes Add/RemoveLast/Size onto Stacktype List struct{ items []int }
func (l *List) Add(item int) { l.items = append(l.items, item) }func (l *List) RemoveLast() int { n := len(l.items) - 1; v := l.items[n]; l.items = l.items[:n]; return v }func (l *List) Size() int { return len(l.items) }
type Stack struct{ List } // leaks Add, RemoveLast to callers
// After — Stack holds a List and delegates only what it needstype Stack struct{ list List }
func (s *Stack) Push(item int) { s.list.Add(item) }func (s *Stack) Pop() int { return s.list.RemoveLast() }func (s *Stack) Size() int { return s.list.Size() }// Rust has no inheritance at all, so delegation is the only option — and// the idiomatic one. The Stack owns a Vec (the "list") privately and// exposes only push and pop.
// Before (the closest mistake) — exposing the inner Vec publicly,// so callers can call insert/remove/truncate and break the invariant.pub struct Stack { pub items: Vec<i32>,}
// After — the Vec is private; only stack operations are delegated outward.pub struct Stack { items: Vec<i32>,}
impl Stack { pub fn new() -> Self { Stack { items: Vec::new() } } pub fn push(&mut self, item: i32) { self.items.push(item); } pub fn pop(&mut self) -> Option<i32> { self.items.pop() } pub fn size(&self) -> usize { self.items.len() }}classDiagram
class List {
+add(item)
+size() number
}
class Stack
Stack --> List : holds & delegates
note for Stack "Stack HAS-A List, not IS-A List" กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- สร้าง field ใน subclass สำหรับถือ instance ของ superclass เดิม แล้วกำหนดค่าเริ่มต้นให้ จะสร้าง instance ใหม่ หรือใช้ตัว object เองถ้ากำลังแกะ self-reference อยู่ก็ได้
- สำหรับแต่ละ method ของ superclass ที่ subclass ใช้จริง ให้เพิ่ม method ที่ delegate ซึ่งส่งต่อการเรียกไปยัง field นั้น
- ลบความสัมพันธ์
extends/ embedding ออก เพื่อให้ class ไม่ inherit interface ทั้งหมดของพ่อแม่อีกต่อไป - ปรับ caller ที่พึ่งพาสมาชิกที่ inherit มาซึ่งไม่ควรเข้าถึงได้ตั้งแต่แรก — นี่คือรอยรั่วที่คุณกำลังอุด
- รัน test หลังเดินสาย method ที่ delegate แต่ละตัวเสร็จ
- เมื่อ delegation อยู่ที่แล้ว คุณก็มีอิสระที่จะแคบ interface ที่เปิดเผยและปกป้อง invariant ที่ inheritance เดิมละเมิด
ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน”ใช้ท่านี้เมื่อ subclass ปฏิเสธมรดกที่ได้รับเสียเป็นส่วนใหญ่ เมื่อ inheritance ปล่อย operation ที่ทำลายกฎของ subclass เองรั่วออกมา หรือเมื่อ “is-a” ไม่เคยเป็นจริงตั้งแต่แรก หลังย้ายเสร็จ คุณจะคุมได้เป๊ะ ๆ ว่า operation ไหนเป็น public จึงบังคับ invariant ได้จริง
ต้นทุนคือ boilerplate ของการส่งต่อเล็กน้อย — หนึ่ง method สั้น ๆ ต่อหนึ่ง operation ที่ delegate นั่นเป็นราคาเล็ก ๆ และซื่อตรงสำหรับความสัมพันธ์ที่ถูกต้องแม่นยำ ใน Go นี่เป็นเพียงการเลือก field ที่ถือไว้แทน embedding ส่วนใน Rust delegation คือเส้นทาง เดียว ดังนั้น refactoring นี้จึงเป็นแค่ “เขียน Rust แบบ idiomatic ตั้งแต่แรก”
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”| ใช้ Replace Inheritance with Delegation เมื่อ | หลีกเลี่ยงเมื่อ |
|---|---|
| subclass ใช้เพียงบางส่วนของ superclass interface | subclass เป็น “is-a” อย่างแท้จริง ไม่ใช่แค่ “has-a” |
| inheritance ทำให้ subclass ผูกพันกับ superclass implementation | delegation จะเพิ่ม forwarding methods มากเกินไป |
| ต้องการ swap implementation โดยไม่เปลี่ยน interface | client code ต้อง downcast ไปยัง superclass type |
⚠️ ไม่ควร Replace Inheritance with Delegation เมื่อ:
- subclass ใช้ superclass interface ครบทุก method
- Liskov Substitution Principle ยังคงใช้ได้ — subclass คือ superclass จริง ๆ
- delegation จะสร้าง wrapper ที่ไม่เพิ่ม value