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

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 คาด​เดา​ไว้​ล่วงหน้า​เท่านั้น

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 อีก​ครั้ง

ใช้​เทคนิค 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 ออกแบบ​ไม่​ดี