- ภาพรวม
- 1. Long Method — เมธอดยาวเกินกว่าจะเข้าใจได้ง่าย
- 2. Large Class — คลาสใหญ่ที่รับหลายบทบาท
- 3. Primitive Obsession — ใช้ชนิดข้อมูลพื้นฐานแทนความหมายของโดเมน
- 4. Long Parameter List — รายการพารามิเตอร์ยาวและเรียกใช้ผิดง่าย
- 5. Data Clumps — กลุ่มข้อมูลที่เดินทางด้วยกันซ้ำ ๆ
- เปรียบเทียบสามอาการที่มักพบร่วมกัน
- ขั้นตอน Refactoring ที่ใช้ในเวิร์กช็อป
- แบบฝึกหัดท้ายบท
- อ่านต่อและแหล่งอ้างอิง
Code Smell คือสัญญาณในโค้ดที่ชวนให้ตรวจสอบการออกแบบ แม้โปรแกรมอาจยังทำงานถูกต้อง แต่โครงสร้างบางส่วนอาจทำให้การอ่าน การทดสอบ และการแก้ไขในอนาคตยากขึ้น

บทความนี้อธิบาย Code Smell กลุ่ม Bloaters จำนวน 5 แบบ ได้แก่ Long Method, Large Class, Primitive Obsession, Long Parameter List และ Data Clumps โดยสรุปแนวคิดจาก Refactoring.Guru และใช้ตัวอย่าง Java ที่จัดทำขึ้นเพื่อการสอน
จำนวนบรรทัดและจำนวนพารามิเตอร์เป็นเพียงเกณฑ์เริ่มต้นในการทบทวนโค้ด ไม่ใช่เงื่อนไขที่บอกว่าโค้ดผิดทันที เป้าหมายของ Refactoring คือปรับโครงสร้างโดยรักษาพฤติกรรมที่สังเกตได้จากภายนอก
#ภาพรวม
| Code Smell | สัญญาณที่ควรสังเกต | คำถามระหว่าง Code Review | แนวทางเริ่มต้น |
|---|---|---|---|
| Long Method | เมธอดยาวหรือทำหลายขั้นตอนปะปนกัน | อธิบายหน้าที่ของเมธอดด้วยประโยคเดียวได้หรือไม่? | Extract Method |
| Large Class | คลาสมีข้อมูลและพฤติกรรมหลายกลุ่ม | ส่วนใดเปลี่ยนด้วยเหตุผลต่างกัน? | Extract Class |
| Primitive Obsession | ใช้ตัวเลขหรือข้อความแทนแนวคิดของโดเมน | ชนิดข้อมูลช่วยป้องกันการใช้ค่าผิดหรือไม่? | Value Object / Enum |
| Long Parameter List | เมธอดรับข้อมูลหลายตัวจนเรียกใช้ยาก | อาร์กิวเมนต์ใดสัมพันธ์กันหรือไม่จำเป็น? | Introduce Parameter Object |
| Data Clumps | กลุ่มข้อมูลเดิมปรากฏร่วมกันหลายแห่ง | ข้อมูลเหล่านี้เป็นแนวคิดเดียวกันหรือไม่? | Extract Class / Parameter Object |
ตัวอย่างด้านล่างใช้ไวยากรณ์ Java 17 เช่น record โค้ดแต่ละหัวข้อเป็นตัวอย่างแยกกัน ไม่ควรรวมทุกบล็อกเป็นไฟล์เดียวทันที บล็อกที่มี ... เป็นโครงร่างเพื่อแสดงการออกแบบ
#1. Long Method — เมธอดยาวเกินกว่าจะเข้าใจได้ง่าย
เมธอดอาจเริ่มจากงานเล็ก ๆ แล้วสะสมการคำนวณ เงื่อนไข และการแสดงผลไว้ด้วยกัน Refactoring.Guru เสนอให้เริ่มตั้งคำถามเมื่อเมธอดยาวเกินประมาณ 10 บรรทัด แต่ความยาวต้องพิจารณาร่วมกับความซับซ้อนและหน้าที่ของเมธอด [1]
#ก่อนปรับปรุง
import java.util.List;
record OrderLine(int unitPriceBaht, int quantity) {}
class ReceiptBefore {
void print(List<OrderLine> lines, boolean member) {
int subtotal = 0;
for (OrderLine line : lines) {
subtotal += line.unitPriceBaht() * line.quantity();
}
int discount = 0;
if (member && subtotal >= 1000) {
discount = subtotal / 10;
}
int total = subtotal - discount;
System.out.println("Subtotal: " + subtotal);
System.out.println("Discount: " + discount);
System.out.println("Total: " + total);
}
}
เมธอดเดียวมีทั้งการรวมยอด การตัดสินใจให้ส่วนลด และรูปแบบใบเสร็จ เมื่อเปลี่ยนเงื่อนไขส่วนลด ผู้อ่านต้องแยกส่วนที่เกี่ยวข้องออกจากรายละเอียดการพิมพ์เอง
#หลังปรับปรุง: Extract Method
ตัวอย่างนี้ใช้ OrderLine เดียวกับด้านบน และสมมติว่าราคากับจำนวนผ่านการตรวจสอบแล้ว รวมทั้งผลรวมอยู่ในช่วงของ int จำนวนเงินใช้หน่วยบาทเต็มเพื่อให้เห็นโครงสร้างชัดเจน ระบบจริงควรเลือกหน่วยเงินและนโยบายการปัดเศษอย่างชัดเจน
class ReceiptAfter {
void print(java.util.List<OrderLine> lines, boolean member) {
int subtotal = calculateSubtotal(lines);
int discount = calculateDiscount(subtotal, member);
printSummary(subtotal, discount);
}
private int calculateSubtotal(java.util.List<OrderLine> lines) {
int subtotal = 0;
for (OrderLine line : lines) {
subtotal += line.unitPriceBaht() * line.quantity();
}
return subtotal;
}
private int calculateDiscount(int subtotal, boolean member) {
return member && subtotal >= 1000 ? subtotal / 10 : 0;
}
private void printSummary(int subtotal, int discount) {
System.out.println("Subtotal: " + subtotal);
System.out.println("Discount: " + discount);
System.out.println("Total: " + (subtotal - discount));
}
}
ตอนนี้เมธอด print() แสดงลำดับงาน ส่วนชื่อเมธอดย่อยอธิบายรายละเอียดแต่ละขั้นตอน โดยรักษาสูตรคำนวณและลำดับข้อความเดิม
จุดตรวจสอบ: ยอด 1,200 บาทสำหรับสมาชิกต้องได้ส่วนลด 120 บาทและยอดสุทธิ 1,080 บาท ตรวจขอบเขต 999 และ 1,000 บาท รวมถึงกรณีไม่เป็นสมาชิกด้วย
อย่าแยกทุกสองบรรทัดโดยอัตโนมัติ หากชื่อเมธอดใหม่ไม่ช่วยสื่อความหมายและทำให้ต้องกระโดดอ่านไปมา การแยกนั้นอาจยังไม่ช่วยให้เข้าใจดีขึ้น
#2. Large Class — คลาสใหญ่ที่รับหลายบทบาท
จำนวนฟิลด์หรือเมธอดมากเป็นจุดเริ่มต้นให้ตรวจสอบ หากพบพฤติกรรมหลายกลุ่มที่แยกจากกันได้ อาจใช้ Extract Class เพื่อแยกความรับผิดชอบ [2]
#ก่อนปรับปรุง
ตัวอย่างต่อไปนี้เป็นโครงร่าง คลาสเล็กที่ยกมามีเจตนาแสดงหน้าที่ปะปนกัน ไม่ได้ใช้จำนวนบรรทัดของตัวอย่างตัดสินว่าเป็น Large Class
class CustomerManager {
void changeAddress(String customerId, String address) { /* ... */ }
void saveCustomer(String customerId) { /* ... */ }
void sendWelcomeEmail(String customerId) { /* ... */ }
void exportMonthlyReport() { /* ... */ }
}
ข้อมูลลูกค้า การจัดเก็บ การส่งอีเมล และรายงาน มีเหตุผลในการเปลี่ยนแปลงต่างกัน ถ้าทั้งหมดสะสมอยู่ในคลาสเดียว ทีมจะต้องแก้ไฟล์เดียวเพื่อรองรับงานที่ไม่เกี่ยวข้องกัน
#หลังปรับปรุง: แยกตามหน้าที่ที่ชัดเจน
class Customer {
void changeAddress(String address) { /* ... */ }
}
class CustomerRepository {
void save(Customer customer) { /* ... */ }
}
class WelcomeEmailSender {
void send(Customer customer) { /* ... */ }
}
class CustomerReportService {
void exportMonthlyReport() { /* ... */ }
}
โครงสร้างนี้เป็นข้อเสนอสำหรับตัวอย่าง ไม่ใช่รูปแบบที่ทุกระบบต้องใช้ ควรย้ายข้อมูลและพฤติกรรมที่สัมพันธ์กันไปด้วยกัน แล้วปรับจุดเรียกใช้อย่างค่อยเป็นค่อยไป โดยรักษาลำดับและผลข้างเคียงเดิม
จุดตรวจสอบ: การแยกคลาสต้องไม่ทำให้บันทึกข้อมูลซ้ำ ส่งอีเมลซ้ำ หรือเปลี่ยนธุรกรรมโดยไม่ตั้งใจ การสร้าง interface อย่างเดียวไม่ได้ลดภาระของคลาสที่ยังรวมงานทั้งหมดไว้
#3. Primitive Obsession — ใช้ชนิดข้อมูลพื้นฐานแทนความหมายของโดเมน
อาการนี้เกิดเมื่อข้อมูลที่มีกฎเฉพาะ เช่น จำนวนเงิน ช่วงวันที่ หรือรหัสบทบาท ถูกแทนด้วยตัวเลขหรือข้อความทั่วไปจนกฎกระจายอยู่หลายจุด การสร้างชนิดข้อมูลของโดเมนช่วยรวมความหมายและพฤติกรรมที่เกี่ยวข้อง [3]
คำว่า Primitive ในชื่อ smell ครอบคลุมการใช้ชนิดข้อมูลทั่วไปเพื่อแทนโดเมนด้วย แม้ String จะไม่ใช่ primitive type ตามนิยามของภาษา Java
#ก่อนปรับปรุง
int role = 1; // 1 หมายถึง ADMIN
boolean canManageUsers(int role) {
return role == 1;
}
ชื่อค่าคงที่อย่าง USER_ADMIN_ROLE อ่านง่ายกว่าตัวเลขล้วน แต่ int ยังรับค่าอย่าง 999 ได้ และยังอาจสลับกับรหัสชนิดอื่นโดย compiler ไม่ช่วยตรวจ
#หลังปรับปรุง: Enum
enum Role {
ADMIN, MEMBER;
boolean canManageUsers() {
return this == ADMIN;
}
}
จุดเรียกใช้เปลี่ยนเป็น Role.ADMIN.canManageUsers() และชนิดข้อมูลบอกชุดบทบาทที่รองรับชัดเจนขึ้น สำหรับระบบที่เก็บบทบาทเป็นตัวเลข ต้องกำหนด mapping ระหว่างรหัสเดิมกับ enum อย่างชัดเจน ไม่ใช้ ordinal() เป็นรหัสถาวร
#อีกตัวอย่าง: Value Object สำหรับช่วงวันที่
import java.time.LocalDate;
import java.util.Objects;
record DateRange(LocalDate start, LocalDate end) {
DateRange {
Objects.requireNonNull(start, "start");
Objects.requireNonNull(end, "end");
if (end.isBefore(start)) {
throw new IllegalArgumentException("end must not precede start");
}
}
boolean contains(LocalDate date) {
Objects.requireNonNull(date, "date");
return !date.isBefore(start) && !date.isAfter(end);
}
}
DateRange ในตัวอย่างกำหนดให้รวมวันเริ่มต้นและวันสิ้นสุด ทั้งสองวันเท่ากันได้ ก่อนนำไปแทนข้อมูลเดิม ต้องตรวจว่านี่คือนโยบายเดิมของระบบ หากเดิมยอมรับช่วงกลับด้าน การเพิ่ม validation จะเป็นการเปลี่ยนพฤติกรรมและควรแยกเป็นงานอีกส่วนหนึ่ง
การใช้ int หรือ String ตามปกติไม่ใช่ปัญหาเสมอไป ควรสร้างชนิดข้อมูลใหม่เมื่อมีความหมาย กฎ หรือพฤติกรรมที่คุ้มค่าต่อการรวมไว้ด้วยกัน
#4. Long Parameter List — รายการพารามิเตอร์ยาวและเรียกใช้ผิดง่าย
Refactoring.Guru ใช้จำนวนมากกว่า 3–4 ตัวเป็นสัญญาณเริ่มต้น แนวทางแก้ ได้แก่ ส่ง object เดิมเมื่อข้อมูลมาจาก object เดียวกัน หรือสร้าง parameter object เมื่อข้อมูลสัมพันธ์กันแต่ได้มาจากหลายแหล่ง ทั้งนี้ต้องคำนึงถึง dependency ที่เพิ่มขึ้นด้วย [4]
#ก่อนปรับปรุง
// โครงร่างเมธอดค้นหารายงาน
void searchReports(
String keyword,
java.time.LocalDate start,
java.time.LocalDate end,
int page,
int pageSize
) { /* ... */ }
#หลังปรับปรุง: Introduce Parameter Object
ใช้ DateRange จากหัวข้อก่อนหน้า ภายใต้เงื่อนไขว่านโยบายช่วงวันที่ตรงกับระบบเดิม
record Pagination(int page, int pageSize) {}
record ReportQuery(
String keyword,
DateRange period,
Pagination pagination
) {}
// โครงร่างเมธอดใหม่
void searchReports(ReportQuery query) { /* ... */ }
ตัวอย่างจุดเรียกใช้:
var query = new ReportQuery(
"testing",
new DateRange(
java.time.LocalDate.of(2026, 10, 1),
java.time.LocalDate.of(2026, 10, 31)
),
new Pagination(1, 20)
);
searchReports(query);
สำหรับตัวอย่างนี้กำหนดให้หน้าแรกเป็นหน้า 1 การย้ายระบบจริงต้องรักษาวิธีนับหน้า ค่าเริ่มต้น และความหมายของค่า null เดิมด้วย การรวมพารามิเตอร์ช่วยตั้งชื่อแนวคิด แต่ไม่ได้รับประกันความถูกต้องของค่าหากยังไม่มี validation
อย่ารวมข้อมูลที่ไม่เกี่ยวข้องทั้งหมดเป็น RequestData เพียงเพื่อลดจำนวนพารามิเตอร์ และอย่าเปลี่ยนพารามิเตอร์ไปเป็น global state เพื่อให้ signature สั้นลง
#5. Data Clumps — กลุ่มข้อมูลที่เดินทางด้วยกันซ้ำ ๆ
เมื่อข้อมูลชุดเดิมปรากฏร่วมกันในหลายคลาสหรือหลายเมธอด อาจมีแนวคิดของโดเมนที่ยังไม่ได้ตั้งชื่อ แนวทางหนึ่งคือย้ายกลุ่มข้อมูลไปเป็นคลาส แล้วพิจารณาย้ายพฤติกรรมที่ใช้ข้อมูลนั้นไปด้วย [5]
#ก่อนปรับปรุง
void createInvoice(String street, String city, String postalCode) {
/* ... */
}
void estimateShipping(String street, String city, String postalCode) {
/* ... */
}
void printShippingLabel(String street, String city, String postalCode) {
/* ... */
}
แม้แต่ละเมธอดรับเพียงสามพารามิเตอร์ ก็อาจมี Data Clumps เพราะที่อยู่ชุดเดียวกันเกิดซ้ำในหลายจุด
#หลังปรับปรุง: สร้าง Address
record Address(String street, String city, String postalCode) {}
void createInvoice(Address billingAddress) { /* ... */ }
void estimateShipping(Address shippingAddress) { /* ... */ }
void printShippingLabel(Address shippingAddress) { /* ... */ }
ชนิดข้อมูลเดียวกันใช้แทนโครงสร้างที่อยู่ได้ แต่ที่อยู่สำหรับออกใบแจ้งหนี้และที่อยู่จัดส่งอาจเป็นคนละค่า ต้องรักษาบทบาทให้ชัดเจน ตัวอย่าง Address นี้ยังไม่ได้กำหนดกฎตรวจสอบที่อยู่ของประเทศใด
จุดตรวจสอบ: ส่งที่อยู่เดิมครบทุกฟิลด์ ไม่สลับ billing กับ shipping และอย่าส่ง object ขนาดใหญ่ที่ผู้รับไม่จำเป็นต้องรู้ หากต้องการเพียงข้อมูลส่วนเล็ก ๆ
#เปรียบเทียบสามอาการที่มักพบร่วมกัน
| อาการ | ประเด็นหลัก | ตัวอย่าง |
|---|---|---|
| Primitive Obsession | ชนิดข้อมูลยังไม่สื่อความหมายหรือกฎ | ใช้ int แทน Role |
| Long Parameter List | signature มีข้อมูลเข้ามามากจนเข้าใจยาก | เมธอดค้นหารับเงื่อนไขจำนวนมาก |
| Data Clumps | ข้อมูลชุดเดิมเกิดร่วมกันหลายจุด | street, city, postalCode ปรากฏซ้ำ |
การสร้าง DateRange อาจช่วยทั้งสามเรื่องพร้อมกัน แต่ต้องตัดสินจากบริบท ไม่จำเป็นต้องสร้างคลาสใหม่ทุกครั้งที่พบตัวแปรหลายตัว
#ขั้นตอน Refactoring ที่ใช้ในเวิร์กช็อป
- เลือกโค้ดที่ต้องแก้บ่อยหรือมีผลต่อการทำงานจริง ระบุปัญหาที่ต้องการลดให้ชัดเจน
- บันทึกพฤติกรรมเดิมด้วยการทดสอบที่ครอบคลุมผลลัพธ์ ขอบเขต เงื่อนไขผิดพลาด และผลข้างเคียงที่สำคัญ
- เลือกการปรับโครงสร้างเพียงหนึ่งอย่าง เช่น Extract Method หรือ Introduce Parameter Object
- เปลี่ยนทีละส่วนและตรวจจุดเรียกใช้ รวมถึง API สาธารณะและรูปแบบข้อมูลที่จัดเก็บ
- รันการทดสอบเดิม แล้วตรวจว่าผลลัพธ์และพฤติกรรมยังเหมือนเดิม
- ทบทวนว่าชื่อและโครงสร้างใหม่ช่วยให้เข้าใจงานง่ายขึ้นจริงหรือไม่ ก่อนปรับส่วนถัดไป
การเปลี่ยนสูตรส่วนลด การเพิ่มข้อจำกัดข้อมูล หรือการเปลี่ยนผลลัพธ์ที่ API ส่งกลับ ควรแยกออกจากงานปรับโครงสร้าง เพื่อให้ตรวจสอบผลของการเปลี่ยนแปลงได้ชัดเจน
#แบบฝึกหัดท้ายบท
- เลือกเมธอดหนึ่งที่อ่านยาก แบ่งขั้นตอนตามความหมาย และเสนอชื่อเมธอดที่จะแยกออกมา
- เลือกคลาสที่แก้ไขบ่อย ระบุเหตุผลในการเปลี่ยนแปลงอย่างน้อยสองกลุ่ม แล้วเสนอขอบเขตคลาสใหม่
- ค้นหารหัสตัวเลขที่แทนบทบาทหรือสถานะ อธิบายว่า enum หรือ value object ช่วยตรวจข้อผิดพลาดใดได้
- ค้นหาเมธอดที่มีพารามิเตอร์หลายตัว ระบุว่าตัวใดสัมพันธ์กัน และตัวใดควรแยกจากกัน
- ค้นหากลุ่มข้อมูลที่เกิดร่วมกันอย่างน้อยสองแห่ง เสนอชื่อชนิดข้อมูลที่สื่อความหมาย
เกณฑ์พิจารณาผลงาน: เหตุผลในการปรับชัดเจน ชื่อสะท้อนโดเมน พฤติกรรมเดิมยังอยู่ และโครงสร้างใหม่ไม่เพิ่มความซับซ้อนโดยไม่จำเป็น
#อ่านต่อและแหล่งอ้างอิง
- Refactoring.Guru — Long Method
- Refactoring.Guru — Large Class
- Refactoring.Guru — Primitive Obsession
- Refactoring.Guru — Long Parameter List
- Refactoring.Guru — Data Clumps
อ่านเรียงจาก Long Method ไปถึง Data Clumps เพื่อเห็นความเชื่อมโยงระหว่างการแยกพฤติกรรม การแยกความรับผิดชอบ และการสร้างชนิดข้อมูลที่สะท้อนโดเมน