Chuyển tới nội dung chính

Cú pháp hiện đại

Java thay đổi rất nhanh kể từ phiên bản 8: nhiều mẫu mã từng phải viết dài dòng nay đã có cú pháp gọn và an toàn hơn. Bài này điểm qua năm tính năng cú pháp tiêu biểu của Java hiện đại: suy luận kiểu cục bộ var, kiểu record, lớp niêm phong (sealed class), biểu thức switch và khối văn bản (text block). Hiểu các tính năng này giúp bạn viết ít hơn mà rõ hơn, đồng thời để máy biên dịch bắt lỗi sớm thay cho việc gỡ lỗi lúc chạy.

Có một sợi chỉ đỏ nối các tính năng ấy: chúng đều giảm mã lặp mà không giảm an toàn. Quên cập nhật equals khi thêm một trường, hay quên break trong switch cổ điển, đều là những lỗi im lặng và khó tìm. Java hiện đại chặn chúng ở mức ngôn ngữ, nên bạn viết ít đi mà phần viết ra lại khó sai hơn.

Lật nhanh năm thẻ dưới đây để có bản đồ tổng quan cùng mốc phiên bản của từng tính năng, rồi ta đi vào từng cái một.

Năm tính năng cú pháp hiện đại

Suy luận kiểu cục bộ với var

Từ Java 10, ta có thể khai báo biến cục bộ bằng var và để trình biên dịch tự suy ra kiểu từ biểu thức khởi tạo. Đây là suy luận kiểu cục bộ (local variable type inference), hoàn toàn ở mức thời điểm biên dịch: biến vẫn có kiểu tĩnh xác định, chỉ là ta khỏi viết lại tên kiểu cho gọn. var chỉ dùng cho biến cục bộ trong phương thức, không dùng cho trường, tham số phương thức hay kiểu trả về.

// without var: the type is written twice
Map<String, List<Integer>> diem = new HashMap<String, List<Integer>>();

// with var: the compiler infers Map<String, List<Integer>>
var diem2 = new HashMap<String, List<Integer>>(); // clear from the right side
var ten = "Nguyen Van A"; // inferred as String
for (var e : diem2.entrySet()) { // e is a Map.Entry
// ...
}
ghi chú

var không có nghĩa là kiểu động. Sau khi suy luận, kiểu của biến là cố định. Tránh lạm dụng khi biểu thức bên phải không nói rõ kiểu, ví dụ var x = doSomething();: người đọc khó đoán x là gì. Quy tắc đơn giản là dùng var khi kiểu hiển nhiên từ vế phải.

Khi nào không nên dùng var

Có chỗ var không hợp lệ, và có chỗ nó hợp lệ nhưng làm mã tệ đi. Trường hợp sau nguy hiểm hơn vì trình biên dịch không cản.

import java.util.ArrayList;

public class VarSaiCach {
public static void chay() {
// var a; // error: no initializer, nothing to infer from
// var b = null; // error: null alone carries no type
// var f = () -> 1; // error: a lambda needs a target type

var ds = new ArrayList<>(); // inferred as ArrayList<Object>
ds.add("mot");
ds.add(2); // no type safety left at all

var dsDung = new ArrayList<String>(); // inferred as ArrayList<String>
// dsDung.add(2); // compile error, as we want
}
}

Dòng new ArrayList<>() là cái bẫy thật: ngoặc kim cương rỗng không có gì để suy, trình biên dịch chọn Object và ta mất sạch an toàn kiểu mà không hề có cảnh báo. Bẫy thứ hai mang tính thiết kế: List<String> ds = new ArrayList<>(); khoá kiểu tĩnh vào giao tiếp List nên sau này đổi sang LinkedList chỉ sửa một chỗ, còn var ds = new ArrayList<String>(); khoá vào chính lớp cài đặt. Vậy nên khi muốn lập trình theo giao tiếp, hãy viết kiểu ra.

Kiểu record: lớp mang dữ liệu

Rất nhiều lớp trong thực tế chỉ để mang một bó dữ liệu bất biến: một điểm tọa độ, một dòng kết quả truy vấn, một thông điệp. Viết tay chúng theo lối truyền thống tốn nhiều mã lặp: trường private final, hàm dựng, các phương thức truy cập, rồi equals, hashCode, toString. Từ Java 16, bản ghi (record) sinh tự động toàn bộ phần đó.

Một record là một kiểu tham chiếu đặc biệt khai báo bằng từ khoá record, mô tả một bó dữ liệu bất biến. Ta khai báo các thành phần (components) trong dấu ngoặc; trình biên dịch tự sinh trường final, hàm dựng chính tắc, phương thức truy cập, equals, hashCodetoString.

