Clean Code คือแนวทางเขียนโค้ดให้ผู้อ่านเข้าใจเจตนาได้ชัดเจน เห็นขอบเขตความรับผิดชอบ และปรับเปลี่ยนได้โดยตรวจสอบผลกระทบได้ เป้าหมายคือช่วยให้ทีมดูแลซอฟต์แวร์ต่อได้ตลอดอายุระบบ

info-clean-code

#1. ตั้งชื่อให้สื่อเจตนา

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

// ต้องเดาว่าแต่ละค่าหมายถึงอะไร
int d = 7;
int t = 3000;

// บอกความหมายและหน่วย
int reservationDurationDays = 7;
int requestTimeoutMillis = 3000;

ชื่อเมธอดควรสะท้อนพฤติกรรม เช่น calculateDiscount() สำหรับคำนวณส่วนลด และ saveOrder() สำหรับบันทึกคำสั่งซื้อ หากเมธอดบันทึกข้อมูลจริง การตั้งชื่อเพียง getOrder() จะทำให้ผู้อ่านคาดการณ์ผลข้างเคียงผิด

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

#2. ให้ฟังก์ชันมีเป้าหมายชัดเจน

ฟังก์ชันควรอธิบายได้ว่าให้ผลลัพธ์หรือจัดการเรื่องใด หากต้องอธิบายด้วยรายการงานหลายประเภท อาจถึงเวลาแยกความรับผิดชอบ

ตัวอย่างเมธอด processOrder() ที่ตรวจสอบคำสั่งซื้อ คำนวณราคา บันทึกฐานข้อมูล สร้าง PDF และส่งอีเมลทั้งหมด อาจแยกงานเหล่านี้ออกเป็นส่วนที่มีชื่อชัดเจน แล้วให้เมธอดระดับบนประสานงาน

// ตัวอย่างโครงสร้าง: สมมติว่ามี collaborator เหล่านี้อยู่แล้ว
public void placeOrder(Order order) {
    orderValidator.validate(order);
    orderRepository.save(order);
    confirmationService.send(order);
}

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

ไม่มีกฎตายตัวว่าฟังก์ชันต้องไม่เกิน 5, 10 หรือ 20 บรรทัด พิจารณาความเข้าใจง่าย จำนวนแนวคิด และผลกระทบของการแก้ไขร่วมกัน การแยกจนต้องเปิดอ่านเมธอดย่อยจำนวนมากก็เพิ่มภาระให้ผู้อ่านได้

#3. จัดคลาสและโมดูลตามเหตุผลที่เปลี่ยนแปลง

Single Responsibility Principle (SRP) ช่วยให้แยกส่วนที่เปลี่ยนด้วยเหตุผลต่างกันออกจากกัน Robert C. Martin อธิบายแนวคิดนี้ผ่านแหล่งที่มาของข้อกำหนดหรือผู้ที่ทำให้ระบบต้องเปลี่ยน [1]

ตัวอย่างระบบใบแจ้งหนี้:

ส่วนของระบบ เรื่องที่รับผิดชอบ ตัวอย่างเหตุผลที่เปลี่ยน
InvoiceCalculator คำนวณยอดใบแจ้งหนี้ เปลี่ยนกติกาการคำนวณ
InvoiceRepository อ่านและบันทึกข้อมูล เปลี่ยนโครงสร้างฐานข้อมูล
InvoicePdfRenderer จัดรูปแบบเอกสาร เปลี่ยนหน้าตาใบแจ้งหนี้

SRP ไม่ได้หมายความว่าคลาสต้องมีเพียงเมธอดเดียว คลาสที่มีหลายเมธอดซึ่งดูแลแนวคิดเดียวกันอาจมีความรับผิดชอบชัดเจนอยู่แล้ว

#4. ลดความซับซ้อนของเงื่อนไข

ใช้ guard clause เพื่อจัดการกรณีที่ไม่เข้าเงื่อนไขหรือข้อมูลผิดสัญญาให้ชัดเจนตั้งแต่ต้น และตั้งชื่อให้เงื่อนไขที่ซับซ้อนเมื่อช่วยอธิบายกฎธุรกิจได้

