Code Smell คือสัญญาณในโค้ดที่ชวนให้ตรวจสอบการออกแบบ แม้โปรแกรมอาจยังทำงานถูกต้อง แต่โครงสร้างบางส่วนอาจทำให้การอ่าน การทดสอบ และการแก้ไขในอนาคตยากขึ้น

info-code-smell-bloaters-5-refactoring-java

บทความนี้อธิบาย 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 ที่ใช้ในเวิร์กช็อป

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

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

#แบบฝึกหัดท้ายบท

  • เลือกเมธอดหนึ่งที่อ่านยาก แบ่งขั้นตอนตามความหมาย และเสนอชื่อเมธอดที่จะแยกออกมา
  • เลือกคลาสที่แก้ไขบ่อย ระบุเหตุผลในการเปลี่ยนแปลงอย่างน้อยสองกลุ่ม แล้วเสนอขอบเขตคลาสใหม่
  • ค้นหารหัสตัวเลขที่แทนบทบาทหรือสถานะ อธิบายว่า enum หรือ value object ช่วยตรวจข้อผิดพลาดใดได้
  • ค้นหาเมธอดที่มีพารามิเตอร์หลายตัว ระบุว่าตัวใดสัมพันธ์กัน และตัวใดควรแยกจากกัน
  • ค้นหากลุ่มข้อมูลที่เกิดร่วมกันอย่างน้อยสองแห่ง เสนอชื่อชนิดข้อมูลที่สื่อความหมาย

เกณฑ์พิจารณาผลงาน: เหตุผลในการปรับชัดเจน ชื่อสะท้อนโดเมน พฤติกรรมเดิมยังอยู่ และโครงสร้างใหม่ไม่เพิ่มความซับซ้อนโดยไม่จำเป็น

#อ่านต่อและแหล่งอ้างอิง

  1. Refactoring.Guru — Long Method
  2. Refactoring.Guru — Large Class
  3. Refactoring.Guru — Primitive Obsession
  4. Refactoring.Guru — Long Parameter List
  5. Refactoring.Guru — Data Clumps

อ่านเรียงจาก Long Method ไปถึง Data Clumps เพื่อเห็นความเชื่อมโยงระหว่างการแยกพฤติกรรม การแยกความรับผิดชอบ และการสร้างชนิดข้อมูลที่สะท้อนโดเมน