Command Palette

Search for a command to run...

[Java Basics] Best Practice Code Sạch trong Java cho Người Mới

javac chấp nhận một class tên shoppingCart, một method dài sáu mươi dòng, con số 0.08 viết lặp ở bốn chỗ khác nhau, và một vòng for lồng năm tầng. Tất cả đều compile được, đều chạy được, và không sinh ra lấy một warning. Việc của compiler là từ chối những chương trình không hợp lệ về mặt Java, và nó làm việc đó rất tốt. Mọi thứ nằm ngoài phạm vi đó đều là chuyện của con người.

Đó chính là ý nghĩa của "code sạch", và cũng là lý do phần lời khuyên trong bài này dễ nghe như nói suông so với phần còn lại của khoá học. Nên mỗi hướng dẫn bên dưới đều được neo vào một thứ bạn kiểm chứng được: một class có thật trong JDK theo đúng convention, một warning có thật mà javac in ra, hoặc một lần refactor chia thành nhiều bước, chạy lại chương trình sau mỗi bước và output không đổi.

Hai panel hình dạng code: bên trái thụt lề thành mũi tên sâu, bên phải phẳng, cùng một lượng code

Mọi warning, error và dòng output bên dưới đều được tạo ra bằng cách compile và chạy code trên OpenJDK 21.0.6. Không có khuyến nghị nào ở đây liên quan tới hiệu năng, và không có gì trong bài được đo thời gian.

Code sạch nghĩa là gì khi compiler không quan tâm

Đây là một class phá vỡ mọi naming convention mà Java có.

class shoppingCart {
    int TOTAL_ITEMS = 0;

    void Add_Item() {
        TOTAL_ITEMS++;
    }

    public static void main(String[] args) {
        shoppingCart c = new shoppingCart();
        c.Add_Item();
        c.Add_Item();
        System.out.println("items = " + c.TOTAL_ITEMS);
    }
}

Compile với toàn bộ warning mà compiler có, nó không nói gì cả:

javac -Xlint:all shoppingCart.java
java shoppingCart
items = 2

Không error, không warning, output đúng. Không có cơ chế máy móc nào ép bạn theo convention, nghĩa là theo nó là một quyết định bạn đưa ra có chủ ý, cho người đọc. Người đọc đó thường là chính bạn, ba tháng sau, cầm trên tay một bug report và không còn nhớ gì về đoạn code này.

Có ba thứ đáng đòi hỏi ở code, và cả ba đều hướng về người đọc đó:

Tính chấtCâu hỏi mà nó trả lời
Tên đoán đượcTôi có biết cái này chứa gì mà không cần tra không?
Mỗi quy tắc một chỗNếu tax rate đổi, tôi phải sửa bao nhiêu nơi?
Đơn vị ngắnTôi phải nhớ bao nhiêu thứ trong đầu để đọc hết một method?

Không điều nào trong bài này là quy tắc mà ngôn ngữ ép buộc. Tất cả đều là thói quen làm cho ba câu trả lời trên tốt hơn.

Naming convention trong Java, và tại sao JDK là chuẩn tham chiếu

Naming convention của Java không phải sở thích của ai đó. Đó là thứ mà thư viện chuẩn đã theo ngay từ bản phát hành đầu tiên, nên mọi lập trình viên Java đều đã quen đọc code theo cách đó — và code của bạn sẽ bị đọc theo kỳ vọng đó dù bạn có muốn hay không.

LoạiConventionVí dụ có thật trong JDK
Class, interface, enumUpperCamelCase, danh từArrayList, StringBuilder, Comparable
MethodlowerCamelCase, cụm động từparseInt, isEmpty, toUpperCase
Variable, fieldlowerCamelCase, danh từsize, elementData, modCount
ConstantUPPER_SNAKE_CASEInteger.MAX_VALUE, Math.PI
Packagetoàn chữ thường, ngăn bằng dấu chấmjava.util, java.lang, java.time

Đây không phải ví dụ bịa ra cho đẹp bảng. Integer.MAX_VALUE đúng là 2147483647, Math.PI đúng là 3.141592653589793, và StringBuilder đúng là nằm trong java.lang — suốt khoá học này bạn đã đọc những cái tên đúng hình dạng đó rồi.

Hai class giống hệt nhau đặt cạnh nhau, một bỏ qua naming convention và một theo đúng JDK, javac cho cùng một kết quả sạch với cả hai

Convention đem lại gì, khi javac vốn không quan tâm

Hình dạng của một cái tên đã nói ra loại của nó trước khi bạn kịp đọc ký tự đầu tiên. Order là một type. order là một giá trị. ORDER_LIMIT là một constant sẽ không đổi. Khi cả codebase giữ đúng lời hứa đó, bạn được bốn thứ miễn phí:

  • shoppingCart ở đầu file bắt bạn dừng lại kiểm tra xem đó là class hay variable. ShoppingCart thì không.
  • TOTAL_ITEMS mà thay đổi sau mỗi lần add là đang nói dối — kiểu viết hoa toàn bộ khẳng định "cái này cố định", còn code thì làm ngược lại.
  • Completion, search và refactoring trong IDE đều dựa trên cùng kỳ vọng đó.
  • Add_Item compile được, nhưng không lập trình viên Java nào đoán ra cái tên đó khi đi tìm. addItem mới là cái họ sẽ gõ.