// สมมติว่า order ไม่เป็น null
public boolean canShip(Order order) {
    if (!order.isPaid()) {
        return false;
    }
    if (!order.isInStock()) {
        return false;
    }
    return true;
}

สำหรับเงื่อนไขสั้น โค้ดนี้อาจอ่านง่ายกว่า:

public boolean canShip(Order order) {
    return order.isPaid() && order.isInStock();
}

ทั้งสองรูปแบบใช้ได้ ให้เลือกรูปแบบที่ทีมอ่านและตรวจสอบได้ง่าย ส่วน guard clause มีประโยชน์มากขึ้นเมื่อแต่ละกรณีมีรายละเอียดต่างกัน ไม่จำเป็นต้องเปลี่ยนทุก if เป็น early return

#5. ลดความซ้ำของกฎที่ควรเปลี่ยนพร้อมกัน

DRY หรือ Don't Repeat Yourself ช่วยให้กฎเดียวกันไม่กระจายหลายตำแหน่ง เช่น เกณฑ์สิทธิ์ส่วนลดที่ใช้ร่วมกันจริง ควรมีแหล่งอ้างอิงชัดเจน

แต่โค้ดที่หน้าตาเหมือนกันอาจเป็นคนละกฎ เช่น ส่วนลดสมาชิกกับค่าชดเชยสินค้า ซึ่งวันนี้ใช้อัตรา 10% เหมือนกัน แต่มีเจ้าของนโยบายและแนวโน้มเปลี่ยนต่างกัน การบังคับให้ใช้ฟังก์ชันเดียวกันอาจทำให้การแก้ไขเรื่องหนึ่งกระทบอีกเรื่อง

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

#6. ใช้โครงสร้างที่เรียบง่ายและพอดีกับปัญหา

KISS หรือ Keep It Simple สนับสนุนให้เลือกวิธีที่ทีมเข้าใจและดูแลได้ ส่วน YAGNI หรือ You Aren't Gonna Need It เตือนให้ระวังการสร้างความสามารถสำหรับอนาคตที่ยังไม่มีความจำเป็น [2]

ตัวอย่าง หากมีการส่งออกไฟล์ CSV เพียงรูปแบบเดียว อาจเริ่มจากเมธอดหรือคลาสธรรมดา เมื่อมีรูปแบบอื่นที่ต้องรองรับจริงจึงพิจารณา interface หรือ Strategy Pattern

ความเรียบง่ายยังต้องรองรับข้อกำหนดด้านความถูกต้อง ความปลอดภัย และประสิทธิภาพที่มีอยู่แล้ว หากข้อกำหนดกำหนดให้รองรับหลายผู้ให้บริการในปัจจุบัน การออกแบบขอบเขตผ่าน interface ก็อาจเป็นทางเลือกที่เรียบง่ายสำหรับสถานการณ์นั้น

#7. ใช้ชนิดข้อมูลและค่าคงที่ให้ตรงความหมาย

หลีกเลี่ยงตัวเลขหรือข้อความที่ผู้อ่านต้องเดา เช่น 1000 หรือ "P" โดยไม่มีบริบท

private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3);

ใช้ enum สำหรับชุดสถานะที่มีขอบเขตชัดเจน ใช้ชนิดข้อมูลสำหรับวันที่ เวลา หรือจำนวนเงินให้เหมาะกับโดเมน และพิจารณา value object เมื่อข้อมูลนั้นมีข้อกำหนดหรือพฤติกรรมเฉพาะ

อย่ารวมค่าที่เท่ากันโดยบังเอิญเป็นค่าคงที่เดียวกัน เช่น จำนวนครั้งล็อกอินสูงสุดกับจำนวนสินค้าที่แสดง แม้ทั้งคู่มีค่า 5 ก็เป็นคนละแนวคิด

ค่าที่เปลี่ยนตามสภาพแวดล้อมควรอยู่ใน configuration ตามความเหมาะสม ส่วนข้อมูลลับควรใช้กลไกจัดการ secret ของระบบ

#8. เขียนคอมเมนต์อธิบายเหตุผลและข้อจำกัด

