Nghĩ về việc xếp hạng thí sinh thì hai công cụ so sánh của Java sáng ra ngay. Comparable là thí sinh tự mang số báo danh — mỗi người một thứ hạng bẩm sinh, và cả cuộc thi chỉ có một cách xếp. Comparator là ban giám khảo cầm thang điểm đứng bên ngoài — bạn mời được bao nhiêu giám khảo tuỳ thích, người chấm theo chiều cao, người chấm theo tuổi, cho cùng một đám thí sinh. Phân biệt hai thứ này thì đơn giản. Nhưng cuối bài có một cái bẫy 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 — và ít người viết Java lâu năm từng gặp.

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.

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.

Đừng bao giờ viết 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.

Muốn tự tay thấy cái bẫy thì chạy đoạn BigDecimal ở trê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 ra sao. 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.

Mẫu số chung

Cặp Comparable/Comparator — "thứ tự bẩm sinh của một kiểu" so với "quy tắc so sánh mang từ ngoài vào" — không phải nét riêng của Java. Mọi ngôn ngữ cần sắp xếp đều có đúng hai khái niệm này, và cả cái bẫy "so-sánh không khớp bằng-nhau" cũng theo sang.

  • Rust tách rất rõ và bắt trình biên dịch canh: Ord/PartialOrd (thứ tự tự nhiên, như Comparable) và Eq/PartialEq (bằng nhau, như equals) là các trait riêng, và quy ước bắt buộc là Ord phải nhất quán với Eq. Dùng #[derive(Ord, Eq)] thì hai cái tự sinh khớp nhau — đúng thứ giúp bạn tránh cái bẫy BigDecimal; muốn xếp theo tiêu chí khác thì truyền closure vào sort_by_key, chính là Comparator.comparing.
  • Python có __lt__ (như compareTo) và __eq__/__hash__ (như equals/hashCode), và có thể làm chúng lệch nhau y hệt Java. Cách xếp nhiều khoá thì gọn tới bất ngờ: sorted(ds, key=...) với một tuple làm khoá — key=lambda x: (x.phong, -x.luong, x.ten) chính là chuỗi thenComparing viết bằng một dòng.
  • Go dùng hàm less truyền vào sort.Slice (đúng một Comparator dạng lambda), và từ Go 1.21 có cmp.Compare cùng slices.SortFunc cho gọn. C# có IComparable và IComparer — trùng tên ý niệm với Java tới từng chữ.

Sợi chỉ chung đáng mang theo: "bằng nhau" và "cùng thứ hạng" là hai câu hỏi khác nhau, và trộn lẫn chúng là nguồn của những con bug im lặng nhất — một tập hợp dựa trên "bằng nhau" (băm) và một tập hợp dựa trên "thứ hạng" (cây) sẽ bất đồng về việc hai phần tử có phải là một hay không, ở bất kỳ ngôn ngữ nào. Quy tắc vàng giống nhau khắp nơi: nếu tự định nghĩa thứ tự cho một kiểu, hãy để nó nhất quán với định nghĩa bằng-nhau của kiểu đó; và khi buộc phải cho chúng lệch nhau (như BigDecimal), hãy biết chính xác cấu trúc dữ liệu nào đang hỏi câu nào.

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ớ.