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

Collapse Hierarchy

Collapse Hierarchy รวม superclass กับ subclass ที่ไม่ต่างกันมากพอจะมีอยู่เป็นสอง type แยกกันอีกต่อไป สมาชิกของตัวหนึ่งถูกพับเข้าไปในอีกตัว class ที่ตอนนี้ซ้ำซ้อนถูกลบทิ้ง และผู้อ่านทุกคนก็รอดพ้นจากชั้นของการอ้อมที่ไม่ได้ให้อะไรพวกเขาเลย

ลำดับชั้นสึกกร่อนได้ การ refactor ดึงสมาชิกขึ้นบ้างผลักลงบ้าง จนสุดท้าย subclass แทบไม่เหลืออะไรเพิ่ม อาจมี field จิ๊บจ๊อยตัวเดียว หรือมี override ที่ตอนนี้เหมือน superclass เป๊ะ นี่คือ Lazy Class ในคราบ subclass คือมี type เพิ่ม ไฟล์เพิ่ม และการกระโดดเพิ่มให้ผู้อ่านทุกคน ทั้งหมดเพื่อแสดงความต่างที่หายไปเงียบ ๆ นานแล้ว เมื่อ subclass กับ superclass กลายเป็นฝาแฝดกัน ท่าที่ซื่อตรงที่สุดคือยุบรวมเป็นตัวเดียว

subclass Salesperson เคยต่อยอด Employee อย่างมีความหมาย แต่ refactoring หลายรอบทำให้เหลือ field เดียว ซึ่ง Employee ก็ถือเองได้ดีพอกัน เราจึงยุบรวม

// Before — Salesperson adds almost nothing over Employee
class Employee {
constructor(public name: string, public monthlyPay: number) {}
annualCost(): number { return this.monthlyPay * 12; }
}
class Salesperson extends Employee {
constructor(name: string, monthlyPay: number, public region: string) {
super(name, monthlyPay);
}
}
// After — one Employee carries the region field directly
class Employee {
constructor(
public name: string,
public monthlyPay: number,
public region: string = "",
) {}
annualCost(): number { return this.monthlyPay * 12; }
}
classDiagram
  class Employee {
    +name string
    +grade number
    +annualCost() number
  }
  note for Employee "Salesperson merged back in — one type now"
สอง type ที่เกือบเหมือนกันกลายเป็น Employee เดียว
  1. ตัดสินใจว่า class ไหนจะอยู่รอด ปกติ superclass จะดูดซับ subclass เข้าไป แต่ถ้าชื่อ subclass เข้ากับแนวคิดที่รวมแล้วมากกว่า ก็เก็บชื่อนั้นไว้แทน
  2. ใช้ Pull Up และ Push Down เพื่อย้าย field และ method ทั้งหมดเข้าไปใน class เดียวที่จะอยู่รอด จน class ที่กำลังจะลบเหลือว่างเปล่า
  3. ชี้ทุกการอ้างอิงและการเรียก constructor ใหม่จาก class ที่ถูกลบไปยัง class ที่อยู่รอด แล้วรัน test
  4. ลบ class ที่ตอนนี้ว่างเปล่าทิ้ง (และเอา embedding/wrapper ออกใน Go และ Rust)
  5. รัน test อีกครั้งเป็นครั้งสุดท้าย โปรแกรมควรทำงานเหมือนเดิมเป๊ะ ๆ โดยมี type ให้นำทางน้อยลงหนึ่งตัว

ยุบลำดับชั้นเมื่อ subclass เพิ่มอะไรน้อยมากจน type ที่เกินมากลายเป็นภาระล้วน ๆ คือไม่มี behavior ที่มีความหมาย ไม่มีความต่างจริง เหลือแต่การอ้อม พอถอดออกแล้ว code สั้นลงและแบนลง ผู้อ่านก็เลิกต้องกระโดดไปมาระหว่าง superclass กับ subclass เพียงเพื่อประกอบแนวคิดเดียว

ข้อแลกเปลี่ยนคือการย้อนกลับได้: ถ้าความแตกต่างกลับมาในภายหลัง คุณก็จะ re-extract subclass (หรือ interface) ขึ้นมาใหม่ ซึ่งไม่เป็นไร — Collapse Hierarchy คือสิ่งตรงข้ามตามธรรมชาติของ Extract Superclass และการ refactoring ก็ตั้งใจให้ไหลได้ทั้งสองทางเมื่อความเข้าใจของคุณเปลี่ยน ใน Go และ Rust ไม่เคยมีลำดับชั้นของ class ตั้งแต่แรก ดังนั้น “collapse” จึงเป็นเพียงการลบ wrapper struct ที่ไม่จำเป็นและฝัง field เดียวเข้าไป

ใช้ Collapse Hierarchy เมื่อหลีกเลี่ยงเมื่อ
subclass ไม่มี behavior ของตัวเองเลย — เหมือน parent ทั้งหมดsubclass มี behavior เฉพาะที่ยังใช้อยู่
subclass มีอยู่เพราะ refactoring เก่าที่ไม่ถูก clean upการ collapse จะทำให้ class ที่เหลือใหญ่เกินไป
inheritance tree ลึกเกินไปจน navigate ยากมี test ที่ test behavior ของ subclass โดยเฉพาะ

⚠️ ไม่ควร Collapse Hierarchy เมื่อ:

  • subclass นั้นอาจถูก extend ในอนาคต — ลบไปจะต้องสร้างใหม่
  • subclass มีชื่อที่สื่อ domain concept ชัดเจน แม้ body จะเล็ก
  • ยังมี test ที่ test interface ของ subclass อยู่ — collapse จะ break test
เมื่อไรคือจังหวะที่เหมาะจะทำ Collapse Hierarchy?
Collapse Hierarchy คือสิ่งตรงข้ามตามธรรมชาติของ refactoring ตัวใด?
subclass ที่แทบว่างเปล่าแสดงกลิ่นไม่ดีใด?
"collapse" หมายถึงอะไรใน Go หรือ Rust ซึ่งไม่มี inheritance?