เมื่อความต้องการเปลี่ยน การแก้โปรแกรมควรมีขอบเขตที่เข้าใจได้ แต่บางระบบต้องแก้หลายเมธอดที่ไม่เกี่ยวกัน หรือตามแก้กฎเดียวกันในหลายคลาส ปัญหาเหล่านี้อยู่ในกลุ่ม Change Preventers

info-code-smells-change-preventers

Code Smell เป็นสัญญาณให้ตรวจสอบการออกแบบ ไม่ได้หมายความว่าโปรแกรมมีข้อผิดพลาดเสมอไป การ Refactoring ควรรักษาพฤติกรรมที่สังเกตได้ของระบบ

#เปรียบเทียบให้เห็นความแตกต่าง

Code Smell คำถามช่วยตรวจจับ ลักษณะปัญหา แนวทางหลัก
Divergent Change ทำไมคลาสนี้ต้องเปลี่ยนด้วยเหตุผลหลายเรื่อง? ความรับผิดชอบต่างเรื่องอยู่ในคลาสเดียว Extract Class และย้ายพฤติกรรมให้ตรงหน้าที่
Shotgun Surgery ทำไมเปลี่ยนกฎเดียวต้องตามแก้หลายคลาส? ความรับผิดชอบเรื่องเดียวกระจายหลายจุด Move Method / Move Field ให้กฎมีเจ้าของ
Parallel Inheritance Hierarchies ทำไมเพิ่มคลาสลูกหนึ่งตัวต้องเพิ่มอีกตัวคู่กัน? ลำดับชั้นการสืบทอดเติบโตผูกกัน ใช้ความสัมพันธ์ระหว่างออบเจ็กต์และลดลำดับชั้นที่ซ้ำซ้อน

วิธีจำ: หลายเหตุผล → คลาสเดียว / เหตุผลเดียว → หลายคลาส / เพิ่มคลาสลูก → ต้องเพิ่มคู่

#1. Divergent Change: คลาสเดียวเปลี่ยนด้วยหลายเหตุผล

#อาการ

คลาสหนึ่งรวมงานหลายด้านไว้ด้วยกัน เช่น เก็บข้อมูลสินค้า จัดรูปแบบข้อความ และคำนวณยอดซื้อ เมื่อกฎของแต่ละงานเปลี่ยน ผู้พัฒนาต้องกลับมาแก้คลาสเดิมซ้ำ ๆ แม้การเปลี่ยนแปลงเหล่านั้นเป็นคนละเรื่อง [1]

#สถานการณ์ตัวอย่าง

คลาส ProductManager ถูกแก้เมื่อทีมต้องการ:

  • เปลี่ยนวิธีจัดเก็บและค้นหาสินค้า
  • เปลี่ยนข้อความแสดงผล
  • เปลี่ยนสูตรคำนวณยอดซื้อ

#ก่อนปรับโครงสร้าง

class ProductManager {
    private final java.util.Map<String, String> products =
            new java.util.HashMap<>();

    void save(String id, String name) {
        products.put(id, name);
    }

    String find(String id) {
        return products.get(id);
    }

    String display(String name) {
        return "Product: " + name;
    }

    int total(int unitPrice, int quantity) {
        return unitPrice * quantity;
    }
}

ปัญหาไม่ได้อยู่ที่จำนวนบรรทัด คลาสนี้มีเหตุผลในการเปลี่ยนแปลงจากหลายแหล่ง แม้เขียนให้สั้นลงก็ยังมีขอบเขตความรับผิดชอบเดิม

#หลังปรับโครงสร้าง: Extract Class

class ProductRepository {
    private final java.util.Map<String, String> products =
            new java.util.HashMap<>();

    void save(String id, String name) {
        products.put(id, name);
    }

    String find(String id) {
        return products.get(id);
    }
}

class ProductPresenter {
    String display(String name) {
        return "Product: " + name;
    }
}

class OrderCalculator {
    int total(int unitPrice, int quantity) {
        return unitPrice * quantity;
    }
}

แต่ละคลาสรับผิดชอบงานที่เปลี่ยนด้วยเหตุผลใกล้เคียงกัน การแก้รูปแบบข้อความจึงอยู่ที่ ProductPresenter ขณะที่การเปลี่ยนวิธีจัดเก็บอยู่ที่ ProductRepository

