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
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”โครงสร้างก่อน 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 — ทางเลือกเมื่อพับลำดับชั้นเข้าด้วยกันตรง ๆ ไม่เหมาะ