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

Parallel Inheritance Hierarchies

สอง​ลำดับ​ชั้น​ที่​ต้อง​โต​คู่​ขนาน​กัน​เสมอ

Parallel Inheritance Hierarchies เกิด​ขึ้น​เมื่อ code มี สอง​ลำดับ​ชั้น class (inheritance hierarchy) ที่​ผูก​กัน​ด้วย​ความ​สัมพันธ์​แบบ composition — ลำดับ​ชั้น​หนึ่ง​อ้างอิง​อีก​ลำดับ​ชั้น​หนึ่ง​เป็น field หรือ parameter และ​ทันที​ที่​ต้อง​เพิ่ม subclass ใหม่​ใน​ฝั่ง A ก็​จะ​พบ​ว่า​ต้อง​เพิ่ม subclass คู่​กัน​ใน​ฝั่ง B เสมอ ไม่มี​ทาง​เลี่ยง

Martin Fowler นิยาม​กลิ่น​นี้​ไว้​ใน​หนังสือ Refactoring: Improving the Design of Existing Code และ​จัด​ให้​อยู่​ใน​ตระกูล Change Preventers — กลุ่ม​กลิ่น​ที่​ทำให้การ​เปลี่ยนแปลง​จุด​เดียว​ลาม​ไป​หลาย​ที่ ร่วม​กับ Divergent Change และ Shotgun Surgery อัน​ที่​จริง Parallel Inheritance Hierarchies ถือ​เป็นกรณี​พิเศษ​ของ Shotgun Surgery โดย​เฉพาะ: แทนที่​การ​เปลี่ยนแปลง​หนึ่ง​จุด​จะ​กระจาย​ไป​หลาย file แบบ​สุ่ม มัน​กระจาย​ไป​ตาม “รูปแบบ​ที่​คาด​เดา​ได้” คือ​คู่​ขนาน​กัน​ของ​ทั้ง​สอง​ลำดับ​ชั้น​เสมอ