พฤติกรรมที่ต้องคงเดิม: บันทึกแล้วค้นหาได้ค่าเดิม ข้อความแสดงผลเหมือนเดิม และยอดซื้อเท่ากับราคา × จำนวนสำหรับอินพุตเดิม

#ข้อควรระวัง

อย่าแยกทุกเมธอดออกเป็นคลาสโดยอัตโนมัติ เมธอดที่ดูแล invariant เดียวกันควรอยู่ใกล้กัน ตัวอย่างนี้แยกตามแหล่งความเปลี่ยนแปลง ไม่ได้แยกตามขนาดไฟล์

#2. Shotgun Surgery: เปลี่ยนเรื่องเดียวแต่ต้องตามแก้หลายคลาส

#อาการ

กฎเดียวกันถูกเขียนซ้ำหรือแบ่งไว้หลายคลาส เมื่อปรับกฎนั้น ผู้พัฒนาต้องแก้หลายจุดให้ตรงกัน ถ้าตกหล่นเพียงจุดเดียว ระบบอาจแสดงผลไม่สอดคล้องกัน [2]

#สถานการณ์ตัวอย่าง

สมมติระบบจำลองกำหนดค่าจัดส่ง 50 หน่วย เมื่อยอดซื้อไม่ถึง 1,000 หน่วย กฎนี้อยู่ทั้งหน้าตะกร้าและใบแจ้งยอด หากเปลี่ยนเกณฑ์เป็น 1,500 ต้องแก้ทั้งสองคลาส

ตัวเลขนี้เป็นเพียงกติกาในตัวอย่าง ไม่ใช่อัตราจริงของบริการใด

#ก่อนปรับโครงสร้าง

class CartView {
    int shippingFee(int subtotal) {
        return subtotal >= 1000 ? 0 : 50;
    }
}

class InvoiceService {
    int shippingFee(int subtotal) {
        return subtotal >= 1000 ? 0 : 50;
    }
}

โค้ดซ้ำสองบรรทัดอาจดูเล็ก แต่ผลเสียเกิดเมื่อกฎเปลี่ยนแล้วแต่ละจุดไม่ถูกปรับพร้อมกัน

#หลังปรับโครงสร้าง: ให้กฎมีเจ้าของ

class ShippingPolicy {
    int fee(int subtotal) {
        return subtotal >= 1000 ? 0 : 50;
    }
}

class CartView {
    private final ShippingPolicy policy;

    CartView(ShippingPolicy policy) {
        this.policy = policy;
    }

    int shippingFee(int subtotal) {
        return policy.fee(subtotal);
    }
}

class InvoiceService {
    private final ShippingPolicy policy;

    InvoiceService(ShippingPolicy policy) {
        this.policy = policy;
    }

    int shippingFee(int subtotal) {
        return policy.fee(subtotal);
    }
}

การเปลี่ยนเกณฑ์ค่าจัดส่งครั้งต่อไปอยู่ใน ShippingPolicy โดยทั้งสองจุดใช้กฎเดียวกันผ่านการส่ง dependency ทาง constructor

พฤติกรรมที่ต้องคงเดิม: ยอด 999 เสียค่าจัดส่ง 50 ยอด 1,000 และ 1,001 เสียค่าจัดส่ง 0 ทั้งหน้าตะกร้าและใบแจ้งยอด

การ Refactoring รอบนี้ยังไม่เปลี่ยนเกณฑ์เป็น 1,500 เพราะการย้ายโครงสร้างและการเปลี่ยนกฎธุรกิจควรตรวจสอบแยกกัน

#ข้อควรระวัง

โค้ดเหมือนกันไม่ได้แปลว่าต้องรวมเสมอ หากค่าจัดส่งสำหรับลูกค้าทั่วไปและคู่ค้าธุรกิจเป็นคนละนโยบาย ควรแยกให้ชัด อย่ารวมเพียงเพราะตัวเลขปัจจุบันบังเอิญเท่ากัน

การเพิ่มฟีเจอร์ซึ่งมีทั้ง API หน้าจอ และฐานข้อมูลก็อาจต้องแก้หลายคลาสตามปกติ ต้องตรวจว่าการแก้นั้นเป็นการทำกฎเดียวซ้ำหลายจุดหรือเป็นงานต่างหน้าที่ที่จำเป็น