Quy tắc đặt tên còn quan trọng hơn cả kiểu viết hoa là độ dài. Một cái tên nên dài đúng bằng mức cần thiết để trả lời câu hỏi. int d không nói gì cả; int daysSinceLastLogin nói hết mọi thứ, và bạn cũng chẳng phải gõ nó quá vài lần. Ngoại lệ là biến đếm trong một vòng lặp ba dòng, ở đó i thực sự rõ hơn currentIndex, vì ai cũng đã hiểu i nghĩa là gì.

Để javac tìm lỗi giúp bạn: -Xlint:all

Bản thân javac mang theo một loạt bước kiểm tra đi xa hơn nhiều so với mức ngôn ngữ yêu cầu, và bạn đã cài sẵn chúng rồi. Mặc định nó khá im lặng, nhưng -Xlint:all bật toàn bộ nhóm warning mà nó biết. Đây là một class nhỏ có bug thật bên trong:

import java.util.ArrayList;
import java.util.List;

public class Warnings {
    static int discountPercent(String tier) {
        int percent;
        switch (tier) {
            case "gold":
                percent = 20;
            case "silver":
                percent = 10;
                break;
            default:
                percent = 0;
        }
        return percent;
    }

    public static void main(String[] args) {
        List cart = new ArrayList();
        cart.add("Book");
        System.out.println("gold   -> " + discountPercent("gold"));
        System.out.println("silver -> " + discountPercent("silver"));
        System.out.println("cart   -> " + cart);
    }
}

Chạy javac Warnings.java trơn thì gần như không được gì:

Note: Warnings.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

Bật flag lên, vẫn file đó báo ra bốn vấn đề:

javac -Xlint:all Warnings.java
Warnings.java:20: warning: [rawtypes] found raw type: List
        List cart = new ArrayList();
        ^
  missing type arguments for generic class List<E>
  where E is a type-variable:
    E extends Object declared in interface List
Warnings.java:20: warning: [rawtypes] found raw type: ArrayList
        List cart = new ArrayList();
                        ^
  missing type arguments for generic class ArrayList<E>
  where E is a type-variable:
    E extends Object declared in class ArrayList
Warnings.java:21: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        cart.add("Book");
                ^
  where E is a type-variable:
    E extends Object declared in interface List
Warnings.java:10: warning: [fallthrough] possible fall-through into case
            case "silver":
            ^
4 warnings

Cái cuối cùng không phải góp ý về style. Nó chính là bug:

gold   -> 10
silver -> 10
cart   -> [Book]

case "gold" gán percent = 20, rồi rơi thẳng xuống case "silver" và ghi đè thành 10. Khách gold nhận mức giảm giá của khách silver, mãi mãi, và thứ duy nhất trong toolchain nhận ra điều đó là một warning bị tắt mặc định.

Bốn warning bắt được lỗi thật của người mới

Những nhóm bên dưới đáng biết sớm, vì mỗi nhóm ứng với một lỗi rất dễ mắc và rất khó nhìn ra. File này có đủ cả bốn:

public class Traps {
    static int counter = 0;

    public static void main(String[] args) {
        int score = 95;
        if (score > 90);
        {
            System.out.println("this always prints");
        }

        int zero = 0;
        System.out.println(10 / 0);

        Traps t = new Traps();
        t.counter = 5;

        int total = 10;
        total += 3.7;
        System.out.println(total + " " + zero);
    }
}
Traps.java:6: warning: [empty] empty statement after if
        if (score > 90);
                       ^
Traps.java:12: warning: [divzero] division by zero
        System.out.println(10 / 0);
                                ^
Traps.java:15: warning: [static] static variable should be qualified by type name, Traps, instead of by an expression
        t.counter = 5;
         ^
Traps.java:18: warning: [lossy-conversions] implicit cast from double to int in compound assignment is possibly lossy
        total += 3.7;
                 ^
4 warnings

Đọc lần lượt từng cái. Dấu chấm phẩy thừa sau if (score > 90) làm cho if không điều khiển gì cả, nên khối bên dưới chạy vô điều kiện — đây là lỗi gõ phổ biến nhất của người mới học Java, và compiler báo cho bạn hoàn toàn miễn phí. 10 / 0 là một ArithmeticException chắc chắn xảy ra lúc runtime mà compiler đã nhìn thấy từ trước. t.counter trông như một instance field nhưng không phải. Còn total += 3.7 âm thầm cắt cụt xuống 13, vì compound assignment giấu một cast mà total = total + 3.7 viết đầy đủ sẽ từ chối compile.

Có hai flag đáng đưa vào build từ sớm:

