- เปรียบเทียบให้เห็นความแตกต่าง
- 1. Divergent Change: คลาสเดียวเปลี่ยนด้วยหลายเหตุผล
- 2. Shotgun Surgery: เปลี่ยนเรื่องเดียวแต่ต้องตามแก้หลายคลาส
- 3. Parallel Inheritance Hierarchies: ลำดับชั้นการสืบทอดที่ต้องเพิ่มเป็นคู่
- ความเกี่ยวข้องกับหลักการออกแบบ
- ขั้นตอนฝึก Refactoring
- การนำตัวอย่างไปทดลอง
- เอกสารอ้างอิง
เมื่อความต้องการเปลี่ยน การแก้โปรแกรมควรมีขอบเขตที่เข้าใจได้ แต่บางระบบต้องแก้หลายเมธอดที่ไม่เกี่ยวกัน หรือตามแก้กฎเดียวกันในหลายคลาส ปัญหาเหล่านี้อยู่ในกลุ่ม 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
- เลือกการเปลี่ยนแปลงจริงหนึ่งเรื่อง เช่น เปลี่ยนข้อความสินค้า เปลี่ยนเกณฑ์ค่าจัดส่ง หรือเพิ่มรายงานลูกค้า
- บันทึกส่วนที่ต้องแก้ ระบุเหตุผลของแต่ละเมธอดและคลาส
- จำแนกปัญหา ดูว่าหลายเรื่องกระจุกในคลาสเดียว เรื่องเดียวกระจายหลายคลาส หรือ subclass ต้องเพิ่มเป็นคู่
- ยืนยันพฤติกรรมเดิม เก็บตัวอย่างอินพุตและผลลัพธ์ รวมกรณีขอบเขต
- ย้ายทีละส่วน โดยยังไม่เปลี่ยนกฎธุรกิจในรอบเดียวกัน
- ตรวจผลหลังย้าย ให้ผลลัพธ์เดิมยังคงเดิม และตรวจจุดเรียกใช้งาน
- ลองความต้องการถัดไป ประเมินว่าการเปลี่ยนเรื่องเดิมมีขอบเขตชัดเจนขึ้นหรือไม่
#เกณฑ์ประเมินงานนักศึกษา
- อธิบายกลิ่นโค้ดจากสาเหตุของการเปลี่ยนแปลงได้
- ยกตัวอย่างความต้องการที่กระทบโครงสร้างก่อนปรับได้
- เลือกตำแหน่งเจ้าของกฎหรือพฤติกรรมได้เหมาะสม
- แสดงหลักฐานว่าผลลัพธ์ก่อนและหลังยังตรงกัน
- อธิบายข้อแลกเปลี่ยนจากคลาสและ dependency ที่เพิ่มขึ้นได้
#คำถามทบทวน
- คลาสเดียวต้องเปลี่ยนเมื่อทั้งรูปแบบไฟล์และกฎส่วนลดเปลี่ยน เป็นสัญญาณใด?
- เปลี่ยนกฎตรวจรหัสสินค้าแล้วต้องแก้ห้าคลาส ควรเริ่มหาขอบเขตเจ้าของกฎอย่างไร?
- ถ้าเพิ่มรายงานใหม่แล้วต้องเพิ่มตัวจัดรูปแบบเฉพาะเสมอ ควรตรวจการสืบทอดส่วนใด?
- ทำไมการนับจำนวนไฟล์ที่เปลี่ยนเพียงอย่างเดียวจึงตัดสินคุณภาพการออกแบบไม่ได้?
- เมื่อใดการรวมโค้ดที่เหมือนกันอาจทำให้ระบบเปลี่ยนยากกว่าเดิม?
#การนำตัวอย่างไปทดลอง
โค้ดแต่ละบล็อกเป็นตัวอย่างอิสระ อย่าวางเวอร์ชันก่อนและหลังไว้ใน package เดียวกันพร้อมกัน เพราะบางคลาสใช้ชื่อซ้ำ บันทึกแต่ละบล็อกเป็น Demo.java ในคนละโฟลเดอร์แล้วตรวจการคอมไพล์ด้วย:
javac Demo.java
ไม่มี main ในตัวอย่าง ต้องเพิ่มจุดเรียกใช้เองหากต้องการรัน โปรแกรมใช้ int เพื่อเน้นโครงสร้าง จึงสมมติว่าค่าเป็นจำนวนเต็มที่ไม่ติดลบและไม่ล้นช่วง สำหรับระบบเงินจริงต้องกำหนดหน่วยเงิน การปัดเศษ และการตรวจข้อมูลให้ชัดเจน
#เอกสารอ้างอิง
- Refactoring.Guru — Divergent Change
- Refactoring.Guru — Shotgun Surgery
- Refactoring.Guru — Parallel Inheritance Hierarchies