#3. Parallel Inheritance Hierarchies: ลำดับชั้นการสืบทอดที่ต้องเพิ่มเป็นคู่

#อาการ

เมื่อเพิ่ม subclass ในลำดับชั้นหนึ่ง ต้องสร้าง subclass คู่กันในอีกลำดับชั้นเสมอ [3]

#สถานการณ์ตัวอย่าง

ระบบมีลำดับชั้นรายงานและตัวจัดรูปแบบแยกกัน แต่ตัวจัดรูปแบบแต่ละ subclass ต่างกันเพียงหัวเรื่อง:

รายงาน ตัวจัดรูปแบบที่ต้องสร้างคู่กัน
SalesReport SalesReportFormatter
StockReport StockReportFormatter
เพิ่ม CustomerReport ต้องเพิ่ม CustomerReportFormatter

#ก่อนปรับโครงสร้าง

abstract class Report {
    abstract String body();
}

class SalesReport extends Report {
    String body() {
        return "Sales = 100";
    }
}

class StockReport extends Report {
    String body() {
        return "Stock = 20";
    }
}

abstract class ReportFormatter {
    abstract String format(Report report);
}

class SalesReportFormatter extends ReportFormatter {
    String format(Report report) {
        return "Sales Report\n" + report.body();
    }
}

class StockReportFormatter extends ReportFormatter {
    String format(Report report) {
        return "Stock Report\n" + report.body();
    }
}

นอกจากต้องเพิ่มคลาสเป็นคู่แล้ว API นี้ยังยอมให้ส่ง StockReport เข้า SalesReportFormatter ได้ หัวเรื่องจึงอาจไม่ตรงกับเนื้อหา

#หลังปรับโครงสร้าง: เก็บข้อมูลเฉพาะชนิดไว้ที่รายงาน

abstract class Report {
    abstract String title();
    abstract String body();
}

class SalesReport extends Report {
    String title() {
        return "Sales Report";
    }

    String body() {
        return "Sales = 100";
    }
}

class StockReport extends Report {
    String title() {
        return "Stock Report";
    }

    String body() {
        return "Stock = 20";
    }
}

class ReportFormatter {
    String format(Report report) {
        return report.title() + "\n" + report.body();
    }
}

ReportFormatter ใช้ออบเจ็กต์ Report ผ่าน interface ของ abstract class โดยไม่ต้องมี subclass ตามประเภทรายงาน การเพิ่ม CustomerReport จึงไม่บังคับให้เพิ่ม formatter คู่กัน

พฤติกรรมที่ต้องคงเดิม: คู่รายงานและ formatter ที่ใช้งานถูกต้องเดิมต้องได้ข้อความเท่าเดิม ส่วนการจับคู่ที่ผิดพลาดถูกกำจัดด้วยโครงสร้างใหม่

#ถ้าต้องรองรับหลายรูปแบบไฟล์

หาก HTML และข้อความธรรมดามีวิธี render ต่างกันจริง อาจแยกตัวจัดรูปแบบตามชนิดผลลัพธ์ เช่น HtmlFormatter และ TextFormatter แล้วให้แต่ละตัวใช้ข้อมูลจาก Report ได้ทุกชนิด

นี่เป็นแนวประยุกต์ Composition และอาจพัฒนาเป็น Bridge เมื่อมีสองมิติที่ต้องเปลี่ยนอย่างอิสระ ไม่จำเป็นต้องใช้ Bridge ทุกครั้งที่พบกลิ่นนี้

อย่าฝืนรวมลำดับชั้นหากแต่ละคู่มีอัลกอริทึมเฉพาะที่ต่างกันมาก การลดจำนวนคลาสโดยเพิ่มเงื่อนไขตรวจชนิดจำนวนมากอาจทำให้ดูแลยากกว่าเดิม

#ความเกี่ยวข้องกับหลักการออกแบบ

หลักการ ใช้พิจารณาอย่างไร
Single Responsibility Principle ตรวจเหตุผลในการเปลี่ยนแปลงของคลาส ไม่ใช่นับจำนวนเมธอด
High Cohesion รวมข้อมูลและพฤติกรรมที่ดูแลเรื่องเดียวกันให้อยู่ใกล้กัน
Low Coupling ลดความจำเป็นที่หลายส่วนต้องเปลี่ยนไปพร้อมกัน
Open/Closed Principle ประเมินว่าการเพิ่มความสามารถต้องแก้ส่วนเดิมมากเพียงใด
Composition ให้ออบเจ็กต์ร่วมงานกันเมื่อช่วยลดการสืบทอดที่ผูกกัน