FlagTác dụng
-Xlint:allBật toàn bộ nhóm warning
-WerrorCoi mọi warning là lỗi compile

Kết hợp lại, chúng biến javac thành thứ từ chối build một file đang có vấn đề đã biết:

error: warnings found and -Werror specified
1 error
3 warnings

javac --help-lint in ra danh sách đầy đủ các nhóm, để bạn tắt đúng một nhóm có chủ đích thay vì tắt hết.

Compile sạch không chứng minh được điều gì

Một lần build không warning nào không phải giấy chứng nhận đúng. Đây là chương trình compile sạch dưới -Xlint:all và sai:

public class Silent {
    public static void main(String[] args) {
        String expected = "admin";
        String typed = new java.util.Scanner("admin").next();

        if (typed == expected) {
            System.out.println("Access granted");
        } else {
            System.out.println("Access denied");
        }
    }
}
Access denied

Hai string đó bằng nhau. == so sánh reference, nên kết quả là false, và javac -Xlint:all không in ra một chữ nào. Bên dưới có hẳn một phần riêng cho chuyện này, vì nó là bug thật phổ biến nhất trong code Java của người mới.

-Xlint:all cũng im lặng hoàn toàn về local variable không dùng, private field không dùng và private method không được gọi — javac không hề có những nhóm đó. IDE làm mờ chúng đi; compiler thì không quan tâm. Nên hãy xem warning như một cặp mắt phụ miễn phí, đừng xem nó là bằng chứng.

Magic number: đặt tên cho mọi con số có ý nghĩa

Magic number là một literal nằm giữa biểu thức mà không có gì nói nó nghĩa là gì. Đây là một hàm tính tiền có sáu cái như vậy:

public class Checkout {
    static double total(double[] prices, int[] quantities, String code, boolean member) {
        double t = 0;
        for (int i = 0; i < prices.length; i++) {
            t += prices[i] * quantities[i];
        }
        if (code != null) {
            if (code.equals("SAVE10")) {
                if (t > 100) {
                    t = t - t * 0.10;
                } else {
                    t = t - 5;
                }
            } else if (code.equals("SAVE20")) {
                if (t > 200) {
                    t = t - t * 0.20;
                }
            }
        }
        if (member) {
            t = t - t * 0.05;
        }
        t = t + t * 0.08;
        if (t < 0) {
            t = 0;
        }
        return Math.round(t * 100) / 100.0;
    }
}

Đọc dòng t = t + t * 0.08; và thử nói xem 0.08 là gì mà không cần kéo lên kéo xuống. Thuế? Phí dịch vụ? Có phải cùng một 0.08 với con số trong file in hoá đơn ở đâu đó không? Bản thân literal không trả lời được gì, và nếu tax rate thay đổi thì bạn phải đi tìm từng bản sao của nó bằng cách search một con số.

Cách sửa là dùng field private static final có tên:

private static final double SAVE10_RATE = 0.10;
private static final double SAVE20_RATE = 0.20;
private static final double SAVE10_FLAT_OFF = 5.0;
private static final double SAVE10_MIN_ORDER = 100.0;
private static final double SAVE20_MIN_ORDER = 200.0;
private static final double MEMBER_RATE = 0.05;
private static final double TAX_RATE = 0.08;

rồi viết t = t + t * TAX_RATE;. Phần còn lại trong thân method không đổi một chữ. Chạy cùng sáu trường hợp với cả hai phiên bản cho ra cùng sáu kết quả:

no code, guest   -> 98.82
SAVE10, guest    -> 93.42
SAVE10, member   -> 88.75
SAVE20, member   -> 93.88
small cart, SAVE10 -> 3.24
empty cart       -> 0.0

Đó mới là refactor chứ không phải viết lại: hành vi giống hệt đến từng byte, thứ duy nhất thay đổi là những gì người đọc nhìn thấy. Chú ý điều mà việc đặt tên vừa phơi bày ra — SAVE10_FLAT_OFF = 5.0SAVE10_RATE = 0.10 là hai kiểu giảm giá hoàn toàn khác nhau mà code gốc giấu sau hai literal trông na ná nhau.

Có hai literal không cần đặt tên: 01, khi chúng mang nghĩa "không có gì" và "một bước". for (int i = 0; i < n; i++) là bình thường. if (retries > 3) thì không.

Tách một method dài thành những phần có tên

total ở trên dài hai mươi lăm dòng và làm năm việc tách biệt: cộng giỏ hàng, áp coupon, áp giảm giá member, cộng thuế, và làm tròn. Không việc nào trong đó có tên, nên không việc nào tìm được, test được hay dùng lại được, và đọc method này đồng nghĩa với việc phải giữ cả năm thứ trong đầu cùng lúc.

Thân method 25 dòng chia thành năm vùng màu, mỗi vùng nối tới method có tên tương ứng

Tách method là đặt tên cho từng vùng. Thân của total trở thành đúng danh sách những việc nó làm:

