Thực hành tốt
Bài này không thêm cú pháp mới, mà nói về thói quen làm cho mã dễ đọc, dễ bảo trì và làm việc nhóm thuận lợi: tổ chức gói và quy ước đặt tên, thiết kế bất biến (immutability), xử lý ngoại lệ đúng cách, tài liệu hóa, đóng gói jar cùng công cụ build, và một bảng tra cứu tính năng theo phiên bản Java.
Mã nguồn được viết một lần nhưng đọc đi đọc lại rất nhiều lần, bởi chính người viết vài tháng sau và bởi đồng đội. Một đoạn mã chạy đúng nhưng đặt tên lộn xộn, không có ranh giới gói rõ ràng sẽ ngốn thời gian mỗi lần ai đó phải sửa. Những quy ước trong bài không phải luật cứng của trình biên dịch, mà là thỏa thuận chung của cộng đồng Java để người này đọc mã người kia không phải dừng lại đoán.
Hãy hình dung một đồ án bốn người. Tới lúc gộp mã thì rắc rối lộ ra: một bạn đặt lớp tên sinhvien, bạn khác SinhVienDTO, bạn thứ ba StudentInfo, cả ba mô tả cùng một thứ; một bạn trả về null khi không tìm thấy, bạn khác ném ngoại lệ, bạn thứ ba trả về đối tượng rỗng. Không dòng nào sai cú pháp, nhưng cả nhóm mất nhiều ngày dàn xếp những khác biệt vô nghĩa. Java hiện đại giảm bớt chi phí ấy bằng cách đưa thói quen tốt vào thẳng ngôn ngữ, nhưng mỗi công cụ đều có cái giá, nên với mỗi mục dưới đây ta hỏi ba câu: cơ chế là gì, vì sao thiết kế như vậy, đánh đổi nằm ở đâu.
Bốn thẻ dưới đây là bốn nguyên tắc xương sống của bài. Lật xem trước để biết mình sắp đi qua những gì.
Tổ chức gói và quy ước đặt tên
Chia mã theo gói (package) đặt theo tên miền đảo ngược, ví dụ vn.dau.cntt.diem, và gom theo tính năng chứ không theo loại kỹ thuật. Quy ước đặt tên của cộng đồng Java rất nhất quán:
- lớp và giao tiếp viết kiểu PascalCase, ví dụ
SinhVien; - phương thức và biến viết camelCase, ví dụ
tinhDiem; - hằng số viết hoa kèm gạch dưới, ví dụ
DIEM_TOI_DA; - tên gói viết thường liền nhau.
Tuân theo quy ước này giúp người khác đọc mã của bạn không phải dừng lại để đoán.
Dưới lớp quy ước hình thức còn một tầng sâu hơn: tên phải nói đúng vai trò. Đáng cảnh giác nhất là những cái tên chỉ loại kỹ thuật thay vì nghiệp vụ, kiểu DataManager hay InfoHelper: chúng không nói lên điều gì và thường báo hiệu lớp đang ôm nhiều trách nhiệm rời rạc. Khi bạn không đặt nổi tên cho một lớp, vấn đề nằm ở thiết kế chứ không ở vốn từ.
Suy luận kiểu var và ranh giới của nó
Từ Java 10, ta khai báo biến cục bộ bằng var và để trình biên dịch suy ra kiểu từ vế phải. Cần gạt bỏ ngay một hiểu lầm: var không biến Java thành ngôn ngữ kiểu động. Biến khai báo bằng var vẫn có kiểu tĩnh cố định, chốt ngay lúc biên dịch, nên var x = "DAU"; x = 10; vẫn là lỗi biên dịch. Nó chỉ dùng cho biến cục bộ có khởi tạo ngay, biến đếm vòng lặp và tham số Lambda, không dùng được cho trường của lớp hay kiểu trả về.
import java.util.ArrayList;
import java.util.List;
public class DungVar {
public static void chay() {
var danhSach = new ArrayList<String>(); // good: the type is right there
for (var ten : danhSach) { // good: obvious from the loop
System.out.println(ten.length());
}
// explicit type is better here: we want the interface, not ArrayList
List<String> theoGiaoTiep = new ArrayList<>();
System.out.println(theoGiaoTiep.size());
}
}
Quy tắc gọn nhất: dùng var khi kiểu đã hiện rõ ở vế phải, tránh khi vế phải không nói lên kiểu. Dòng var ds = new ArrayList<SinhVien>() đọc là hiểu, còn var kq = xuLy(dl) buộc người đọc đi tìm chữ ký của xuLy. Một đánh đổi nữa: khai báo tường minh theo giao tiếp thì đổi sang LinkedList sau này không phải sửa gì khác, còn var chốt kiểu tĩnh vào đúng lớp cài đặt.
- Dùng
varcho biến khởi tạo từ một phương thức tên mơ hồ. Nguyên nhân gốc:varchuyển gánh nặng suy luận kiểu từ trình biên dịch sang mắt người đọc, mà mắt người không kiểm tra kiểu; nhầmintvớilongcó thể lọt qua và gây tràn số. - Viết
var x = null;: không biên dịch được. Nguyên nhân gốc:nullthuộc mọi kiểu tham chiếu nên không có kiểu duy nhất để suy ra.
Khối văn bản (text block)
Trước đây, nhúng một đoạn JSON hay SQL nhiều dòng vào mã Java nghĩa là nối chuỗi bằng dấu cộng và thoát từng dấu nháy kép. Khối văn bản, viết bằng ba dấu nháy kép, giữ nguyên xuống dòng và cho phép dấu nháy kép trần bên trong.
public class KhoiVanBan {
public static void chay() {
String sql = """
SELECT ma, ten
FROM sinh_vien
WHERE diem >= 8.0
""";
System.out.println(sql);
}
}
Điểm cần hiểu là cách xử lý thụt lề: trình biên dịch tìm dòng thụt vào ít nhất trong khối, tính cả dòng chứa ba nháy đóng, rồi cắt đúng chừng ấy khoảng trắng ở mọi dòng. Hệ quả thực dụng là vị trí ba nháy đóng quyết định lề trái của chuỗi, nên xê dịch nó chính là thay đổi nội dung chuỗi; đây là nguồn của phần lớn bất ngờ khi lần đầu dùng tính năng này.
Tính bất biến (immutability)
Bất biến (immutability) nghĩa là đối tượng không đổi trạng thái sau khi tạo. Đối tượng bất biến an toàn khi chia sẻ giữa nhiều luồng mà không cần đồng bộ, dễ kiểm thử và không gây hiệu ứng phụ bất ngờ. Cách làm: khai báo trường private final, không cung cấp phương thức gán, và sao chép phòng thủ khi nhận hoặc trả về tham chiếu tới một đối tượng có thể thay đổi. Kiểu record đã đi sẵn theo nguyên tắc này, nên là lựa chọn tự nhiên cho dữ liệu bất biến.
// lop bat bien viet tay: truong final, khong setter, khong sua tai cho
public final class Diem {
private final double toan;
private final double van;
public Diem(double toan, double van) {
this.toan = toan;
this.van = van;
}
public double getToan() { return toan; }
public double getVan() { return van; }
// phep cap nhat tra ve doi tuong moi thay vi sua truong
public Diem voiToan(double toanMoi) {
return new Diem(toanMoi, this.van);
}
}
// cung y nghia nhung record sinh san moi thu (kieu phu: bo public de mot file mot kieu public)
record DiemRecord(double toan, double van) {}
Cả hai cách đều cho đối tượng không đổi trạng thái sau khi tạo. Lớp viết tay cần tự kỷ luật từng bước, còn record gói kỷ luật đó vào ngôn ngữ, sinh sẵn trường final, hàm dựng, equals, hashCode và toString, nên khó làm sai hơn. Một điểm cần nhớ: nếu trường tham chiếu tới đối tượng có thể thay đổi như mảng hay List, bất biến của lớp bao chỉ giữ được khi ta sao chép phòng thủ lúc nhận vào và lúc trả ra.
record cho lớp mang dữ liệu
record có từ Java 16, sinh ra cho một nhu cầu rất cụ thể: rất nhiều lớp trong dự án chỉ là cái túi đựng vài trường. Viết tay một lớp như vậy tốn vài chục dòng mà không dòng nào nói lên ý định nghiệp vụ, và tệ hơn, equals với hashCode viết tay rất dễ quên cập nhật khi ai đó thêm trường mới; một hashCode lệch với equals làm hỏng mọi HashMap chứa đối tượng đó, theo cách cực khó dò. record sinh sẵn mọi thứ đó dựa trên toàn bộ trường.
public record Diem2(double toan, double van) {
// compact constructor: validate before the fields are assigned
public Diem2 {
if (toan < 0 || toan > 10 || van < 0 || van > 10) {
throw new IllegalArgumentException("diem phai nam trong khoang 0 den 10");
}
}
// a record may add behaviour, it just may not add mutable state
public double trungBinh() { return (toan + van) / 2; }
}
Hàm dựng rút gọn chạy trước khi các trường được gán, nên một record không hợp lệ không bao giờ ra đời được. Khi nào đừng dùng record? Khi lớp cần trạng thái thay đổi được, khi cần kế thừa (record là final ngầm), hoặc khi so sánh theo toàn bộ trường là sai về nghiệp vụ: hai sinh viên khác nhau vẫn phải khác nhau dù trùng tên và trùng ngày sinh.
Lớp niêm phong (sealed)
Lớp niêm phong là tính năng của Java 17, cho phép lớp cha khai báo dứt khoát danh sách kiểu con được phép kế thừa. Vấn đề nó giải quyết: khi kế thừa mở hoàn toàn, ai cũng có thể sinh thêm kiểu con mới, nên trình biên dịch không biết một switch xử lý từng loại hình đã liệt kê đủ chưa, và lỗi thiếu nhánh chỉ lộ ra lúc chạy. Với sealed, tập kiểu con hữu hạn nên tính đầy đủ kiểm tra được.
public sealed interface HinhHoc permits HinhHoc.Tron, HinhHoc.ChuNhat, HinhHoc.Vuong {
record Tron(double banKinh) implements HinhHoc {}
record ChuNhat(double rong, double cao) implements HinhHoc {}
record Vuong(double canh) implements HinhHoc {}
// no default arm: the compiler proves these three cases are exhaustive
static double dienTich(HinhHoc h) {
return switch (h) {
case Tron t -> Math.PI * t.banKinh() * t.banKinh();
case ChuNhat c -> c.rong() * c.cao();
case Vuong v -> v.canh() * v.canh();
};
}
}
Khối switch trên không có nhánh default, và điều đó hợp lệ vì tập kiểu con đã đóng. Cái lợi thật lộ ra lúc bảo trì: ngày mai ai đó thêm TamGiac vào permits thì mọi switch thiếu nhánh lập tức không biên dịch được, ta được chỉ đúng chỗ phải sửa.
Biểu thức switch
Câu lệnh switch kiểu cũ có ba tật: rơi xuyên nhánh khi quên break, là câu lệnh chứ không phải biểu thức nên muốn lấy giá trị phải khai báo trước một biến thay đổi được rồi gán trong từng nhánh, và không được kiểm tra tính đầy đủ của các nhánh. Biểu thức switch, chốt chính thức ở Java 14, sửa cả ba.
public class XepLoai {
public static String xep(int diem) {
return switch (diem / 10) {
case 10, 9 -> "xuat sac"; // several labels on one arm
case 8 -> "gioi";
case 7 -> "kha";
default -> {
// a block arm hands its value back with yield, never with return
String ghiChu = diem < 0 ? "du lieu sai" : "khong dat";
yield ghiChu;
}
};
}
}
Mũi tên -> không rơi xuyên nhánh, và toàn khối trả về một giá trị nên gán thẳng vào biến final được. Khi một nhánh cần nhiều dòng, ta mở khối ngoặc nhọn và dùng yield; viết return bên trong là không hợp lệ, vì switch ở đây là biểu thức đang tính giá trị chứ không phải một nhánh điều khiển của phương thức. Đây là chỗ người mới hay vấp.
Optional dùng đúng chỗ
Optional<T> là hộp chứa hoặc một giá trị hoặc không gì cả. Mục đích của nó là làm cho khả năng vắng giá trị hiện lên trong kiểu trả về thay vì nấp trong tài liệu. Một phương thức khai báo trả về SinhVien mà lại có đường trả null là cái bẫy: người gọi không có cách nào biết mà phòng thủ. Trả về Optional<SinhVien> thì nói thẳng rằng có thể không có ai, và người gọi buộc phải mở hộp trước khi dùng.
import java.util.List;
import java.util.Optional;
public record SinhVien(String ma, String ten) {
public static Optional<SinhVien> timTheoMa(List<SinhVien> ds, String ma) {
return ds.stream().filter(sv -> sv.ma().equals(ma)).findFirst();
}
public static String moTa(List<SinhVien> ds, String ma) {
return timTheoMa(ds, ma)
.map(SinhVien::ten) // only runs when present
.orElse("khong tim thay sinh vien"); // default when absent
}
}
Điều quan trọng nhất về Optional lại là chỗ không nên dùng nó. Nó sinh ra cho vị trí giá trị trả về. Đừng dùng làm kiểu trường, vì mỗi đối tượng phải mang thêm một lớp bọc mà bản thân trường ấy vẫn có thể null. Đừng dùng làm kiểu tham số, vì người gọi phải bọc giá trị lại chỉ để cho vừa chữ ký, trong khi cách tự nhiên là nạp chồng hai phương thức. Cũng đừng bọc Optional quanh một tập hợp: danh sách rỗng đã đủ diễn tả "không có gì". Khi mở hộp, orElse cho giá trị mặc định dựng sẵn, orElseGet chỉ tính giá trị mặc định khi thật sự cần, orElseThrow ném ngoại lệ khi rỗng.
Nói rộng hơn, tránh null không chỉ có Optional: trả về tập hợp rỗng thay vì null, kiểm tra đối số ngay đầu phương thức, và so sánh chuỗi hằng theo lối "gioi".equals(xepLoai) vì cách viết đó chịu được xepLoai bằng null.
- Đặt
Optionallàm kiểu trường hoặc kiểu tham số. Nguyên nhân gốc: nó được thiết kế cho vị trí giá trị trả về, nơi người gọi cần bị buộc xử lý trường hợp vắng; ở chỗ khác nó chỉ thêm một tầng bọc và thêm chính khả năngnullmà ta đang muốn diệt. - Gọi
opt.get()mà không kiểm tra: némNoSuchElementException. Nguyên nhân gốc: ta dựng cả một cơ chế rồi vô hiệu hoá nó ở dòng cuối, tức chỉ đổi tên choNullPointerException.
Xử lý ngoại lệ đúng cách
Xử lý ngoại lệ tốt hay dở ảnh hưởng trực tiếp tới độ khó gỡ lỗi về sau. Vài quy tắc thực dụng:
- Bắt ngoại lệ ở mức thật sự xử lý được, đừng bắt chỉ để rồi bỏ qua.
- Không dùng khối
catchrỗng: nó nuốt mất triệu chứng của lỗi thật. - Ném loại ngoại lệ nói đúng bản chất lỗi:
IllegalArgumentExceptioncho đối số sai,IllegalStateExceptioncho trạng thái không hợp lệ. - Khi gói lại lỗi gốc trong một ngoại lệ mới, truyền nguyên nhân qua hàm dựng để giữ vết ngăn xếp.
public Diem doc(String duLieu) {
try {
String[] phan = duLieu.split(",");
return new Diem(Double.parseDouble(phan[0]), Double.parseDouble(phan[1]));
} catch (NumberFormatException e) {
// goi lai loi goc, giu nguyen nhan de khong mat vet ngan xep
throw new IllegalArgumentException("du lieu diem khong hop le: " + duLieu, e);
}
}
Tránh dùng ngoại lệ để điều khiển luồng bình thường của chương trình: ngoại lệ đắt và làm mã khó theo dõi. Với trường hợp không có giá trị, Optional thường là lựa chọn sạch hơn việc ném và bắt ngoại lệ.
Ngoại lệ có kiểm và không kiểm
Java chia ngoại lệ làm hai họ. Ngoại lệ có kiểm (checked) là lớp con của Exception nhưng không phải con của RuntimeException, ví dụ IOException; trình biên dịch bắt buộc hoặc bắt nó bằng catch, hoặc khai báo throws để đẩy trách nhiệm lên trên. Ngoại lệ không kiểm (unchecked) là lớp con của RuntimeException, ví dụ NullPointerException hay IllegalArgumentException; trình biên dịch không đòi hỏi gì.
Ranh giới này rõ nghĩa nếu đọc theo câu hỏi "ai có thể làm gì với lỗi này". Ngoại lệ có kiểm dành cho hỏng hóc mà người gọi khắc phục được một cách hợp lý, như tệp không mở được thì hỏi lại đường dẫn, mạng đứt thì thử lại. Ngoại lệ không kiểm dành cho lỗi lập trình, thứ mà cách xử lý đúng là sửa mã chứ không phải bắt lấy: chia cho không, chỉ số ngoài mảng, đối số vô lý.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class DocCauHinh {
// IOException is checked: it must be caught here or declared like this
public static String doc(Path duong) throws IOException {
return Files.readString(duong);
}
// an unchecked exception reports a bug at the call site
public static void kiemTra(String ma) {
if (ma == null || ma.isBlank()) {
throw new IllegalArgumentException("ma sinh vien khong duoc rong");
}
}
}
Thực tế, cộng đồng Java ngày càng dè dặt với ngoại lệ có kiểm, vì chi phí lan truyền: chỉ cần một phương thức ở tầng sâu ném IOException là mọi phương thức trên đường gọi đều phải khai báo throws IOException. Đáp lại, người ta hay bắt rồi bọc lại thành ngoại lệ không kiểm mang ngữ nghĩa của tầng mình.
- Khối
catchrỗng, hoặc chỉprintStackTracerồi chạy tiếp. Nguyên nhân gốc: ta bắt lỗi ở nơi không đủ thông tin để xử lý; hãy để nó lan lên tầng nào biết phải làm gì. - Ném ngoại lệ mới mà quên truyền nguyên nhân gốc. Nguyên nhân gốc: hàm dựng một tham số chỉ nhận thông điệp, phải dùng bản hai tham số có
causethì vết ngăn xếp ban đầu mới còn.
Tài liệu hóa bằng Javadoc
Tài liệu hóa tốt giúp người khác dùng mã của bạn mà không phải đọc từng dòng cài đặt. Java có công cụ javadoc sinh trang tài liệu HTML từ các chú thích dạng /** ... */ đặt ngay trên lớp, phương thức hay trường. Dùng các thẻ chuẩn để mô tả có cấu trúc:
/**
* Tinh diem trung binh co trong so cua mot sinh vien.
*
* @param diem mang diem thanh phan, khong duoc null
* @param trongSo mang trong so tuong ung, cung do dai voi diem
* @return diem trung binh da lam tron hai chu so thap phan
* @throws IllegalArgumentException neu hai mang khac do dai
*/
public double trungBinh(double[] diem, double[] trongSo) {
// ...
return 0.0;
}
Thẻ @param, @return, @throws mô tả đối số, giá trị trả về và điều kiện ném lỗi. Viết tài liệu tập trung vào ý định và hợp đồng của phương thức, chứ không lặp lại cái mà tên phương thức đã nói rõ.
Đóng gói jar và công cụ build
Khi dự án có nhiều lớp, ta đóng gói toàn bộ tệp .class cùng tài nguyên thành một tệp lưu trữ jar (Java archive) để phân phối và chạy.
# compile sources into the out/ directory
javac -d out src/vn/dau/cntt/*.java
# package classes into an executable jar with a main class
jar --create --file ung-dung.jar --main-class vn.dau.cntt.Main -C out .
# run the jar
java -jar ung-dung.jar
Với dự án thật, hiếm khi ta gõ tay các lệnh này; thay vào đó dùng một công cụ quản lý dựng (build tool) như Maven hoặc Gradle để khai báo phụ thuộc thư viện, biên dịch, kiểm thử và đóng gói tự động chỉ bằng một lệnh. Maven mô tả dự án trong tệp pom.xml theo cấu trúc quy ước sẵn; Gradle dùng tập lệnh build.gradle linh hoạt hơn. Cả hai đều tự tải các thư viện phụ thuộc từ kho trung tâm, nên bạn không phải tự đi tìm và chép tệp jar của bên thứ ba.
Bảng tính năng theo phiên bản Java
Bảng dưới gom tính năng theo bốn bản hỗ trợ dài hạn (LTS) mà các dự án thực tế thường chọn làm nền tảng, chứ không phải theo đúng phiên bản đầu tiên giới thiệu từng tính năng.
| Phiên bản | Năm | Tính năng tiêu biểu |
|---|---|---|
| Java 8 | 2014 | Lambda, Streams API, Optional, phương thức default trong giao tiếp. |
| Java 11 | 2018 | var cho tham số Lambda, bản hỗ trợ dài hạn (LTS) phổ biến. |
| Java 17 | 2021 | record, lớp niêm phong (sealed class), biểu thức switch ổn định, bản LTS. |
| Java 21 | 2023 | So khớp mẫu cho switch, luồng ảo (virtual thread), bản LTS. |
Bảng gộp theo bản LTS nên vài tính năng thật ra có sớm hơn một chút. Suy luận kiểu var cho biến cục bộ có từ Java 10, còn dòng Java 11 chỉ bổ sung var cho tham số Lambda. Biểu thức switch chốt chính thức ở Java 14, record ở Java 16, còn lớp niêm phong mới đúng là của Java 17. Khi cần con số chính xác cho tài liệu kỹ thuật, hãy tra bản ghi tính năng của từng phiên bản thay vì dựa vào bảng gộp này.
- Đặt tên trộn phong cách kiểu
Sinh_vienhaytinhDIEM: gây ma sát cho người đọc, hãy theo PascalCase cho lớp và camelCase cho phương thức. - Gom mã theo loại kỹ thuật thay vì theo tính năng: một thay đổi nghiệp vụ nhỏ phải sờ vào nhiều gói rải rác.
- Tưởng lớp là bất biến nhưng trường lại giữ tham chiếu tới
Listhay mảng sửa được: người gọi vẫn đổi được nội dung; hãy sao chép phòng thủ. - Bắt
Exceptionchung chung cho mọi thứ rồi in ra rồi thôi: che mất lỗi thật. - Chép tay tệp
jarcủa bên thứ ba vào dự án: mất kiểm soát phiên bản; hãy khai báo phụ thuộc trong Maven hoặc Gradle.
Bài tập
Bài 1. Cho một lớp TaiKhoan có trường soDu thay đổi được qua setter. Hãy chỉ ra ba bước biến nó thành lớp bất biến, và giải thích vì sao bản bất biến an toàn hơn khi chia sẻ giữa nhiều luồng.
Gợi ý
Ba bước: đổi các trường thành private final; bỏ mọi phương thức gán (setter); với phép cập nhật, trả về một đối tượng TaiKhoan mới thay vì sửa tại chỗ. Bản bất biến an toàn khi chia sẻ vì trạng thái không đổi sau khi tạo, nên nhiều luồng đọc cùng lúc không thể thấy giá trị nửa vời, khỏi cần đồng bộ.
Bài 2. Một dự án có các lớp đặt rải rác, đặt tên lẫn lộn kiểu Sinh_vien, tinhDIEM, diemToiDa. Hãy sửa lại tên cho đúng quy ước Java và đặt chúng vào gói vn.dau.cntt.diem.
Gợi ý
Lớp viết PascalCase: SinhVien. Phương thức viết camelCase: tinhDiem. Hằng số viết hoa kèm gạch dưới: DIEM_TOI_DA (nếu là static final), hoặc giữ diemToiDa nếu chỉ là biến thường. Khai báo package vn.dau.cntt.diem; ở đầu mỗi tệp, đặt tệp đúng theo cây thư mục vn/dau/cntt/diem.
Bài 3. Cho một dự án nhỏ có lớp chính vn.dau.cntt.Main. Hãy viết các lệnh biên dịch, đóng gói thành jar chạy được và chạy nó, rồi nêu một lý do nên chuyển sang Maven hoặc Gradle.
Gợi ý
Biên dịch javac -d out src/vn/dau/cntt/*.java, đóng gói jar --create --file ung-dung.jar --main-class vn.dau.cntt.Main -C out ., chạy java -jar ung-dung.jar. Lý do chuyển sang Maven hoặc Gradle: khi dự án có thư viện phụ thuộc của bên thứ ba, công cụ build tự tải về từ kho trung tâm và quản lý phiên bản, thay cho việc tự đi tìm và chép tệp jar thủ công.
Bài 4. Một phương thức timSinhVien(String ma) hiện trả về null khi không tìm thấy. Hãy viết lại theo hướng dùng Optional, và chỉ ra hai cách dùng Optional phải tránh trong lúc sửa.
Lời giải chi tiết
Đổi kiểu trả về thành Optional<SinhVien>, rồi ở nơi gọi thì mở hộp bằng map với orElse, hoặc orElseThrow nếu vắng giá trị đúng là lỗi.
import java.util.List;
import java.util.Optional;
public class KhoSinhVien {
public record SV(String ma, String ten) {}
private final List<SV> ds;
public KhoSinhVien(List<SV> ds) { this.ds = List.copyOf(ds); }
// the return type now announces that the value may be absent
public Optional<SV> timSinhVien(String ma) {
return ds.stream().filter(sv -> sv.ma().equals(ma)).findFirst();
}
public SV batBuocCo(String ma) {
return timSinhVien(ma)
.orElseThrow(() -> new IllegalArgumentException("khong co ma " + ma));
}
}
Hai cách phải tránh: đặt Optional làm kiểu trường của SV, vì nó chỉ dành cho vị trí giá trị trả về; và gọi timSinhVien(ma).get() mà không kiểm tra, vì khi rỗng nó ném NoSuchElementException, tức chỉ đổi tên cho NullPointerException.
Bài 5. Mô hình hoá ba trạng thái của một đơn đăng ký học phần là chờ duyệt, đã duyệt (kèm tên người duyệt) và từ chối (kèm lý do). Dùng sealed cùng record, viết biểu thức switch sinh thông báo cho từng trạng thái, rồi giải thích vì sao khối switch này không cần nhánh default.
Lời giải chi tiết
Mệnh đề permits liệt kê dứt khoát ba kiểu con nên tập trạng thái là đóng, trình biên dịch tự chứng minh được ba nhánh phủ hết mọi khả năng và nhánh default thành thừa. Khi ai đó thêm trạng thái thứ tư, mọi switch thiếu nhánh sẽ không biên dịch được, tức lỗi vốn chỉ lộ ra lúc chạy nay thành lỗi biên dịch nằm đúng dòng cần sửa.
public sealed interface TrangThaiDon
permits TrangThaiDon.ChoDuyet, TrangThaiDon.DaDuyet, TrangThaiDon.TuChoi {
record ChoDuyet() implements TrangThaiDon {}
record DaDuyet(String nguoiDuyet) implements TrangThaiDon {}
record TuChoi(String lyDo) implements TrangThaiDon {}
// exhaustive over a closed set of subtypes, so no default arm is needed
static String thongBao(TrangThaiDon t) {
return switch (t) {
case ChoDuyet c -> "don dang cho duyet";
case DaDuyet d -> "da duyet boi " + d.nguoiDuyet();
case TuChoi r -> "bi tu choi, ly do: " + r.lyDo();
};
}
}
Tự kiểm tra
Vì sao record là lựa chọn tự nhiên khi cần một kiểu dữ liệu bất biến?
record sinh sẵn các trường final và không có phương thức gán, đúng theo nguyên tắc bất biến mà phần này nêu: trường private final, không setter. Lớp thường vẫn làm được nhưng phải tự kỷ luật từng bước; record gói toàn bộ kỷ luật đó vào ngôn ngữ nên khó làm sai.
Maven và Gradle khác nhau ở điểm cơ bản nào?
Maven mô tả dự án trong tệp pom.xml theo cấu trúc quy ước cố định, nghiêng về khai báo. Gradle dùng tập lệnh build.gradle, linh hoạt và lập trình được hơn. Cả hai đều tự tải thư viện phụ thuộc từ kho trung tâm và tự động hóa biên dịch, kiểm thử, đóng gói bằng một lệnh.
Câu hỏi tự kiểm
- 1Theo quy ước đặt tên của cộng đồng Java, một hằng số nên viết thế nào?
- 2Vì sao một đối tượng bất biến an toàn khi chia sẻ giữa nhiều luồng mà không cần đồng bộ?
- 3Từ phiên bản nào record chính thức có mặt trong Java?
- 4Đâu là một cách dùng Optional phải tránh?
Tóm tắt
var (Java 10) chỉ nên dùng khi kiểu đã hiện rõ ở vế phải. Khối văn bản giữ định dạng nhiều dòng, với lưu ý vị trí ba nháy đóng quyết định lề trái của chuỗi. record (Java 16) là lựa chọn mặc định cho lớp mang dữ liệu bất biến. Lớp niêm phong (Java 17) đóng tập kiểu con để trình biên dịch kiểm tra được tính đầy đủ của các nhánh. Biểu thức switch (Java 14) trả về giá trị và không rơi xuyên nhánh. Optional chỉ nên xuất hiện ở vị trí giá trị trả về, không làm trường hay tham số. Ngoại lệ có kiểm dành cho hỏng hóc mà người gọi khắc phục được, ngoại lệ không kiểm dành cho lỗi lập trình mà cách xử lý đúng là sửa mã. Điểm chung: mỗi công cụ đều có cái giá, dùng máy móc thường tệ hơn không dùng.
Java hiện đại thay phần lớn mã lặp bằng cú pháp gọn và an toàn hơn: var suy luận kiểu cục bộ, record sinh sẵn lớp mang dữ liệu bất biến, lớp niêm phong khống chế tập kiểu con, biểu thức switch trả về giá trị và không rơi xuyên nhánh, khối văn bản giữ định dạng nhiều dòng. Về an toàn, Optional buộc xử lý trường hợp rỗng để tránh NullPointerException, còn try-with-resources và ngoại lệ riêng làm việc xử lý lỗi gọn và rõ. Cuối cùng, các thói quen tốt về đặt tên, bất biến và dùng công cụ build như Maven hay Gradle là thứ phân biệt mã chạy được với mã dễ bảo trì.