คอมเมนต์มีประโยชน์เมื่อบอกสิ่งที่อ่านจากโค้ดตรง ๆ ไม่ได้ เช่น เหตุผลทางธุรกิจ ข้อจำกัดของระบบภายนอก หรือเงื่อนไขสำคัญของอัลกอริทึม

// ต้องเก็บลำดับเดิม เพราะระบบปลายทางใช้ลำดับรายการในการจับคู่ผลลัพธ์
List<Request> requests = new ArrayList<>(incomingRequests);

คอมเมนต์ที่เพียงบรรยายคำสั่งซ้ำอาจไม่ช่วย:

// เพิ่มค่า count ขึ้นหนึ่ง
count++;

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

#9. จัดการข้อผิดพลาดและผลข้างเคียงให้ชัดเจน

ระบุสัญญาของเมธอดว่าอะไรเป็น input ที่ยอมรับได้ ผลลัพธ์หมายถึงอะไร และกรณีผิดพลาดถูกส่งต่ออย่างไร

หลีกเลี่ยงการกลืน exception แล้วคืนค่าที่ดูเหมือนสำเร็จ เช่น อ่านฐานข้อมูลไม่สำเร็จแต่คืนลิสต์ว่างจนผู้เรียกเข้าใจว่าไม่มีข้อมูล

// ตัวอย่างบางส่วน: ใช้ภายในชั้นที่อ่านไฟล์
try {
    return Files.readString(path);
} catch (IOException exception) {
    throw new UncheckedIOException("Cannot read configuration file", exception);
}

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

อีกประเด็นคือผลข้างเคียง: เมธอดที่คำนวณราคาไม่ควรส่งอีเมลหรือแก้ไขฐานข้อมูลอย่างซ่อนเร้น การแยกการคำนวณออกจาก I/O ช่วยให้ทดสอบและไล่ปัญหาได้ง่ายขึ้น

#10. ทดสอบพฤติกรรมและ Refactor ทีละส่วน

Refactoring คือการปรับโครงสร้างภายในเพื่อให้เข้าใจและแก้ไขง่ายขึ้น โดยรักษาพฤติกรรมที่สังเกตได้ตามขอบเขตของระบบ [3]

แนวทางทำงาน:

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

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

#ตัวอย่าง Java: ปรับโค้ดคำนวณส่วนลด

#ข้อกำหนดของตัวอย่าง

  • รับยอดซื้อเป็นบาทด้วย BigDecimal
  • ยอดซื้อห้ามเป็น null และห้ามติดลบ
  • สมาชิกที่ซื้ออย่างน้อย 1,000 บาท ได้ส่วนลด 10%
  • กรณีอื่นได้ส่วนลด 0 บาท
  • คืนค่า “จำนวนเงินส่วนลด” สองตำแหน่งทศนิยม โดยใช้ HALF_UP
  • นโยบายการปัดเศษเป็นข้อกำหนดสมมติของตัวอย่าง ต้องตกลงตามระบบจริง

#ก่อนปรับปรุง

คลาสนี้ทำงานตามข้อกำหนด แต่ชื่อและโครงสร้างยังทำให้ต้องอ่านรายละเอียดเพื่อเข้าใจ

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class DiscountCalculator {
    public BigDecimal calculateDiscount(BigDecimal total, boolean member) {
        Objects.requireNonNull(total, "total must not be null");
        if (total.signum() < 0) {
            throw new IllegalArgumentException("total must not be negative");
        }

        BigDecimal d = BigDecimal.ZERO;
        if (member) {
            if (total.compareTo(new BigDecimal("1000.00")) >= 0) {
                d = total.multiply(new BigDecimal("0.10"));
            }
        }
        return d.setScale(2, RoundingMode.HALF_UP);
    }
}

#หลังปรับปรุง