// traditional immutable carrier: about 25 lines of boilerplate
public final class DiemCu {
private final int x;
private final int y;

public DiemCu(int x, int y) {
this.x = x;
this.y = y;
}

public int x() { return x; }
public int y() { return y; }

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DiemCu d)) return false;
return x == d.x && y == d.y;
}

@Override
public int hashCode() {
return java.util.Objects.hash(x, y);
}

@Override
public String toString() {
return "DiemCu[x=" + x + ", y=" + y + "]";
}
}

// record: one line, all of the above generated automatically
record Diem(int x, int y) { }

// compact constructor for validation (optional)
record Khoang(int batDau, int ketThuc) {
public Khoang { // compact canonical constructor
if (ketThuc < batDau) { // validate before fields are set
throw new IllegalArgumentException("ket thuc < bat dau");
}
}
}

Lớp truyền thống bên trên có năm khối mã phải tự viết và tự bảo trì; phần record chỉ là một dòng khai báo, còn lại do trình biên dịch sinh ra.

Hàm dựng rút gọn (compact constructor) như Khoang ở trên là chỗ duy nhất ta xen vào giữa lúc nhận đối số và lúc trình biên dịch gán chúng vào trường. Nó không có danh sách tham số, không có lệnh gán: ta chỉ kiểm tra, hoặc chuẩn hoá bằng cách gán lại chính tên tham số, và giá trị sau cùng mới được đặt vào trường.

import java.util.List;

public record LopHoc(String ten, List<String> sinhVien) {
public LopHoc { // compact constructor
ten = ten.trim(); // normalize before storing
sinhVien = List.copyOf(sinhVien); // defensive copy
}
}

Dòng List.copyOf rất đáng chú ý: record chỉ làm tham chiếu thành final, nó không làm đối tượng được trỏ tới thành bất biến. Cất thẳng danh sách người gọi truyền vào thì họ vẫn giữ tham chiếu ấy và thêm bớt phần tử sau lưng ta được. Sao chép phòng vệ chặn đúng lỗ hổng đó.

Ngoài phần sinh sẵn, record vẫn cho thêm phương thức thường, hằng số static, và thậm chí ghi đè một phương thức truy cập nếu muốn trả về bản sao thay vì tham chiếu gốc. Cái nó không cho là thêm trường thể hiện ngoài danh sách thành phần, hay kế thừa một lớp khác, vì mọi record đã ngầm kế thừa lớp Record và Java chỉ cho kế thừa đơn. Hiểu ranh giới này giúp trả lời câu hỏi thường gặp: khi nào ngừng dùng record. Câu trả lời là ngay khi căn cước của đối tượng không còn trùng với trọn bộ thành phần, hoặc khi nó cần mang trạng thái đổi theo thời gian; lúc đó lớp thường mới đúng vai.

Ghi nhớ

record là bất biến: các trường là final, không có phương thức gán. Nó hợp với vai trò khoá của Map, phần tử của Set hay đối tượng truyền dữ liệu (data transfer object), nhưng không hợp khi cần thay đổi trạng thái theo thời gian.

Lớp niêm phong (sealed class)

Mặc định, bất kỳ lớp nào không final đều có thể bị lớp khác kế thừa. Đôi khi ta muốn giới hạn: một kiểu chỉ cho phép một tập hữu hạn các kiểu con đã biết trước. Từ Java 17, lớp niêm phong (sealed class) làm việc đó bằng mệnh đề permits, liệt kê đúng những kiểu con được phép.

// only Tron, ChuNhat and TamGiac may extend HinhPhang
public sealed interface HinhPhang permits Tron, ChuNhat, TamGiac { }

record Tron(double banKinh) implements HinhPhang { }
record ChuNhat(double rong, double cao) implements HinhPhang { }
record TamGiac(double day, double cao) implements HinhPhang { }

Mệnh đề permits đi kèm hai ràng buộc. Mọi kiểu con liệt kê trong đó phải nằm cùng gói hoặc cùng mô đun, để trình biên dịch nhìn thấy trọn tập ấy. Và mỗi kiểu con phải tự tuyên bố nó đóng hay mở tiếp: final là chốt hẳn, sealed là niêm phong thêm một tầng, non-sealed là cố ý mở lại. Một record mặc định đã final nên rất hợp vai kiểu con ở đây.