สัญญาณ​ผิวเผิน​ที่สุด​คือ​ชื่อ class ที่​มี prefix ร่วม​กัน​เป็น​คู่ ๆ เช่น Dog คู่​กับ DogHandler, Cat คู่​กับ CatHandler — ทุก​ครั้ง​ที่​ทีม​เพิ่ม​สัตว์​ชนิด​ใหม่ ต้อง “จำ” ให้​เพิ่ม handler คู่​กัน​ด้วย​เสมอ

  • ชื่อ class เป็น​คู่​ที่​มี prefix ร่วม​กัน ตลอด​สอง​ลำดับ​ชั้น เช่น InvoiceValidator/InvoiceRenderer ที่​มี subclass ตาม InvoiceType เดียวกัน​ทุก​ตัว
  • จุด map type ต่อ type อยู่​ตรง​กลาง มัก​เป็น factory, switch statement หรือ dictionary lookup ที่​คอย​จับ​คู่ subclass ของ​ฝั่ง​หนึ่ง​เข้า​กับ subclass ที่ “ถูกต้อง” ของ​อีก​ฝั่ง (ดู Switch Statements)
  • เพิ่ม​ของ​ใหม่​ต้อง​แก้​สอง​ที่​เสมอ — pull request ที่​เพิ่ม subclass ใหม่​ใน​ฝั่ง​หนึ่ง มัก​มี diff อีก file ที่​เพิ่ม subclass คู่​กัน​ใน​ฝั่ง​ตรง​ข้าม​แนบ​มา​ด้วย​ตลอด
  • ลืม​เพิ่ม​คู่​แล้ว​พัง​แบบ​เงียบ ๆ เช่น factory fall-through ไป default handler ผิด​ชนิด หรือ​โยน exception ตอน runtime แทนที่​จะ​เป็น compile error
  • จำนวน class โต​เป็น​สัดส่วน 1:1 ระหว่าง​สอง​ลำดับ​ชั้น ยิ่ง​ระบบ​ใหญ่​ยิ่ง​เห็น pattern นี้​ชัดเจน — และ​ยิ่ง​ลำบาก​ตอน​ต้อง​เพิ่ม​ลำดับ​ชั้น​ที่​สาม
  • ผิด​หลัก DRY โครงสร้าง​การ​แตก​แขนง (variation) ของ domain ถูก​เข้า​รหัส​ซ้ำ​สอง​รอบ — ครั้ง​หนึ่ง​ใน​ลำดับ​ชั้น A อีก​ครั้ง​ใน​ลำดับ​ชั้น B ทั้ง​ที่​จริง ๆ มัน​คือ “ความ​รู้​เดียวกัน” เพียง​แต่​กระจาย​อยู่​คนละ​ที่
  • ผิด​หลัก Open/Closed Principle การ​เพิ่ม​พฤติกรรม​ใหม่​หนึ่ง​อย่าง​บังคับ​ให้​ต้อง​แก้ไข/เพิ่ม code ใน​สอง​จุด​พร้อม​กัน​เสมอ ระบบ​ไม่​ได้ “เปิด​สำหรับ​การ​ขยาย” อย่าง​แท้จริง เพราะ​การ​ขยาย​หนึ่ง​ครั้ง​เท่ากับ​งาน​สอง​เท่า
  • ต้นทุน​การ​เปลี่ยนแปลง​โต​แบบ​ทวีคูณ ถ้า​มี​ลำดับ​ชั้น​ที่​สาม​มา​ผูก​เพิ่ม​อีก (เช่น DogSerializer เพิ่ม​มา​จับ​คู่​กับ Dog/DogHandler) งาน​เพิ่ม1 subclass จะ​กลาย​เป็น​สาม จุด​ที่​ต้อง​แก้​โต​เป็น N หลัก​แทนที่​จะ​เป็น 1
  • เสี่ยง​ต่อ bug จาก​ความ​ไม่​สอดคล้อง (inconsistency) เพราะ compiler ไม่​ได้​บังคับ​ว่า​คู่​ต้อง​มี​จริง — ถ้า​นัก​พัฒนา​ลืม​เพิ่ม subclass คู่​กัน ระบบ​อาจ compile ผ่าน​แต่​ทำงาน​ผิด​ตอน runtime
  • เพิ่ม​ภาระ​การ​รับรู้ (cognitive load) ผู้​ดูแล code ต้อง​จำ mapping ที่​ซ่อน​อยู่​ใน​หัว​ว่า subclass ไหน​ของ​ฝั่ง​หนึ่ง​ต้อง​คู่​กับ subclass ไหน​ของ​อีก​ฝั่ง แทนที่​ความ​สัมพันธ์​นี้​จะ​ถูก​แสดงออก​มา​อย่าง​ชัดเจน​ใน​โครงสร้าง code

โครงสร้าง​ก่อน refactor — สอง​ลำดับ​ชั้น​ที่​ต้อง​โต​คู่​กัน​เสมอ ผูก​กัน​ผ่าน factory ที่ map type ต่อ type:

flowchart LR
    Animal --> Dog
    Animal --> Cat
    AnimalHandler --> DogHandler
    AnimalHandler --> CatHandler
    Dog -.-> DogHandler
    Cat -.-> CatHandler
public abstract class Animal
{
public abstract string Name { get; }
}
public class Dog : Animal
{
public override string Name => "Dog";
}
public class Cat : Animal
{
public override string Name => "Cat";
}
// ลำดับชั้นที่สอง ต้องมี subclass คู่กับ Animal ทุกตัวเสมอ
public abstract class AnimalHandler
{
public abstract void Handle(Animal animal);
}
public class DogHandler : AnimalHandler
{
public override void Handle(Animal animal) =>
Console.WriteLine($"{animal.Name} วิ่งไปคาบลูกบอลกลับมา");
}
public class CatHandler : AnimalHandler
{
public override void Handle(Animal animal) =>
Console.WriteLine($"{animal.Name} เดินหนีไปนอนตรงมุมห้องอย่างสง่างาม");
}
// จุด map type ต่อ type ที่ต้องอัปเดตทุกครั้งที่เพิ่มสัตว์ชนิดใหม่
public static class AnimalHandlerFactory
{
public static AnimalHandler Create(Animal animal) => animal switch
{
Dog => new DogHandler(),
Cat => new CatHandler(),
_ => throw new NotSupportedException($"ไม่รู้จัก handler ของ {animal.Name}")
};
}