static double total(double[] prices, int[] quantities, String code, boolean member) {
    double amount = subtotal(prices, quantities);
    amount -= couponDiscount(amount, code);
    amount -= memberDiscount(amount, member);
    amount += tax(amount);
    return roundToCents(Math.max(amount, 0));
}

private static double subtotal(double[] prices, int[] quantities) {
    double sum = 0;
    for (int i = 0; i < prices.length; i++) {
        sum += prices[i] * quantities[i];
    }
    return sum;
}

private static double couponDiscount(double amount, String code) {
    if (code == null) {
        return 0;
    }
    if (code.equals("SAVE10")) {
        return amount > SAVE10_MIN_ORDER ? amount * SAVE10_RATE : SAVE10_FLAT_OFF;
    }
    if (code.equals("SAVE20") && amount > SAVE20_MIN_ORDER) {
        return amount * SAVE20_RATE;
    }
    return 0;
}

private static double memberDiscount(double amount, boolean member) {
    return member ? amount * MEMBER_RATE : 0;
}

private static double tax(double amount) {
    return amount * TAX_RATE;
}

private static double roundToCents(double amount) {
    return Math.round(amount * 100) / 100.0;
}

Chạy lại đúng sáu trường hợp đó:

no code, guest   -> 98.82
SAVE10, guest    -> 93.42
SAVE10, member   -> 88.75
SAVE20, member   -> 93.88
small cart, SAVE10 -> 3.24
empty cart       -> 0.0

Giống hệt, lần thứ ba. Đó chính là toàn bộ kỷ luật của refactoring: đổi hình dạng theo từng bước đủ nhỏ để sau mỗi bước bạn chạy được chương trình và chứng minh không có gì xê dịch. Nếu sau một bước mà bạn không chạy được, bước đó đã quá lớn.

Cái mà việc tách method đem lại không phải là ngắn hơn. Nó là mỗi câu hỏi giờ đã có địa chỉ. "Tax rate nằm ở đâu?" là tax. "Member có được cộng dồn với coupon không?" đọc năm dòng của total là ra. Và couponDiscount giờ đủ nhỏ để gọi trực tiếp với một con số tự bịa rồi kiểm tra bằng mắt.

Refactor thường làm file dài ra

Nói thẳng về cái giá phải trả. Ba phiên bản của Checkout.java dài 29, 37 và 50 dòng. Đặt tên cho constant thêm tám dòng; tách method thêm mười ba dòng nữa. Signature, câu return và dòng trống đều không miễn phí.

Số dòng không phải thước đo. Thước đo là bạn phải đọc bao nhiêu để trả lời một câu hỏi, và con số đó đi từ hai mươi lăm dòng xuống khoảng năm. Một file dài hơn nhưng đi lại được vẫn hơn một file ngắn mà phải đọc từ đầu đến cuối.

Guard clause thay cho if lồng nhau

Phần validate viết bằng if lồng nhau sẽ trôi dần sang phải cho tới khi phần việc thật nằm sát mép màn hình, và mỗi else cách cái if của nó cả một màn hình code:

static String registerNested(String name, int age, String email) {
    if (name != null) {
        if (!name.isBlank()) {
            if (age >= 18) {
                if (email != null && email.contains("@")) {
                    return "registered: " + name;
                } else {
                    return "bad email";
                }
            } else {
                return "too young";
            }
        } else {
            return "blank name";
        }
    } else {
        return "no name";
    }
}

Guard clause đảo ngược từng điều kiện và return ngay lập tức. Các trường hợp lỗi được xử lý rồi quên đi từng cái một, và thứ còn lại ở dưới cùng chính là trường hợp bạn thực sự quan tâm:

static String registerGuarded(String name, int age, String email) {
    if (name == null) {
        return "no name";
    }
    if (name.isBlank()) {
        return "blank name";
    }
    if (age < 18) {
        return "too young";
    }
    if (email == null || !email.contains("@")) {
        return "bad email";
    }
    return "registered: " + name;
}

Sáu input chạy qua cả hai phiên bản, in ra từng kết quả và cho biết chúng có khớp nhau không:

no name  |  no name  |  same=true
blank name  |  blank name  |  same=true
too young  |  too young  |  same=true
bad email  |  bad email  |  same=true
bad email  |  bad email  |  same=true
registered: Lan  |  registered: Lan  |  same=true

Cùng hành vi, và được ba thứ rất thực tế. Mỗi lý do từ chối nằm ngay trên cùng dòng với điều kiện gây ra nó, nên bạn không bao giờ phải cuộn màn hình để ghép một else với if của nó. Nhánh thành công nằm ở một tầng thụt lề thay vì bốn. Và thêm quy tắc thứ năm chỉ là thêm một khối ở trên cùng, thay vì bọc thêm một lớp lồng quanh tất cả.