Lợi ích lớn nhất là khi kết hợp với switch: do trình biên dịch biết hết các nhánh có thể có, nó kiểm tra được tính đầy đủ. Đây là nền cho mẫu lập trình theo kiểu dữ liệu đại số, rất gọn khi mô hình hóa một miền nghiệp vụ có số trường hợp cố định.

Cặp lớp niêm phong và biểu thức switch tạo nên cái thường gọi là kiểu dữ liệu đại số: một kiểu tổng gồm đúng vài dạng đã biết, mỗi dạng mang dữ liệu riêng. So với cách cũ là một trường int loai rồi switch trên nó, cách này bắt trình biên dịch kiểm hộ. Nếu mai này miền nghiệp vụ có thêm một hình mới, chỉ cần thêm một record vào mệnh đề permits; mọi switch chưa xử lý hình đó lập tức đỏ lỗi biên dịch, chỉ cho ta danh sách đúng những chỗ phải sửa. Đây là kiểu an toàn mà một chuỗi if instanceof rời rạc không bao giờ cho được, vì bỏ sót một nhánh chỉ âm thầm trả sai lúc chạy.

Vì sao kiểu con của lớp niêm phong không biên dịch

Nguyên nhân gốc là trình biên dịch phải nhìn thấy trọn tập kiểu con để kiểm tra tính đầy đủ. Bởi vậy mỗi kiểu con phải nằm cùng mô đun (hoặc cùng gói nếu không dùng mô đun) với lớp cha, và phải tự tuyên bố nó đóng hay mở tiếp bằng đúng một trong final, sealed, non-sealed. Bỏ trống lời tuyên bố đó để lỗ hổng ngỏ: một lớp con không rõ đóng mở sẽ phá vỡ đảm bảo rằng tập kiểu con là hữu hạn và biết trước, nên trình biên dịch từ chối ngay.

Biểu thức switch

Câu lệnh switch cổ điển dễ sót break và không trả về giá trị. Từ Java 14, switch có dạng biểu thức (switch expression) dùng mũi tên ->: mỗi nhánh là một biểu thức, không rơi xuyên nhánh, và cả khối trả về một giá trị.

double dienTich = switch (h) { // switch as an expression
case Tron t -> Math.PI * t.banKinh() * t.banKinh();
case ChuNhat r -> r.rong() * r.cao();
case TamGiac g -> 0.5 * g.day() * g.cao();
// no default needed: HinhPhang is sealed, all cases are covered
};

String thu = switch (n) { // multiple labels per arrow
case 1, 7 -> "cuoi tuan";
case 2, 3, 4, 5, 6 -> "ngay thuong";
default -> throw new IllegalArgumentException("thu khong hop le");
};

HinhPhang là niêm phong và đã liệt kê đủ ba nhánh, trình biên dịch chấp nhận khối switch không cần default. Nếu sau này ta thêm một kiểu con mới mà quên cập nhật switch, mã sẽ báo lỗi biên dịch ngay, đó chính là sự an toàn ta mong muốn.

Nhánh nhiều câu lệnh và từ khoá yield

Khi một nhánh cần chạy vài câu lệnh trước lúc cho ra giá trị, ta viết thân nhánh trong ngoặc nhọn và dùng yield để nói "đây là giá trị của nhánh này". Đừng nhầm yield với return: return thoát khỏi cả phương thức, còn yield chỉ kết thúc một nhánh.

public class XepLoai {
public static String tu(int diem) {
return switch (diem) {
case 9, 10 -> "xuat sac";
case 8 -> "gioi";
case 7 -> {
String ghiChu = "kha"; // a block arm may run statements
yield ghiChu.toUpperCase(); // yield gives this arm its value
}
case 5, 6 -> "trung binh";
default -> "yeu";
};
}
}

Vì là biểu thức, khối switch phải cho ra giá trị ở mọi đường đi, nên nó bắt buộc phủ kín trường hợp, hoặc bằng default, hoặc nhờ kiểu niêm phong.

Một khác biệt tinh tế giữa hai kiểu nhánh đáng nhớ. Nhánh mũi tên -> chỉ chạy đúng vế của nó rồi thoát, nên không có chuyện rơi xuyên nhánh; còn nhánh hai chấm : kiểu cũ vẫn rơi tiếp xuống nhánh dưới nếu thiếu break, đúng cái bẫy mà dạng biểu thức sinh ra để dẹp. Vì một biểu thức bắt buộc cho ra giá trị trên mọi đường đi, khối switch biểu thức không được phép có lối thoát nào không trả về gì; đó là lý do trình biên dịch đòi phủ kín, hoặc bằng default, hoặc nhờ một kiểu niêm phong đã liệt kê đủ nhánh. Ràng buộc phủ kín này chính là nơi an toàn đến từ, không phải một phiền phức thừa.

