Class Depends on Subclass
base class รู้จัก subclass ของตน — กลับทิศความสัมพันธ์ของ inheritance
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Class Depends on Subclass เกิดเมื่อ base class มีความรู้เกี่ยวกับ subclass ของตน — อ้างชื่อ subclass ตรง ๆ ใน code, เช็กชนิดด้วย is/as/GetType(), หรือ cast ตัวเองลงไปเป็น subclass เพื่อเรียก member เฉพาะของมัน
ทิศทางที่ถูกต้องของ inheritance คือ subclass รู้จัก base (มันสืบทอดจาก base และเติมเต็ม contract ที่ base นิยามไว้) ส่วน base ไม่ควรรู้ว่าใครเป็นลูกของมัน — base มีหน้าที่แค่ประกาศ abstraction (virtual method, abstract member, interface) แล้วปล่อยให้ polymorphism ตัดสินใจว่าจะรัน code ของ subclass ตัวไหน เมื่อ base เริ่มเขียน code แบบ “ถ้าเป็น subclass ตัวนี้ ให้ทำแบบนี้ ถ้าเป็นอีกตัวให้ทำแบบนั้น” มันคือสัญญาณว่าความสัมพันธ์ถูกกลับหัวกลับหาง
กลิ่นนี้เป็นญาติใกล้ชิดกับ Refused Bequest ใน object-orientation abusers ของ Fowler — ทั้งสองกลิ่นบอกว่าลำดับชั้น inheritance ที่มีอยู่ไม่สะท้อนความสัมพันธ์ “is-a” ที่แท้จริง เพียงแต่ Refused Bequest มองจากฝั่ง subclass ที่ไม่ยอมใช้สิ่งที่ base ให้มา ส่วน Class Depends on Subclass มองจากฝั่ง base ที่ยื่นมือลงไปจัดการ subclass เอง
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”สัญญาณที่บอกว่ากำลังเจอกลิ่นนี้ใน code:
- ใน base class มีการเช็ก
if (this is ManagerEmployee)หรือif (obj.GetType() == typeof(SalariedEmployee)) - มี
switchหรือif/else ifไล่เช็กชื่อ subclass แล้วทำพฤติกรรมต่างกันในเมทอดของ base - base class มี
usingหรือ reference ตรงไปยัง namespace/type ของ subclass เพื่อ cast ((SubclassA)this) - เพิ่ม subclass ใหม่ทีไร ต้องกลับไปแก้ code ใน base class ทุกครั้ง (สัญญาณคลาสสิกของการละเมิด Open-Closed)
- Base class มี comment ทำนอง ”// TODO: จัดการ subclass ตัวใหม่ตรงนี้ด้วย” — เป็นหลักฐานว่า base ผูกอยู่กับรายชื่อ subclass ที่ตายตัว
- Unit test ของ base class ต้อง mock หรืออ้างอิง concrete subclass เพื่อให้ครอบคลุมทุก branch
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”ละเมิด Open-Closed Principle (OCP) — ทุกครั้งที่ต้องเพิ่ม subclass ใหม่ในระบบ นักพัฒนาต้องย้อนกลับไปแก้ base class เพื่อเพิ่มเงื่อนไขใหม่ ทั้งที่ OCP ต้องการให้ระบบ “เปิดสำหรับการขยาย ปิดสำหรับการแก้ไข” — พอ base ผูกกับรายชื่อ subclass แบบ hard-code ลำดับชั้นก็ขยายไม่ได้โดยไม่แตะ code เดิม
บ่อนทำลาย Liskov Substitution Principle (LSP) — LSP บอกว่า client ที่ใช้ reference ของ base type ต้องสามารถสลับไปใช้ subclass ตัวไหนก็ได้โดยพฤติกรรมของโปรแกรมไม่พัง แต่เมื่อ base มี code ที่พฤติกรรมแตกต่างกันไปตาม concrete type ของตัวเอง มันก็แปลว่า caller ต้อง “รู้” ว่าจริง ๆ แล้วกำลังถืออะไรอยู่ในมือ ทั้งที่ควรจะโปร่งใสผ่าน abstraction เดียว
ทิศทาง dependency ผิด (ละเมิด DIP) — Dependency Inversion Principle ต้องการให้ high-level abstraction ไม่ผูกกับ low-level detail แต่ที่นี่ base (ซึ่งควรเป็นชั้น abstraction ที่มั่นคงกว่า) กลับไปพึ่งพา detail ของ subclass ที่เจาะจงกว่า — เมื่อไหร่ที่ concrete subclass เปลี่ยน ก็มีโอกาสกระทบ base ตามไปด้วย ทั้งที่ทิศทางที่ควรเป็นคือตรงกันข้าม
ทดสอบยากและเปราะ (fragile base class) — เพราะ behavior ของ base ผูกอยู่กับรายชื่อ subclass ที่ตายตัว การเขียน unit test ให้ base class ต้องรู้จักและ setup concrete subclass ทุกตัวที่ base เช็กถึง เพิ่ม subclass ใหม่แล้วลืมแก้ base ก็กลายเป็น bug เงียบ ๆ ที่ไม่มี compiler เตือน
ปิดกั้นการนำ base ไปใช้ซ้ำ (reuse) — base class ที่ผูกกับ subclass เฉพาะกลุ่มหนึ่งไม่สามารถถูกนำไป extend โดยทีมอื่นหรือ module อื่นได้อย่างปลอดภัย เพราะพฤติกรรมของมันขึ้นกับรายชื่อ subclass ที่ผู้เขียน base คาดเดาไว้ล่วงหน้าเท่านั้น
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ก่อน refactor — base class รู้จัก subclass ของตน
หัวข้อที่มีชื่อว่า “ก่อน refactor — base class รู้จัก subclass ของตน”public class Employee{ public string Name { get; set; } public decimal BaseSalary { get; set; }
// base class เช็กชนิดของตัวเองเพื่อคำนวณโบนัสต่างกันไป public decimal CalculateBonus() { if (this is Manager manager) { return BaseSalary * 0.20m + manager.TeamSize * 500m; } else if (this is SalesEmployee sales) { return sales.SalesTotal * 0.05m; } else { return BaseSalary * 0.10m; } }}
public class Manager : Employee{ public int TeamSize { get; set; }}
public class SalesEmployee : Employee{ public decimal SalesTotal { get; set; }}ปัญหา: Employee (base) รู้จักทั้ง Manager และ SalesEmployee (subclass) โดยตรง — ต้อง cast ลงไปเพื่ออ่าน TeamSize และ SalesTotal ซึ่งเป็น member ที่ไม่มีอยู่ใน base เลย พอจะเพิ่ม ContractEmployee ขึ้นมาอีกตัว ก็ต้องกลับมาแก้เมทอด CalculateBonus ใน Employee อีกครั้ง
หลัง refactor — Replace Conditional with Polymorphism
หัวข้อที่มีชื่อว่า “หลัง refactor — Replace Conditional with Polymorphism”ใช้เทคนิค Replace Conditional with Polymorphism ของ Fowler: ยก logic เฉพาะของแต่ละ subclass ออกจาก base แล้วให้แต่ละ subclass override virtual method ของตัวเอง — base ประกาศแค่ contract ผ่าน virtual/abstract โดยไม่ต้องรู้จักชื่อ subclass เลยสักตัว
public abstract class Employee{ public string Name { get; set; } public decimal BaseSalary { get; set; }
// base ประกาศ contract แต่ไม่รู้ว่าใครเป็นคนเติมเต็ม public virtual decimal CalculateBonus() => BaseSalary * 0.10m;}
public class Manager : Employee{ public int TeamSize { get; set; }
public override decimal CalculateBonus() => BaseSalary * 0.20m + TeamSize * 500m;}
public class SalesEmployee : Employee{ public decimal SalesTotal { get; set; }
public override decimal CalculateBonus() => SalesTotal * 0.05m;}
// เพิ่ม subclass ใหม่ได้โดยไม่ต้องแตะ Employee เลยpublic class ContractEmployee : Employee{ public decimal ContractRate { get; set; }
public override decimal CalculateBonus() => ContractRate * 0.02m;}ตอนนี้ caller เขียนแค่ employee.CalculateBonus() ผ่าน reference ชนิด Employee ได้เสมอ ไม่ว่าจะถือ instance ของ subclass ไหนอยู่จริง — สอดคล้องกับ LSP เต็มรูปแบบ และการเพิ่ม subclass ใหม่ (ContractEmployee) ไม่ต้องแก้ Employee แม้แต่บรรทัดเดียว สอดคล้องกับ OCP
หากในบางเคส inheritance ไม่เหมาะสมตั้งแต่ต้น (subclass ไม่มีอะไรร่วมกับ base จริง ๆ) refactoring.guru และ SourceMaking แนะนำ Replace Inheritance with Delegation แทน คือเลิกสืบทอดแล้วให้ class ถือ reference ไปยัง object ที่ต้องการ behavior นั้นแทน หรือถ้ามีแค่บาง field/method ที่ base ไม่ควรมี ให้ใช้ Extract Superclass ดึงเฉพาะส่วนที่ subclass ทั้งหมดใช้ร่วมกันจริงขึ้นไปเป็น base ใหม่ที่บางลง
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Liskov Substitution — หลักการที่กลิ่นนี้ละเมิดโดยตรงเมื่อ behavior ขึ้นกับ concrete subclass
- Dependency Inversion — ทิศทาง dependency ที่ base ไม่ควรพึ่งพา subclass
- Open-Closed — เหตุผลที่การเพิ่ม subclass ไม่ควรบังคับให้แก้ base
- Switch Statements — กลิ่นฝาแฝดเมื่อ conditional บนชนิด object ปรากฏกระจายอยู่หลายที่
- Conditional Complexity — เมื่อ branch การเช็กชนิดพอกพูนจนอ่านยาก
- Parallel Inheritance Hierarchies — อีกกลิ่นที่เกิดจากลำดับชั้น inheritance ออกแบบไม่ดี