ปัญหา: เพิ่ม​สัตว์​ชนิด​ที่​สาม เช่น Bird ต้อง​แก้​สาม​จุด​พร้อม​กัน — เพิ่ม Bird : Animal, เพิ่ม BirdHandler : AnimalHandler, และ​เพิ่ม case ใหม่​ใน AnimalHandlerFactory ลืม​จุด​ไหน​จุด​หนึ่ง​ก็ throw exception ตอน runtime

วิธี​แก้​ตาม​แนวทาง​ของ Fowler: ใช้ Move Method และ Move Field ย้าย​พฤติกรรม​ของ AnimalHandler แต่ละ​ตัว​เข้าไป​รวม​กับ Animal subclass ที่​มัน​คู่​กัน​อยู่​แล้ว จาก​นั้น​ลบ​ลำดับ​ชั้น AnimalHandler ทิ้ง​ทั้งหมด (พับ​ลำดับ​ชั้น​ที่​ซ้ำซ้อน​เข้า​เป็น​หนึ่ง​เดียว — บาง​ตำรา​เรียก​ขั้นตอน​นี้​ว่า Collapse Hierarchy เมื่อ​ใช้​ต่อ​จาก Move Method/Move Field):

public abstract class Animal
{
public abstract string Name { get; }
public abstract void Play();
}
public class Dog : Animal
{
public override string Name => "Dog";
public override void Play() =>
Console.WriteLine($"{Name} วิ่งไปคาบลูกบอลกลับมา");
}
public class Cat : Animal
{
public override string Name => "Cat";
public override void Play() =>
Console.WriteLine($"{Name} เดินหนีไปนอนตรงมุมห้องอย่างสง่างาม");
}
// ไม่มี AnimalHandler ไม่มี factory ที่ต้องคอยจับคู่ type อีกต่อไป
foreach (var animal in animals)
{
animal.Play();
}

ตอน​นี้​เพิ่ม​สัตว์​ชนิด​ใหม่​ต้อง​แก้​แค่ จุด​เดียว: เขียน Bird : Animal แล้ว override Play() — compiler บังคับ​ให้ implement ครบ ไม่มี​ทาง​ลืม handler คู่​กัน​เพราะ​ไม่มี​ลำดับ​ชั้น​ที่​สอง​ให้​ลืม​อีก​แล้ว

ข้อ​ควร​ระวัง: การ​พับ​ลำดับ​ชั้น​เข้า​ด้วย​กัน​เหมาะ​เมื่อ​พฤติกรรม​ของ handler ผูก​อยู่​กับ​ตัวตน​ของ Animal โดยตรง แต่​ถ้า AnimalHandler มี dependency ภายนอก​ที่​ไม่​เกี่ยว​กับ Animal เลย (เช่น​ต้อง​คุย​กับ database หรือ external service) การ​ยัด​มัน​ลง​ใน Animal จะ​ไป​ผิด Single Responsibility Principle แทน — กรณี​นั้น​ควร​พิจารณา Visitor หรือ Strategy เพื่อ​แยก concern ออก​จาก​กัน​โดย​ไม่​ต้อง​คง parallel hierarchy ไว้ และ​ถ้า​ลอง refactor แล้ว code ยิ่ง​อ่าน​ยาก​กว่า​เดิม บาง​ครั้ง​การ​ปล่อย​ให้​สอง​ลำดับ​ชั้น​คู่​ขนาน​กัน​ไว้​ก็​ยัง​ดี​กว่า​ฝืน​ยุบ​รวม​จน​โครงสร้าง​พัง

  • Shotgun Surgery — กลิ่น​ตระกูล​เดียวกัน ที่ Parallel Inheritance เป็น​กรณี​พิเศษ
  • Divergent Change — Change Preventer อีก​ตัว​ที่​มัก​เจอ​คู่​กัน
  • Switch Statements — จุด map type ต่อ type ที่​มัก​ซ่อน​อยู่​ตรง​กลาง​ของ​กลิ่น​นี้
  • Don’t Repeat Yourself — หลักการ​ที่​กลิ่น​นี้​ละเมิด
  • Open/Closed Principle — เป้าหมาย​ที่​การ refactor ควร​พา​ไป​ถึง
  • Visitor — ทาง​เลือก​เมื่อ​พับ​ลำดับ​ชั้น​เข้า​ด้วย​กัน​ตรง ๆ ไม่​เหมาะ