So khớp mẫu trong switch

Các nhánh dạng case Tron t ->so khớp mẫu (pattern matching) cho switch: thử nghiệm từ Java 17 cùng lớp niêm phong, chuẩn hoá ở Java 21. Trên JDK cũ hơn, hãy dùng if với instanceof như phần dưới.

Khối văn bản (text block)

Chuỗi nhiều dòng trong Java cổ điển phải nối bằng dấu cộng và ký tự xuống dòng \n, rất khó đọc khi chứa JSON, SQL hay HTML. Từ Java 15, khối văn bản (text block) mở và đóng bằng ba dấu nháy kép, giữ nguyên định dạng nhiều dòng.

String json = """
{
"ten": "Nguyen Van A",
"diem": 8.5
}
"""; // leading indentation is stripped consistently

Quy tắc thụt lề đáng nắm cho chắc: trình biên dịch tìm dòng có ít khoảng trắng đầu dòng nhất trong khối, tính cả dòng chứa ba nháy đóng, rồi cắt đúng chừng ấy ở mọi dòng. Phần bị cắt là khoảng trắng phụ (incidental whitespace), phần còn lại mới thuộc về nội dung; vì thế xê dịch dòng ba nháy đóng là đổi luôn kết quả. Khối văn bản vẫn là một String bình thường, nên """...""".formatted(maLop) chèn tham số vào một câu SQL nhiều dòng rất gọn.

So khớp mẫu với instanceof

Mẫu mã kiểm tra kiểu rồi ép kiểu có ở khắp nơi trong Java cũ: if (o instanceof String) rồi ngay dòng sau String s = (String) o;. Lời ép ấy thừa vì instanceof vừa kiểm tra xong. Từ Java 16, so khớp mẫu cho instanceof khai báo luôn một biến ràng buộc (binding variable) ngay trong phép kiểm tra.

public class SoKhop {
// pattern matching: the test itself introduces s, no cast needed
public static boolean trung(Object o, String mau) {
return o instanceof String s && s.equalsIgnoreCase(mau);
}

// the binding also works after an early return
public static int doDai(Object o) {
if (!(o instanceof String s)) {
return -1; // s is not usable in this branch
}
return s.length(); // but it is usable from here on
}
}

Phạm vi của biến ràng buộc do trình biên dịch suy ra theo dòng chảy điều khiển: nó chỉ dùng được ở nơi phép kiểm tra chắc chắn đã đúng. Vì thế o instanceof String s && s.length() > 0 hợp lệ, còn thay && bằng || thì không, vì nhánh phải của || chỉ chạy khi phép kiểm tra sai.

Lỗi thường gặp với cú pháp mới
  • var ds = new ArrayList<>(); với ngoặc kim cương rỗng: suy ra ArrayList<Object>, mất sạch an toàn kiểu mà không cảnh báo.
  • Dùng var cho trường hoặc tham số: không hợp lệ, var chỉ dành cho biến cục bộ.
  • Tưởng record bất biến sâu: nó chỉ làm tham chiếu thành final, cần sao chép phòng vệ.
  • Dùng return thay yield trong nhánh khối của biểu thức switch.
  • Kiểu con của lớp niêm phong không khai báo final, sealed hay non-sealed: không biên dịch.
  • Xê dịch dòng ba nháy đóng của khối văn bản: thụt lề của cả chuỗi đổi theo.

Bài tập

Bài 1. Viết lại lớp DiemCu ở phần trên thành một record, rồi liệt kê những phương thức mà trình biên dịch sinh tự động.

Gợi ý

Khai báo gọn lại thành public record Diem(int x, int y) { }. Trình biên dịch sinh: hai trường final x và y; hàm dựng chính tắc Diem(int x, int y); hai phương thức truy cập x()y(); cùng equals, hashCodetoString. Tên phương thức truy cập trùng tên thành phần, không có tiền tố get.

Bài 2. Cho đoạn mã var ds = new ArrayList<String>();. Hãy giải thích kiểu tĩnh của ds và nêu một trường hợp dùng var làm mã khó đọc hơn.

Gợi ý

Kiểu tĩnh của dsArrayList<String>, suy ra từ vế phải, và cố định từ lúc biên dịch. var khó đọc khi vế phải không nói rõ kiểu, ví dụ var k = tinhToan();: người đọc phải lần vào chữ ký của tinhToan mới biết k là gì.