ตารางนี้เป็นการอธิบายเชื่อมโยงสำหรับการเรียนรู้ ไม่ใช่กฎว่าหนึ่ง Code Smell ต้องใช้หนึ่ง Design Pattern

#ขั้นตอนฝึก Refactoring

  1. เลือกการเปลี่ยนแปลงจริงหนึ่งเรื่อง เช่น เปลี่ยนข้อความสินค้า เปลี่ยนเกณฑ์ค่าจัดส่ง หรือเพิ่มรายงานลูกค้า
  2. บันทึกส่วนที่ต้องแก้ ระบุเหตุผลของแต่ละเมธอดและคลาส
  3. จำแนกปัญหา ดูว่าหลายเรื่องกระจุกในคลาสเดียว เรื่องเดียวกระจายหลายคลาส หรือ subclass ต้องเพิ่มเป็นคู่
  4. ยืนยันพฤติกรรมเดิม เก็บตัวอย่างอินพุตและผลลัพธ์ รวมกรณีขอบเขต
  5. ย้ายทีละส่วน โดยยังไม่เปลี่ยนกฎธุรกิจในรอบเดียวกัน
  6. ตรวจผลหลังย้าย ให้ผลลัพธ์เดิมยังคงเดิม และตรวจจุดเรียกใช้งาน
  7. ลองความต้องการถัดไป ประเมินว่าการเปลี่ยนเรื่องเดิมมีขอบเขตชัดเจนขึ้นหรือไม่

#เกณฑ์ประเมินงานนักศึกษา

  • อธิบายกลิ่นโค้ดจากสาเหตุของการเปลี่ยนแปลงได้
  • ยกตัวอย่างความต้องการที่กระทบโครงสร้างก่อนปรับได้
  • เลือกตำแหน่งเจ้าของกฎหรือพฤติกรรมได้เหมาะสม
  • แสดงหลักฐานว่าผลลัพธ์ก่อนและหลังยังตรงกัน
  • อธิบายข้อแลกเปลี่ยนจากคลาสและ dependency ที่เพิ่มขึ้นได้

#คำถามทบทวน

  1. คลาสเดียวต้องเปลี่ยนเมื่อทั้งรูปแบบไฟล์และกฎส่วนลดเปลี่ยน เป็นสัญญาณใด?
  2. เปลี่ยนกฎตรวจรหัสสินค้าแล้วต้องแก้ห้าคลาส ควรเริ่มหาขอบเขตเจ้าของกฎอย่างไร?
  3. ถ้าเพิ่มรายงานใหม่แล้วต้องเพิ่มตัวจัดรูปแบบเฉพาะเสมอ ควรตรวจการสืบทอดส่วนใด?
  4. ทำไมการนับจำนวนไฟล์ที่เปลี่ยนเพียงอย่างเดียวจึงตัดสินคุณภาพการออกแบบไม่ได้?
  5. เมื่อใดการรวมโค้ดที่เหมือนกันอาจทำให้ระบบเปลี่ยนยากกว่าเดิม?

#การนำตัวอย่างไปทดลอง

โค้ดแต่ละบล็อกเป็นตัวอย่างอิสระ อย่าวางเวอร์ชันก่อนและหลังไว้ใน package เดียวกันพร้อมกัน เพราะบางคลาสใช้ชื่อซ้ำ บันทึกแต่ละบล็อกเป็น Demo.java ในคนละโฟลเดอร์แล้วตรวจการคอมไพล์ด้วย:

javac Demo.java

ไม่มี main ในตัวอย่าง ต้องเพิ่มจุดเรียกใช้เองหากต้องการรัน โปรแกรมใช้ int เพื่อเน้นโครงสร้าง จึงสมมติว่าค่าเป็นจำนวนเต็มที่ไม่ติดลบและไม่ล้นช่วง สำหรับระบบเงินจริงต้องกำหนดหน่วยเงิน การปัดเศษ และการตรวจข้อมูลให้ชัดเจน

#เอกสารอ้างอิง

  1. Refactoring.Guru — Divergent Change
  2. Refactoring.Guru — Shotgun Surgery
  3. Refactoring.Guru — Parallel Inheritance Hierarchies