Sắp xếp là việc ai cũng làm hằng ngày, và cũng là chỗ Java có hai công cụ dễ nhầm lẫn: Comparable và Comparator.
Phân biệt thì đơn giản. Nhưng cuối bài có một cái bẫy mà tôi nghĩ nhiều người viết Java lâu năm cũng chưa gặp — nó khiến cùng một cặp dữ liệu cho ra kích thước khác nhau tuỳ bạn dùng HashSet hay TreeSet.
Hai công cụ, hai vai trò
Comparable — đối tượng tự biết cách so sánh với đồng loại. Thứ tự này gọi là "thứ tự tự nhiên", và mỗi lớp chỉ có một.
static class Tuoi implements Comparable<Tuoi> {
final int nam;
@Override public int compareTo(Tuoi o) {
return Integer.compare(nam, o.nam);
}
}
[20 tuổi, 30 tuổi, 40 tuổi]
Comparator — một quy tắc so sánh nằm bên ngoài. Bao nhiêu quy tắc cũng được, cho cùng một lớp.
Chọn cái nào? Nếu lớp có một thứ tự hiển nhiên mà ai cũng đồng ý — số theo giá trị, chuỗi theo bảng chữ cái, ngày theo thời gian — thì Comparable. Còn lại, và nhất là khi lớp không phải của bạn, thì Comparator.
Giá trị trả về của compareTo chỉ có ba nghĩa: âm là "tôi đứng trước", không là "bằng nhau", dương là "tôi đứng sau". Con số cụ thể không quan trọng.
return this.gia - o.gia;. Với hai số cách nhau quá lớn, phép trừ tràn số nguyên và trả về dấu ngược — đúng cái bẫy tràn số ở bài về toán tử. Luôn dùng Integer.compare(a, b) hoặc Long.compare.
Sắp xếp theo nhiều khoá
Đây là chỗ Comparator toả sáng. Cho một danh sách nhân viên, sắp theo phòng, trong mỗi phòng lương giảm dần, lương bằng nhau thì theo tên:
ds.sort(Comparator.comparing(NhanVien::phong)
.thenComparing(Comparator.comparingLong(NhanVien::luong).reversed())
.thenComparing(NhanVien::ten));
Kinh doanh 30,000,000 Lan
Kinh doanh 30,000,000 Vy
Kỹ thuật 25,000,000 Minh
Kỹ thuật 22,000,000 Hải
Đọc như một câu tiếng Việt. So với cách viết tay ngày xưa:
ds.sort((a, b) -> {
int kq = a.phong().compareTo(b.phong());
if (kq != 0) return kq;
kq = Long.compare(b.luong(), a.luong());
if (kq != 0) return kq;
return a.ten().compareTo(b.ten());
});
Cùng kết quả, nhưng bản trên khó đọc hơn và rất dễ sai — chỉ cần quên một dòng if (kq != 0) là toàn bộ tiêu chí sau bị bỏ qua.
Vài phương thức đáng thuộc:
| Cách viết | Việc |
|---|---|
Comparator.comparing(X::ten) |
So theo một trường |
.thenComparing(X::tuoi) |
Tiêu chí phụ khi bằng nhau |
.reversed() |
Đảo ngược toàn bộ chuỗi trước đó |
Comparator.comparingInt/Long/Double |
Tránh đóng hộp, nhanh hơn |
Comparator.naturalOrder() |
Dùng Comparable sẵn có |
Comparator.nullsFirst/nullsLast(c) |
Cho phép null |
Chú ý chỗ dễ sai nhất: .reversed() đảo cả chuỗi phía trước nó, không phải chỉ tiêu chí liền kề. Nên ở ví dụ trên tôi phải bọc riêng phần lương — Comparator.comparingLong(...).reversed() — rồi mới đưa vào thenComparing. Viết ...thenComparingLong(NhanVien::luong).reversed() sẽ đảo ngược cả phòng lẫn lương, không phải ý muốn.
Xử lý null
Comparator.comparing gặp null là ném NPE. Bọc lại thì xong:
coNull.sort(Comparator.nullsLast(Comparator.naturalOrder()));
[a, b, c, null, null]
Với trường có thể null bên trong đối tượng thì viết:
Comparator.comparing(NhanVien::phong, Comparator.nullsLast(Comparator.naturalOrder()))
Dạng hai tham số này ít người biết, mà lại đúng thứ cần khi dữ liệu đến từ cơ sở dữ liệu.
Cái bẫy: compareTo không nhất quán với equals
Giờ tới phần chính.
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
Hai giá trị này có bằng nhau không? Câu trả lời là tuỳ bạn hỏi ai:
a.equals(b) : false
a.compareTo(b)==0: true
HashSet size = 2 (dùng equals)
TreeSet size = 1 (dùng compareTo)
Cùng hai đối tượng, cho vào hai loại Set khác nhau, ra hai kích thước khác nhau.
BigDecimal.equals so cả số chữ số thập phân, nên 1.0 khác 1.00. Còn compareTo chỉ so giá trị, nên hai số bằng nhau.
Hệ quả: HashSet dùng equals nên giữ cả hai. TreeSet dùng compareTo nên coi chúng là một và vứt đi một phần tử.
Đây là lỗi rất khó tìm, vì nó chỉ xuất hiện khi có ai đó đổi từ HashSet sang TreeSet — một thay đổi trông vô hại, thường làm để có thứ tự khi hiển thị. Dữ liệu bắt đầu mất mà không ai hiểu vì sao.
Tài liệu Java gọi đây là "thứ tự tự nhiên không nhất quán với equals", và khuyến nghị rất rõ: hãy làm cho compareTo trả về 0 đúng khi và chỉ khi equals trả về true. BigDecimal cố tình vi phạm và ghi rõ điều đó trong tài liệu — vì với tiền tệ, phân biệt 1.0 và 1.00 đôi khi có ý nghĩa.
Nên khi tự viết Comparable, hãy giữ nhất quán. Còn khi dùng TreeSet hay TreeMap với BigDecimal, hãy chuẩn hoá trước bằng stripTrailingZeros().
Một hệ quả nữa của cùng nguyên nhân: TreeSet.contains() không gọi equals mà gọi compareTo. Nếu comparator của bạn chỉ so một trường, thì mọi đối tượng cùng trường đó đều bị coi là "đã có".
toString: nhỏ mà lời nhất
Bài này gộp toString vào cùng chỗ vì cả ba đều là những phương thức "lịch sự" mà lớp nào cũng nên có.
Không có toString, log in ra NhanVien@778d1062. Có nó, mọi dòng log và mọi lần dừng ở breakpoint đều đọc được. Ba dòng mã đổi lấy hàng giờ gỡ lỗi — tôi chưa thấy khoản đầu tư nào lời hơn.
Vài nguyên tắc:
Đưa vào những trường giúp nhận diện, không cần tất cả. Với đơn hàng thì mã đơn và trạng thái là đủ; danh sách trăm mặt hàng thì không.
Đừng đưa mật khẩu, token, số thẻ vào. Log thường được gom về nơi nhiều người đọc hơn bạn nghĩ.
Đừng để toString ném ngoại lệ. Nó hay được gọi trong lúc xử lý lỗi, và một NPE ở đó sẽ che mất lỗi gốc.
Với lớp thuần dữ liệu, dùng record — nó sinh sẵn toString đủ dùng:
record NhanVien(String ten, String phong, int tuoi, long luong) {}
// NhanVien[ten=Minh, phong=Kỹ thuật, tuoi=30, luong=25000000]
Sắp xếp ổn định và tại chỗ
Hai chi tiết đáng biết về sort của Java:
Sắp xếp đối tượng là ổn định — hai phần tử được coi là bằng nhau thì giữ nguyên thứ tự tương đối ban đầu. Nhờ vậy bạn sắp xếp nhiều lượt được: sắp theo tên trước, rồi sắp theo phòng, kết quả là trong mỗi phòng vẫn theo thứ tự tên.
Sắp xếp mảng số nguyên thuỷ thì không ổn định — nó dùng quicksort kép, còn mảng đối tượng dùng TimSort. Với số nguyên thuỷ thì "ổn định" cũng không có nghĩa gì, vì hai số bằng nhau là không phân biệt được.
Còn list.sort(...) sửa chính danh sách đó. Muốn giữ nguyên bản gốc thì ds.stream().sorted(cmp).toList() — chuyện của chặng Stream.
Thử ba mươi giây
Chạy đoạn BigDecimal ở trên trên máy bạn. Rồi thử thêm new BigDecimal("1.000") vào cả hai set và đoán kích thước trước khi in.
Sau đó đổi TreeSet thành TreeSet<>(Comparator.comparing(BigDecimal::toPlainString)) và xem kết quả đổi thế nào. Bài học đọng lại: Set không có một định nghĩa "bằng nhau" duy nhất — nó phụ thuộc vào bản cài đặt bạn chọn.
Ngày mai: lớp lồng, lớp nội và lớp ẩn danh — bốn loại, cái nào giữ tham chiếu ngầm tới đối tượng bao ngoài, và vì sao điều đó gây rò rỉ bộ nhớ.