เก็บเมธอดสาธารณะและพฤติกรรมเดิมไว้ ตั้งชื่อกฎให้ชัด และแยกการตรวจสอบ input ออกจากการคำนวณ

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class DiscountCalculator {
    private static final BigDecimal MINIMUM_MEMBER_TOTAL =
            new BigDecimal("1000.00");
    private static final BigDecimal MEMBER_DISCOUNT_RATE =
            new BigDecimal("0.10");
    private static final BigDecimal NO_DISCOUNT = new BigDecimal("0.00");

    public BigDecimal calculateDiscount(BigDecimal total, boolean member) {
        validateTotal(total);

        if (!member || total.compareTo(MINIMUM_MEMBER_TOTAL) < 0) {
            return NO_DISCOUNT;
        }

        return total.multiply(MEMBER_DISCOUNT_RATE)
                .setScale(2, RoundingMode.HALF_UP);
    }

    private void validateTotal(BigDecimal total) {
        Objects.requireNonNull(total, "total must not be null");
        if (total.signum() < 0) {
            throw new IllegalArgumentException("total must not be negative");
        }
    }
}

ตัวอย่างทั้งสองเป็นคนละเวอร์ชันของ DiscountCalculator.java ให้เลือกใช้ทีละเวอร์ชัน ใช้ BigDecimal จากสตริงเพื่อแทนค่าทศนิยมตามที่เขียนโดยตรง และใช้ compareTo() สำหรับเปรียบเทียบค่าตัวเลข

พารามิเตอร์ boolean เดียวในตัวอย่างยังมีความหมายที่จัดการได้ หากมี flag หลายตัวหรือรูปแบบการคำนวณหลายประเภท จึงค่อยพิจารณาชนิดข้อมูลที่อธิบายบริบทได้ชัดกว่า

#กรณีที่ควรใช้ตรวจสอบก่อนและหลัง

ยอดซื้อ สมาชิก ผลลัพธ์ส่วนลด
0.00 ใช่ 0.00
999.99 ใช่ 0.00
1000.00 ใช่ 100.00
1500.00 ใช่ 150.00
1000.05 ใช่ 100.01
1500.00 ไม่ใช่ 0.00
-1.00 ใช่หรือไม่ใช่ IllegalArgumentException
null ใช่หรือไม่ใช่ NullPointerException

ตารางนี้เป็นเกณฑ์ทดสอบที่เสนอสำหรับตัวอย่าง บทความไม่ได้แนบผลการรันทดสอบ Java

ในระบบจริงอาจต้องเพิ่มกฎเรื่องสกุลเงิน จำนวนตำแหน่งทศนิยมที่รับได้ การปัดเศษรายบรรทัดเทียบกับทั้งเอกสาร และข้อจำกัดของยอดเงินตามธุรกิจ

#Clean Code, SOLID และ Refactoring เกี่ยวข้องกันอย่างไร

แนวคิด จุดเน้น ตัวอย่าง
Clean Code ความชัดเจนของโค้ดและความสะดวกในการดูแล ตั้งชื่อสื่อเจตนา ลดเงื่อนไขซ้อน
SOLID หลักพิจารณาการออกแบบโมดูลและความสัมพันธ์ แยกกฎคำนวณออกจาก persistence ตามเหตุผลที่เปลี่ยน
Refactoring กระบวนการปรับโครงสร้างโดยรักษาพฤติกรรม Extract Function, Rename, Move Function
Coding Standard ข้อตกลงร่วมของทีมที่ตรวจสอบได้ รูปแบบชื่อ การจัดย่อหน้าและ import

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

#ข้อผิดพลาดที่พบบ่อยเมื่อเริ่มใช้ Clean Code

การใช้แบบตายตัว สิ่งที่ควรพิจารณา
บังคับให้ทุกฟังก์ชันสั้นเท่ากัน หนึ่งแนวคิดที่ชัดเจนอาจยาวได้ตามบริบท
สร้าง interface ให้ทุกคลาส เพิ่มเมื่อมีขอบเขตหรือความแปรผันที่ต้องจัดการจริง
รวมทุกส่วนที่หน้าตาคล้ายกัน ตรวจว่าเป็นกฎเดียวกันและควรเปลี่ยนพร้อมกันหรือไม่
ลบคอมเมนต์ทั้งหมด เก็บเหตุผล ข้อจำกัด และสัญญาที่โค้ดสื่อไม่ได้
ใช้ one-liner เพื่อให้บรรทัดน้อย เลือกรูปแบบที่อ่านและตรวจสอบผลได้ง่าย
เปลี่ยนทั้งระบบในครั้งเดียว เริ่มจากส่วนที่ต้องแก้ไขบ่อยและมีการทดสอบรองรับ
เชื่อคะแนน static analysis เพียงค่าเดียว ใช้ร่วมกับการอ่านโค้ด การทดสอบ และบริบทจริง

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

