Phần này lẽ ra là phần nhàm chán nhất của mọi khoá học Java. if, else, for, while — ai chẳng biết, ngôn ngữ nào chẳng có.

Nhưng Java mấy năm gần đây đã đổi khá nhiều ở đúng chỗ này, và tôi vẫn thấy mã mới viết năm 2026 dùng switch theo lối năm 2005. Nên hôm nay ta xem cái cũ hỏng ở đâu và cái mới thay thế thế nào.

switch kiểu cũ và cái bẫy rơi xuống

Đây là switch mà ai học Java cũng gặp đầu tiên:

switch (ngay) {
    case 6: System.out.print("thứ Bảy ");
    case 7: System.out.println("+ Chủ nhật"); break;
    default: System.out.println("ngày thường");
}

Tôi cố tình quên breakcase 6. Kết quả khi chạy với ngay = 6 rồi ngay = 7:

  thứ Bảy + Chủ nhật (rơi xuống!)
+ Chủ nhật (rơi xuống!)

Với ngay = 6, chương trình chạy case 6 xong không dừng lại mà chảy tiếp vào case 7. Đó là hành vi "rơi xuống" — fall-through — Java thừa hưởng từ C.

Đôi khi người ta cố ý dùng nó để gộp nhiều nhánh. Nhưng phần lớn thời gian nó là lỗi, và là loại lỗi trình biên dịch không bắt được. Tôi từng mất nửa buổi chiều vì một break bị xoá nhầm khi giải quyết xung đột merge.

switch biểu thức: gọn hơn và không rơi xuống

Từ Java 14, switch có dạng biểu thức, dùng mũi tên:

String ten = switch (ngay) {
    case 6, 7 -> "cuối tuần";
    case 1, 2, 3, 4, 5 -> "ngày làm việc";
    default -> throw new IllegalArgumentException("ngày không hợp lệ: " + ngay);
};

Bốn cải tiến gói trong vài dòng đó.

Không rơi xuống. Mỗi nhánh mũi tên độc lập, không cần break, không thể quên.

Gộp nhánh bằng dấu phẩy thay vì xếp chồng nhiều case rỗng.

Nó là biểu thức, tức có giá trị, gán thẳng vào biến được. Không còn cảnh khai biến rỗng ở trên rồi gán trong từng nhánh.

Trình biên dịch bắt buộc vét cạn. Thiếu nhánh mà không có default là lỗi biên dịch ngay, chứ không phải âm thầm trả về null lúc chạy.

Cần nhiều dòng trong một nhánh thì dùng yield:

int soNgay = switch (thang) {
    case 2 -> {
        boolean namNhuan = (nam % 4 == 0 && nam % 100 != 0) || nam % 400 == 0;
        yield namNhuan ? 29 : 28;
    }
    case 4, 6, 9, 11 -> 30;
    default -> 31;
};

Pattern matching trong switch

Đây mới là thứ đáng nói nhất, chính thức từ Java 21. switch giờ rẽ nhánh theo kiểu, không chỉ theo giá trị:

static String moTa(Object o) {
    return switch (o) {
        case Integer i when i < 0 -> "số nguyên âm " + i;
        case Integer i            -> "số nguyên " + i;
        case String s             -> "chuỗi dài " + s.length();
        case null                 -> "không có gì";
        default                   -> "kiểu khác: " + o.getClass().getSimpleName();
    };
}

Chạy thử với năm giá trị khác nhau:

  số nguyên 5
  số nguyên âm -3
  chuỗi dài 8
  kiểu khác: Double
  không có gì

Ba điều đáng chú ý.

Biến is được khai và ép kiểu cùng lúc với việc so khớp. Không còn instanceof rồi ép kiểu thủ công ở dòng sau.

Từ khoá when thêm điều kiện phụ vào nhánh. Nhánh Integer i when i < 0 phải đặt trước nhánh Integer i chung chung — thứ tự có ý nghĩa, và nếu bạn đặt ngược thì trình biên dịch báo nhánh sau không bao giờ tới được.

case null xử lý null ngay trong switch. Trước Java 21, switch gặp null là ném NPE, nên lúc nào cũng phải kiểm tra null ở ngoài.

Ghép với sealed thì càng mạnh:

sealed interface Hinh permits Tron, Vuong {}
record Tron(double r) implements Hinh {}
record Vuong(double c) implements Hinh {}

static double dienTich(Hinh h) {
    return switch (h) {
        case Tron t  -> Math.PI * t.r() * t.r();
        case Vuong v -> v.c() * v.c();
    };
}
  diện tích tròn r=2: 12.57