Quy tắc này áp dụng rộng hơn phần validate: xử lý trường hợp bất thường rồi thoát ra, sau đó viết trường hợp bình thường ở dạng phẳng. Nếu chỗ thụt lề sâu nhất của một method từ ba tầng trở lên, thường là đang có một guard clause hoặc một method cần tách nằm chờ ở đó.

== và equals: con bug mà compile sạch không bắt được

Bạn đã gặp Integer cache ở phần đầu khoá học, trong bài về data type. Nó quay lại ở đây vì nó là con bug sống sót qua mọi bước kiểm tra trong bài này — tên sạch, method ngắn, không warning — mà vẫn lọt lên production.

== trên bất kỳ reference type nào đều hỏi "hai cái này có phải cùng một object không?" Còn equals hỏi "hai cái này có biểu diễn cùng một giá trị không?" Với số đã boxed, hai câu trả lời tách nhau tại một điểm rất cụ thể:

Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println("Integer 127: a == b       -> " + (a == b));
System.out.println("Integer 128: c == d       -> " + (c == d));
System.out.println("Integer 128: c.equals(d)  -> " + c.equals(d));
Integer 127: a == b       -> true
Integer 128: c == d       -> false
Integer 128: c.equals(d)  -> true

Autoboxing đi qua Integer.valueOf, hàm này trả về object đã cache với các giá trị nhỏ và tạo object mới với giá trị vượt cache. Chạy dọc qua ranh giới sẽ thấy chính xác chỗ nó lật:

125 -> x == y is true
126 -> x == y is true
127 -> x == y is true
128 -> x == y is false
129 -> x == y is false
130 -> x == y is false
-128 -> true
-129 -> false

Cache là chi tiết cài đặt của JVM chứ không phải quy tắc của ngôn ngữ, và bạn chứng minh được điều đó bằng cách dời ranh giới lúc khởi động:

java -XX:AutoBoxCacheMax=200 Boundary
125 -> x == y is true
126 -> x == y is true
127 -> x == y is true
128 -> x == y is true
129 -> x == y is true
130 -> x == y is true

Cùng file class, cùng code, kết quả khác. Bất kỳ đoạn code nào mà tính đúng đắn phụ thuộc vào == với giá trị boxed thì đang phụ thuộc vào một flag của JVM.

String cũng có đúng dạng vấn đề đó, và compiler còn làm nó tệ hơn vì quá tốt bụng:

String s1 = "hello";
String s2 = "hello";
String s3 = "hel" + "lo";
String part = "hel";
String s4 = part + "lo";
literal   : s1 == s2      -> true
const fold: s1 == s3      -> true
runtime   : s1 == s4      -> false
runtime   : s1.equals(s4) -> true

s3 được gộp thành hằng "hello" ngay lúc compile và intern chung với các literal khác, nên == ra true. s4 được tạo lúc runtime nên là một object khác, == ra false. Đây chính là lý do con bug này nguy hiểm đến vậy: == trên string chạy đúng tuyệt đối với mọi literal bạn gõ vào bài test, và sai ngay lần đầu tiên string đến từ Scanner, từ file hay từ một response qua mạng.

⚠️ javac -Xlint:all không báo gì cho bất kỳ phép so sánh nào trong phần này. Không có warning nào để bật cả.

Thói quen thì đơn giản và với người mới thì không có ngoại lệ nào đáng học: dùng equals cho object, == cho primitive. == trên reference chỉ đúng trong ba trường hợp hẹp — so với null, so các hằng enum, và một phép kiểm tra "có đúng là cùng một object không" có chủ đích — và trong cả ba bạn đều biết rõ mình đang muốn gì.

Getter làm lộ state bên trong của object

Encapsulation chưa xong khi field đã là private. Một getter trả về chính field đó là đưa cho caller một tay nắm sống vào ruột object của bạn, và mọi quy tắc mà class đang giữ cũng đi theo luôn.

class LeakyTeam {
    private final List<String> members = new ArrayList<>();

    void add(String name) {
        if (members.size() >= 3) {
            throw new IllegalStateException("a team holds at most 3 people");
        }
        members.add(name);
    }

    List<String> getMembers() {
        return members;
    }
}

add bảo vệ invariant. getMembers đem nó đi cho:

LeakyTeam leaky = new LeakyTeam();
leaky.add("Lan");
leaky.getMembers().add("Minh");
leaky.getMembers().add("Huy");
leaky.getMembers().add("Nam");
leaky.getMembers().add("Trang");
leaky size after add()      -> 1
leaky size after outside add-> 5
leaky members               -> [Lan, Minh, Huy, Nam, Trang]

Năm thành viên trong một team mà cả tài liệu lẫn code đều nói là tối đa ba. Field private final không làm được gì, vì final bảo vệ reference chứ không bảo vệ object mà nó trỏ tới, còn object đó thì vừa được trao tận tay caller.

Cách sửa là trả về một bản sao:

List<String> getMembers() {
    return new ArrayList<>(members);
}
caller's list -> [Lan, Minh, Huy]
team's list   -> [Lan]
team size     -> 1