Bài 3. Định nghĩa một giao tiếp niêm phong PhuongTien chỉ cho phép ba record Xe, Tau, MayBay. Viết một biểu thức switch tính chi phí mỗi loại, không dùng nhánh default, và giải thích vì sao trình biên dịch chấp nhận.

Gợi ý

Khai báo public sealed interface PhuongTien permits Xe, Tau, MayBay { }. Trong switch, viết đủ ba nhánh case Xe x -> ..., case Tau t -> ..., case MayBay m -> .... Trình biên dịch chấp nhận bỏ default vì giao tiếp niêm phong cho biết toàn bộ tập kiểu con, nên nó tự kiểm tra ba nhánh đã phủ kín.

Bài 4. Viết record DoanDiem(int batDau, int ketThuc) có hàm dựng rút gọn từ chối đoạn ngược, kèm phương thức tĩnh doDaiCua(Object o) trả về độ dài nếu o là một DoanDiem, ngược lại trả về -1. Dùng so khớp mẫu cho instanceof.

Lời giải

Hàm dựng rút gọn chạy trước khi các trường được gán, nên là chỗ đúng để kiểm tra. Ở doDaiCua, phép instanceof vừa kiểm tra kiểu vừa khai báo biến ràng buộc d, nên không cần lời ép kiểu nào.

public record DoanDiem(int batDau, int ketThuc) {
public DoanDiem { // compact constructor
if (ketThuc < batDau) {
throw new IllegalArgumentException("ket thuc < bat dau");
}
}

public int doDai() {
return ketThuc - batDau;
}

public static int doDaiCua(Object o) {
// pattern matching: test and bind in one step
return (o instanceof DoanDiem d) ? d.doDai() : -1;
}

public static void chay() {
System.out.println(doDaiCua(new DoanDiem(2, 7))); // prints: 5
System.out.println(doDaiCua("khong phai doan")); // prints: -1
}
}

Tự kiểm tra

Vì sao record là lựa chọn tự nhiên cho dữ liệu bất biến, còn lớp thường thì không?

record sinh sẵn các trường final và không có phương thức gán, nên đối tượng không đổi trạng thái sau khi tạo. Đó đúng là định nghĩa của bất biến. Lớp thường vẫn có thể bất biến nhưng phải tự kỷ luật: tự khai báo final, tự bỏ các setter, tự viết equalshashCode cho đúng. record gói toàn bộ kỷ luật đó vào ngôn ngữ nên khó làm sai.

Lớp niêm phong giúp biểu thức switch an toàn hơn ở điểm nào?

Vì giao tiếp niêm phong liệt kê đầy đủ tập kiểu con được phép, trình biên dịch biết chính xác có bao nhiêu nhánh. Khi switch đã phủ hết các nhánh đó, ta được phép bỏ default. Quan trọng hơn, nếu sau này thêm một kiểu con mới mà quên cập nhật switch, trình biên dịch báo lỗi ngay lúc dịch chứ không để lỗi lọt tới lúc chạy.

Câu hỏi tự kiểm

Kiểm tra nhanh: cú pháp hiện đại0/4 đúngchưa trả lời
  1. 1Từ phiên bản nào Java có suy luận kiểu cục bộ với var?
  2. 2Trong một nhánh khối (mở ngoặc nhọn) của biểu thức switch, muốn cho ra giá trị của nhánh đó ta dùng từ khoá nào?
  3. 3Vì sao một record có trường kiểu List vẫn có thể bị sửa nội dung từ bên ngoài nếu không sao chép phòng vệ?
  4. 4Vì sao một biểu thức switch trên kiểu niêm phong đã liệt kê đủ nhánh lại không cần default?

Tóm tắt

  • var (Java 10) là suy luận kiểu lúc biên dịch cho biến cục bộ, không phải kiểu động; dùng khi vế phải đã nói rõ kiểu.
  • Biểu thức switch (Java 14) không rơi xuyên nhánh, cho ra một giá trị, dùng yield khi nhánh cần nhiều câu lệnh.
  • Khối văn bản (Java 15) giữ định dạng nhiều dòng, tự cắt khoảng trắng phụ theo dòng thụt ít nhất.
  • record (Java 16) sinh sẵn trường final, hàm dựng, phương thức truy cập, equals, hashCode, toString; hàm dựng rút gọn là nơi kiểm tra và chuẩn hoá.
  • Lớp niêm phong (Java 17) khai báo trọn tập kiểu con qua permits, nhờ đó switch phủ kín mà không cần default.
  • So khớp mẫu cho instanceof (Java 16) gộp kiểm tra và ép kiểu vào một bước.