Không có default, và trình biên dịch vẫn chấp nhận — vì sealed đã tuyên bố chỉ có đúng hai lớp cài đặt, nên hai nhánh là vét cạn. Cái hay thật sự nằm ở chỗ này: hôm nào bạn thêm record TamGiac, mọi switch thiếu nhánh sẽ đỏ lên ngay lúc biên dịch thay vì im lặng rơi vào default rồi hỏng lúc chạy. Ta sẽ quay lại sealedrecord ở phần hướng đối tượng.

Bốn kiểu vòng lặp, dùng khi nào

for cổ điển — khi bạn cần chỉ số, hoặc cần bước nhảy khác 1:

for (int i = 0; i < ds.size(); i++) { ... }

for-each — khi chỉ cần từng phần tử. Ngắn hơn, và không thể sai chỉ số:

for (String ten : danhSach) { ... }

Nó chạy được trên mảng và trên mọi thứ cài Iterable. Nhược điểm duy nhất: không biết mình đang ở phần tử thứ mấy, và không xoá phần tử được — xoá trong lúc duyệt sẽ ném ConcurrentModificationException, chuyện của phần nói về Collections.

while — khi không biết trước số vòng:

while ((dong = reader.readLine()) != null) { ... }

do-while — khi cần chạy ít nhất một lần. Thật lòng thì mười năm viết Java tôi dùng nó chưa tới mười lần; gần như luôn có cách viết rõ hơn bằng while.

break có nhãn: thứ ít người biết mà lúc cần thì rất cần

Thoát khỏi vòng lặp lồng nhau là chuyện phiền. Cách thường thấy là thêm một biến cờ, hoặc tách hàm riêng để return. Java có sẵn công cụ cho việc này:

ngoai:
for (int i = 0; i < bang.length; i++) {
    for (int j = 0; j < bang[i].length; j++) {
        if (bang[i][j] == 5) {
            break ngoai;      // thoát cả hai vòng
        }
    }
}
  tìm thấy 5 tại [1][1], thoát cả hai vòng

continue cũng nhận nhãn, khi đó nó nhảy sang vòng kế tiếp của vòng lặp ngoài.

Tôi dùng nhãn rất tiết kiệm — hai vòng lồng thì được, ba vòng trở lên thì đó là dấu hiệu nên tách hàm. Nhưng biết nó tồn tại vẫn hơn là bịa ra một biến boolean daTimThay rải khắp nơi.

Java không có goto. Từ khoá này nằm trong danh sách từ dành riêng nhưng không dùng được — người thiết kế giữ chỗ để không ai đặt tên biến là goto. Nhãn của break chỉ gắn với vòng lặp, không nhảy tuỳ tiện được như goto trong C.

Vài thói quen nhỏ đáng giữ

Luôn đặt dấu ngoặc nhọn, kể cả thân chỉ một dòng. Đây không phải chuyện thẩm mỹ — lỗi "goto fail" nổi tiếng của Apple năm 2014, làm hỏng việc kiểm tra chứng chỉ SSL trên toàn bộ iOS và macOS, có nguyên nhân trực tiếp là một câu if không ngoặc với hai dòng thân.

Điều kiện phức tạp thì đặt tên. So sánh hai cách viết:

if (nguoiDung.getTuoi() >= 18 && nguoiDung.isDaXacThuc() && !nguoiDung.isBiKhoa()) { ... }

boolean duocPhepDatHang = nguoiDung.getTuoi() >= 18
        && nguoiDung.isDaXacThuc()
        && !nguoiDung.isBiKhoa();
if (duocPhepDatHang) { ... }

Cách thứ hai dài hơn một dòng nhưng nói ra được ý định, và khi có lỗi thì bạn biết ngay phải đọc chỗ nào.

Trả về sớm thay vì lồng sâu. Mỗi tầng if lồng thêm là một tầng người đọc phải giữ trong đầu. Kiểm tra điều kiện loại trừ trước rồi return, phần thân chính sẽ nằm ở mức thụt lề ngoài cùng.

Thử một chút

Mở jshell và gõ đoạn này:

Object x = 42;
String kq = switch (x) {
    case Integer i when i > 100 -> "lớn";
    case Integer i -> "nhỏ: " + i;
    default -> "không phải số";
};

Rồi đổi x thành "abc", thành null, thành 3.14. Ba mươi giây, và bạn sẽ nhớ cú pháp này lâu hơn đọc mười trang tài liệu.

Ngày mai ta nói về String — kiểu dữ liệu bạn dùng nhiều nhất, và cũng là kiểu có nhiều hiểu lầm nhất. Bắt đầu bằng câu hỏi: vì sao new String("a") == "a" cho ra false trong khi "a" == "a" cho ra true.