Caller muốn làm gì với bản sao của nó cũng được, team vẫn nguyên vẹn. Nếu bạn muốn caller biết ngay lập tức rằng nó không được phép ghi, Collections.unmodifiableList(members) trả về một view chỉ đọc:

safe getMembers().add()     -> java.lang.UnsupportedOperationException
safe size                   -> 1

Cùng cái bẫy đó áp dụng cho array, vì getScores() trả về int[] scores là đưa luôn cả array đi, và áp dụng cho mọi object có thể thay đổi mà bạn lưu trong field rồi trả về nguyên khối. Phép thử cần đặt ra: sau khi caller dùng getter này, object có còn giữ được đúng mọi thứ mà các method của nó hứa hẹn không?

Comment nói dối, và comment xứng đáng được viết

Comment là phần duy nhất trong file mà compiler không bao giờ kiểm tra, nên nó cũng là phần duy nhất có thể sai vĩnh viễn mà không làm hỏng thứ gì.

// Returns the price with 10% VAT added.
static double addVat(double price) {
    return price * 1.08;
}
comment says 10%, code does -> 108.0

Mức thuế đã đổi còn comment thì không. Giờ mọi người đọc đều phải tự quyết định tin cái nào, và câu trả lời trung thực là không tin cái nào cả — bạn phải đi tra lại. Một comment chỉ diễn đạt lại điều code đang làm là bản sao thứ hai của sự thật mà không ai buồn cập nhật.

Phần lớn những comment kiểu đó thực ra là một cái tên đang đòi được viết ra. Thay vì giải thích 1.08, hãy đặt tên cho nó:

private static final double VAT_RATE = 0.08;

static double priceWithVat(double price) {
    return price * (1 + VAT_RATE);
}

Phiên bản đó không thể lỗi thời được, vì lời giải thích và giá trị là cùng một token.

Comment thực sự xứng đáng thì nói tại sao, và cụ thể là ghi lại thứ mà người đọc không thể suy ra từ code:

int[] unsorted = { 9, 3, 7, 1 };
System.out.println(Arrays.binarySearch(unsorted, 9));
-5

Số 9 nằm ở index 0, vậy mà binarySearch báo "không tìm thấy", vì nó chỉ được định nghĩa trên array đã sắp xếp. Một lời gọi sort nằm ngay trên binarySearch trông rất giống thứ có thể xoá đi với người không biết điều này. Nên:

// binarySearch is undefined on an unsorted array: it can report "not found"
// for an id that is present. Sorting here is what makes the call valid.
Arrays.sort(ids);
3

Comment đó mang thông tin mà code không mang được: một ràng buộc đến từ nơi khác, và một cảnh báo về thay đổi trông có vẻ an toàn. Những comment khác đáng viết là lý do một workaround tồn tại, một đường dẫn tới yêu cầu nghiệp vụ đứng sau một quy tắc kỳ lạ, và một dòng "đúng, tôi cố ý viết như vậy" đặt lên chỗ trông như lỗi gõ. Còn lại thì nên dồn công sức vào cái tên.

Format code: chọn một kiểu rồi thôi tranh cãi

Format là phần của code sạch cho lại ít nhất trên mỗi giờ tranh luận, nên gần như mọi team đều ngừng tranh luận và giao hẳn chuyện đó cho một công cụ.

Kiểu format chuẩn của Java, kiểu mà mọi ví dụ trong khoá học này đều theo và bạn sẽ gặp trong gần như mọi codebase Java, nói ra rất ngắn:

  • thụt lề bốn dấu cách, không dùng tab
  • dấu ngoặc nhọn mở nằm cùng dòng với khai báo, ngoặc đóng nằm riêng một dòng
  • mỗi dòng một câu lệnh, và luôn có ngoặc nhọn kể cả khi thân if chỉ có một câu lệnh
  • một dòng trống giữa các method, và giữa các nhóm logic bên trong một method
  • có dấu cách quanh toán tử hai ngôi và sau dấu phẩy

Ngoặc nhọn quanh if một dòng là mục duy nhất ở trên mà người mới hay bỏ qua, và cũng là mục có hậu quả thật: về sau thêm dòng thứ hai vào một if không ngoặc sẽ đẩy dòng đó ra ngoài điều kiện, một cách âm thầm. Compiler sẽ không cảnh báo. Warning [empty] do dấu chấm phẩy thừa ở phần trên cũng thuộc đúng nhóm tai nạn này.

Mọi IDE Java phổ biến đều có chức năng format khi lưu file, và các build tool đều có plugin format có thể định dạng lại cả project hoặc làm fail build khi file sai định dạng. Bật một cái lên, commit kết quả đúng một lần, rồi không bao giờ tốn thêm một buổi review nào cho chuyện đặt dấu ngoặc. Giá trị của việc format nhất quán không nằm ở chỗ kiểu nào hay hơn; nó nằm ở chỗ khi mọi thứ được format giống nhau, một dòng trông lạ mắt trở thành tín hiệu chứ không phải nhiễu.