#เช็กลิสต์สำหรับ Code Review

  • [ ] ชื่อตัวแปรและเมธอดบอกความหมายตรงกับพฤติกรรมจริง
  • [ ] หน่วยและชนิดข้อมูลชัดเจนในจุดที่มีผลต่อความถูกต้อง
  • [ ] ฟังก์ชันมีเป้าหมายที่อธิบายได้ และระดับรายละเอียดสอดคล้องกัน
  • [ ] โครงสร้างเงื่อนไขอ่านตามได้โดยไม่ต้องจำบริบทมากเกินไป
  • [ ] กฎที่ใช้ร่วมกันจริงมีแหล่งอ้างอิงที่เหมาะสม
  • [ ] คลาสและโมดูลแยกตามเหตุผลที่เปลี่ยนแปลง
  • [ ] ไม่มี abstraction หรือความสามารถที่ยังไม่จำเป็น
  • [ ] คอมเมนต์อธิบายเหตุผลและยังตรงกับโค้ด
  • [ ] ข้อผิดพลาดและผลข้างเคียงถูกจัดการตามสัญญาที่ชัดเจน
  • [ ] การทดสอบครอบคลุมพฤติกรรมสำคัญและกรณีขอบ
  • [ ] การปรับโครงสร้างยังคงพฤติกรรมตามขอบเขตที่ตกลงไว้
  • [ ] รูปแบบโค้ดสอดคล้องกับข้อตกลงของทีม

ทีมสามารถใช้ style guide เป็นฐานสำหรับข้อตกลงเรื่องชื่อและรูปแบบ เช่น Google Java Style Guide [4] โดยปรับให้เหมาะกับโครงการ ตัวอย่างในบทความใช้รูปแบบเพื่อการสอนและไม่ได้อ้างว่าผ่านกฎ Google Style ทุกข้อ

#แบบฝึกหัดสำหรับนักศึกษา

เลือกเมธอดหนึ่งจากโปรเจกต์ที่มีเงื่อนไขซ้อนหรือชื่อที่ยังคลุมเครือ แล้วทำงานต่อไปนี้:

  1. เขียนอธิบาย input, output, ผลข้างเคียง และกรณีผิดพลาด
  2. ระบุจุดที่ทำให้เข้าใจยากอย่างน้อยสามจุด พร้อมเหตุผล
  3. เตรียมการทดสอบพฤติกรรมสำคัญและกรณีขอบ
  4. ปรับชื่อและโครงสร้างทีละส่วนโดยรันทดสอบระหว่างทาง
  5. ให้เพื่อนอ่านโค้ดทั้งสองเวอร์ชันและอธิบายกฎที่เข้าใจ
  6. ส่ง diff พร้อมอธิบายว่าความชัดเจนดีขึ้นตรงไหน และมี trade-off อะไร

เกณฑ์ประเมินควรดูความถูกต้อง ความชัดเจน และเหตุผลในการออกแบบร่วมกัน แทนการให้คะแนนจากจำนวนบรรทัดที่ลดลงเพียงอย่างเดียว

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

  1. Robert C. Martin — The Single Responsibility Principle: การจัดกลุ่มส่วนที่เปลี่ยนด้วยเหตุผลเดียวกัน และแยกส่วนที่เปลี่ยนด้วยเหตุผลต่างกัน
  2. Martin Fowler — Yagni: การพิจารณาต้นทุนของความสามารถที่สร้างล่วงหน้าก่อนจำเป็น
  3. Martin Fowler — Definition of Refactoring: ความหมายของ refactoring และการรักษาพฤติกรรมที่สังเกตได้
  4. Google — Google Java Style Guide: ตัวอย่างข้อตกลงเรื่องการจัดรูปแบบและการตั้งชื่อ Java