Những code smell nên nhận ra sớm

Code smell không phải bug. Nó là một hình dạng mà rất thường xuyên có bug nấp bên trong. Điểm hữu ích của bốn cái bên dưới là bạn nhận ra được cả bốn mà chưa cần đọc code.

Bốn panel thể hiện code smell dưới dạng hình bóng: một khối liền, một signature chạy tràn khỏi mép, một khối lặp ba lần, và các literal rải rác trong thân method

Code smellDấu hiệuCách sửa
Method quá dàiMột khối code không dòng trống, không chỗ ngắtTách từng việc thành method có tên
Parameter list quá dàiTừ năm parameter trở lên, nhiều cái cùng typeGom lại thành một object, hoặc tách method
Code copy-pasteCùng một hình dạng xuất hiện hai ba lầnTách ra một lần rồi gọi ba lần
Magic numberLiteral trần trong biểu thức, thường bị lặpprivate static final kèm một cái tên
Lồng quá sâuCode trôi sang phải thành hình mũi tênGuard clause và tách method
Comment mô tả một khối"Giờ ta tính..." đặt trên mười dòng codeKhối đó là một method, comment là tên của nó

Hai cái đắt nhất là code lặp và parameter list quá dài. Code lặp đắt vì một lần sửa phải được tìm ra và áp dụng ở mọi bản sao, và các bản sao sẽ trôi xa nhau đến mức không còn nhìn ra là cùng một đoạn code. Parameter list dài đắt vì Java khớp argument theo vị trí chứ không theo tên: trong save(name, age, email, phone, active, role), đổi chỗ emailphone vẫn compile sạch và cho ra dữ liệu sai lúc runtime.

Dòng cuối bảng là thói quen hữu ích nhất trong cả danh sách. Mỗi khi bạn sắp viết // giờ tính discount phía trên một khối code, nghĩa là bạn đã đặt tên cho method rồi — hãy viết discountFor(...) và để cái tên đó sống ở nơi người đọc sẽ tìm thấy.

FAQ

Code sạch có làm chương trình Java chạy chậm hơn không?

Đừng chọn style dựa trên hiệu năng, và cũng đừng tin một con số lấy từ bài viết nào. Không có gì trong bài này được đo thời gian, và đó là chủ ý: dễ đọc là quyết định dành cho con người, còn hình dạng code gần như không bao giờ là thứ quyết định chương trình có đủ nhanh hay không. Nếu một đoạn code cụ thể thực sự quan trọng, hãy đo chính đoạn đó bằng một benchmark tử tế thay vì suy luận từ hình dạng của nó.

Một method Java nên dài bao nhiêu?

Không có con số nào đáng bảo vệ. Có một phép thử hữu ích hơn: bạn đặt tên chính xác cho method mà không cần dùng chữ "và" được không? calculateTotalAndPrintReceiptAndSaveToFile là ba method chưa được tách ra. Nếu một method nằm vừa một màn hình và làm đúng một việc gọi tên được, độ dài của nó là ổn.

Có bắt buộc theo naming convention không, khi code vẫn chạy?

Không có gì ép cả — class shoppingCart compile và chạy được, như đã thấy ở đầu bài. Nhưng mọi lập trình viên Java và mọi công cụ đều đọc tên theo convention của JDK, nên bỏ qua nó khiến người đọc phải khựng lại ở từng identifier. Làm đúng thì không tốn gì, và đó cũng là thứ đầu tiên người phỏng vấn hay người review nhìn thấy.

Có nên bật -Xlint:all cho mọi project không?

Bật lên và đọc những gì nó nói. Nó bắt được fall-through trong switch, dấu chấm phẩy thừa sau if, phép chia cho 0 chắc chắn xảy ra, compound assignment mất dữ liệu và API đã deprecated — toàn những thứ nếu không thì phải đợi tới runtime mới lộ, hoặc không bao giờ lộ. Thêm -Werror sau khi đã dọn hết warning cũ, để warning mới không tích tụ được. Nếu một nhóm thực sự chỉ gây nhiễu cho project của bạn, hãy tắt đúng nhóm đó theo tên thay vì tắt tất cả.

Khi nào thì một comment đáng viết?

Khi nó ghi lại thứ mà người đọc không lấy được từ code: tại sao có một workaround, một lời gọi đang dựa trên ràng buộc nào, hoặc yêu cầu nghiệp vụ nào buộc phải có một quy tắc kỳ lạ. Comment chỉ nói lại điều mà dòng code đang làm là một bản sao rồi sẽ lỗi thời. Nếu bạn sắp viết comment mô tả một khối code làm gì, hãy tách khối đó thành method và dùng chính comment đó làm tên.

Có khi nào dùng == là đúng trong Java không?

Có, ở bốn chỗ: so sánh primitive như intchar, kiểm tra một reference với null, so sánh các hằng enum, và một phép kiểm tra identity có chủ đích khi bạn thực sự muốn hỏi "có đúng là cùng một object không". Mọi chỗ khác, kể cả toàn bộ string và toàn bộ boxed type, hãy dùng equals.

Refactor và viết lại khác nhau ở đâu?

Refactor đổi cấu trúc mà không đổi hành vi, theo từng bước đủ nhỏ để sau mỗi bước bạn chạy chương trình và thấy đúng output cũ — chính xác như đã làm ba lần với Checkout ở trên. Viết lại thì đổi hành vi, hoặc đổi quá nhiều thứ cùng lúc đến mức bạn không biết nó có đổi hành vi hay không. Refactor an toàn vì kiểm chứng được; viết lại thì phải test lại từ đầu.

Kết luận

Code sạch không có compiler đứng sau, nên rốt cuộc nó nằm ở một nhúm thói quen bạn áp dụng có chủ đích: đặt tên theo đúng cách JDK đặt tên, bật -Xlint:all lên và đọc nó, đặt tên cho mọi literal có ý nghĩa, giữ method đủ ngắn để gọi tên chính xác, thoát sớm thay vì lồng sâu, dùng equals cho object, không bao giờ trao state bên trong ra ngoài qua getter, và viết comment nói tại sao. Không có gì trong đó là khó. Tất cả cộng lại là khác biệt giữa đoạn code bạn sửa được vào tháng sau và đoạn code bạn phải viết lại.

Bài này cũng khép lại khoá Java Basics. Ba mươi sáu bài trước, câu hỏi mở đầu là Java là gì và tại sao nó cần JVM; từ đó khoá học đi qua JDK và vòng compile-and-run, variable, type và casting, operator, string, input, câu điều kiện và vòng lặp, array một chiều và hai chiều cùng những thuật toán duyệt chúng, method với parameter, overload, scope và đệ quy, rồi tới phần lõi hướng đối tượng — class và object, field và constructor, this, staticfinal, encapsulation, inheritance, polymorphism, abstract class và interface — rồi exception, collection và map, đọc ghi file, và cuối cùng là một project console gộp tất cả vào một chương trình chạy được.

Cụ thể là bây giờ bạn đọc được một class Java và đoán được nó làm gì, viết được một class mô hình hoá một bài toán thật, chọn được collection phù hợp cho từng việc, xử lý lỗi bằng exception thay vì cầu may, đọc ghi được file, và compile, chạy, debug kết quả từ terminal mà không cần IDE dắt tay. Đó là một nền tảng thật, và cũng gần đúng với xuất phát điểm của một Java developer junior. Nó chưa phải toàn bộ ngôn ngữ: mọi thứ bạn viết tới giờ đều nhỏ, đơn luồng và khép kín trong một chương trình.

Tiếp theo là khoá Advanced Java, mở đầu bằng việc quay lại bốn nguyên lý OOP ở độ sâu mà khoá này chưa đủ chỗ để đi, rồi tới nested class, inner class và anonymous class, enum có field và hành vi riêng, generics và lý do List<String> cần cặp ngoặc nhọn đó ngay từ đầu, các nguyên lý SOLID, và những design pattern bạn sẽ gặp trong mọi codebase Java thật. Chính những thói quen trong bài này là thứ làm cho phần kiến thức đó dễ đọc khi bạn tới nơi.

Bài viết liên quan

[Java Basics] Đọc và ghi file text trong Java

Đọc và ghi file text trong Java: FileReader và FileWriter, vì sao cần BufferedReader và BufferedWriter, try-with-resources, API hiện đại Files và Path, đường dẫn tương đối, các exception thật khi file không tồn tại, và character encoding quyết định dữ liệu tiếng Việt có sống sót hay không.

[Java Basics] Mảng nhiều chiều trong Java

Mảng nhiều chiều trong Java thực chất là array của array chứ không phải hình chữ nhật - cách tạo và duyệt grid, jagged array, deepToString, deepEquals, shallow vs deep copy, mảng 3 chiều và ví dụ matrix, mọi output đều compile và chạy thật trên JDK 21.

[Java Basics] ArrayList và LinkedList trong Java: Làm việc với Collection

Hướng dẫn thực dụng về collection cơ bản trong Java - interface Collection và List, cách ArrayList tăng capacity đọc trực tiếp từ elementData, cách LinkedList đi qua từng node, add, get, set, remove, contains, indexOf, size và isEmpty, duyệt bằng Iterator và removeIf, ConcurrentModificationException, cái bẫy remove(int) với remove(Object), và vì sao lời khuyên quen thuộc về LinkedList là sai. Đã compile và chạy trên JDK 21.

[Java Basics] Tính kế thừa trong Java: extends và super

extends hoạt động thế nào, subclass kế thừa gì và không kế thừa gì, vì sao constructor không bao giờ được kế thừa, super(...) xâu chuỗi constructor lên tới java.lang.Object rồi chạy ngược xuống ra sao, field hiding khác overriding chỗ nào, protected qua package, final class, vấn đề fragile base class, và khi nào composition là lựa chọn đúng.