Comparator và Comparable — thứ tự sắp xếp và hợp đồng so sánh
Hợp đồng compareTo và compare, bẫy tràn số phép trừ, tính nhất quán với equals trong TreeSet và kỹ thuật chaining Comparator trong quản lý ấn phẩm.
TL;DR: Java phân tách việc sắp xếp thành hai interface rõ rệt: Comparable<T> định nghĩa "thứ tự tự nhiên" (natural ordering) duy nhất gắn liền bên trong class thông qua compareTo(), trong khi Comparator<T> đóng vai trò chiến lược so sánh bên ngoài độc lập (external strategy) qua compare(). Bất kỳ hàm so sánh nào cũng phải tuân thủ nghiêm ngặt 4 quy tắc toán học (phản đối xứng, bắc cầu, bảo toàn dấu và nhất quán với equals). Vi phạm hợp đồng so sánh—chẳng hạn như dùng phép trừ a - b gây tràn số nguyên hoặc bỏ qua tính nhất quán với equals()—sẽ khiến TreeSet âm thầm nuốt mất phần tử hoặc làm thuật toán TimSort ném ngoại lệ ở runtime.
1. Vì sao TreeSet lại nuốt mất ấn phẩm khi chỉ so sánh theo năm xuất bản?
Xét tình huống thực tế trong hệ thống thư viện: Bạn cần lưu trữ danh mục sách vào một TreeSet để tự động sắp xếp theo năm xuất bản. Nhà phát triển tạo lớp CatalogBook cài đặt Comparable<CatalogBook>:
public class CatalogBook implements Comparable<CatalogBook> {
private final String isbn;
private final String title;
private final int publishYear;
public CatalogBook(String isbn, String title, int publishYear) {
this.isbn = isbn;
this.title = title;
this.publishYear = publishYear;
}
@Override
public int compareTo(CatalogBook other) {
return Integer.compare(this.publishYear, other.publishYear);
}
}
Mọi bài kiểm thử đơn lẻ đều vượt qua. Nhưng khi đưa vào vận hành thực tế, khi nạp 1.000 cuốn sách vào TreeSet<CatalogBook>, tập hợp chỉ còn lại chưa tới 50 cuốn! Tất cả các cuốn sách xuất bản trong cùng một năm đều bị loại bỏ chỉ còn giữ lại đúng 1 cuốn duy nhất.

Nguyên nhân: TreeSet định vị và kiểm tra sự tồn tại của phần tử chỉ thông qua compareTo(). Khi compareTo() trả về 0, TreeSet kết luận rằng phần tử này đã có sẵn trong cây và từ chối thêm vào (add() trả về false). Trên toàn bộ quá trình, phương thức equals() không hề được gọi tới.
2. Khi nào nên cài đặt thứ tự tự nhiên với Comparable?
Interface java.lang.Comparable<T> được thiết kế cho các lớp có một trật tự hiển nhiên duy nhất (Natural Ordering):
public interface Comparable<T> {
int compareTo(T o);
}
Hợp đồng quy ước giá trị trả về của compareTo(o):
- Số âm (
< 0): Đối tượng hiện tại nhỏ hơno. - Số 0 (
== 0): Hai đối tượng tương đương nhau về thứ tự sắp xếp. - Số dương (
> 0): Đối tượng hiện tại lớn hơno.
Hầu hết các lớp cơ bản trong JDK như String (theo bảng chữ cái), Integer (theo độ lớn số học), LocalDate (theo trình tự thời gian) đều cài đặt Comparable. Với ấn phẩm thư viện, mã tiêu chuẩn quốc tế ISBN là ứng viên lý tưởng cho thứ tự tự nhiên:
@Override
public int compareTo(CatalogBook other) {
return this.isbn.compareTo(other.isbn);
}
3. Khi nào nên linh hoạt định nghĩa thứ tự ngoài với Comparator?
Trong thực tế quản lý kho sách, thủ thư không chỉ tìm kiếm theo ISBN mà còn cần sắp xếp theo nhiều góc độ khác nhau: theo số lượt mượn giảm dần, theo độ dày sách, hoặc theo tên tác giả. java.util.Comparator<T> cho phép tách biệt logic so sánh ra khỏi cấu trúc đối tượng:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
Bởi vì Comparator là một @FunctionalInterface, ta có thể truyền nó linh hoạt dưới dạng Lambda hoặc Method Reference:
List<CatalogBook> books = getCatalogList();
// Sort by checkout frequency descending:
books.sort((b1, b2) -> Integer.compare(b2.getCheckoutCount(), b1.getCheckoutCount()));
// Initialize TreeSet with safe composite Comparator:
Comparator<CatalogBook> byYearThenIsbn = Comparator
.comparingInt(CatalogBook::getPublishYear)
.thenComparing(CatalogBook::getIsbn);
Set<CatalogBook> sortedShelf = new TreeSet<>(byYearThenIsbn);
| Tiêu chí | Comparable<T> | Comparator<T> |
|---|---|---|
| Vị trí định nghĩa | Nằm bên trong chính class cần so sánh | Định nghĩa bên ngoài class (lambda, hàm độc lập) |
| Số lượng thứ tự | Chỉ có 1 thứ tự tự nhiên duy nhất | Có thể tạo vô số tiêu chí so sánh |
| Yêu cầu quyền sửa mã | Bắt buộc sửa class nguồn | Không cần can thiệp vào mã nguồn gốc |
| Ứng dụng điển hình | Định danh tự nhiên (ID, Mã vạch, Ngày tháng) | Báo cáo, lọc bảng dữ liệu, đa tiêu chí sắp xếp |
4. Bốn quy tắc trong hợp đồng toán học của phép so sánh là gì?
Cả compareTo() lẫn compare() đều phải thỏa mãn 4 quy tắc toán học khắt khe để bảo đảm thuật toán sắp xếp (như TimSort của Java) không bị sụp đổ:
- Quy tắc dấu đối xứng:
sgn(compare(x, y)) == -sgn(compare(y, x)). - Quy tắc bắc cầu (Transitivity): Nếu $x > y$ và $y > z$ thì bắt buộc $x > z$.
- Quy tắc nhất quán tương đương: Nếu $compare(x, y) == 0$, thì
sgn(compare(x, z)) == sgn(compare(y, z))với mọi $z$. - Tính nhất quán với
equals()(Consistency with Equals): Khuyến nghị mạnh mẽ rằng $(compare(x, y) == 0) \iff (x.equals(y))$.
Nếu vi phạm quy tắc bắc cầu hoặc phản đối xứng, Arrays.sort() hoặc Collections.sort() sẽ ném ra lỗi IllegalArgumentException: Comparison method violates its general contract!.
5. Vì sao phép trừ a - b lại là cạm bẫy gây tràn số kinh điển?
Nhiều lập trình viên có thói quen viết tắt:
// Dangerous anti-pattern:
@Override
public int compareTo(CatalogBook other) {
return this.publishYear - other.publishYear; // Never use subtraction here!
}
Phép trừ số nguyên 32-bit trong Java có thể gặp hiện tượng Tràn số (Integer Overflow):
- Giả sử
this.publishYear = Integer.MIN_VALUE($-2.147.483.648$) vàother.publishYear = 1. - Kết quả phép toán: $-2.147.483.648 - 1 = +2.147.483.647$ (giá trị bị đảo sang số dương cực lớn do tràn bit).
- Hàm so sánh báo rằng số cực âm lại "lớn hơn" số dương! Thứ tự sắp xếp sẽ bị phá hủy hoàn toàn.
✅ Giải pháp chuẩn: Luôn dùng phương thức tiện ích tĩnh của kiểu đóng gói:
return Integer.compare(this.publishYear, other.publishYear);
6. Xử lý giá trị Double.NaN và tính nhất quán với equals() ra sao?
Khi so sánh trọng lượng hoặc phí phạt quá hạn (double/float), toán tử so sánh thông thường < hoặc > sẽ sụp đổ khi gặp giá trị Double.NaN:
- Theo chuẩn IEEE 754,
Double.NaN < x,Double.NaN > x, vàDouble.NaN == xđều trả vềfalse. - Hàm so sánh tự viết sẽ vi phạm quy tắc phản đối xứng vì
compare(NaN, NaN)không trả về 0.
✅ Giải pháp: Sử dụng Double.compare(d1, d2) để bảo đảm Total Ordering chuẩn IEEE 754 (coi NaN là giá trị lớn nhất và bằng chính nó).
Khác biệt giữa BigDecimal trong HashSet và TreeSet
Một ví dụ kinh điển về việc không nhất quán với equals() trong chính JDK là BigDecimal:
new BigDecimal("1.0").equals(new BigDecimal("1.00"))trả vềfalse(vì khác độ chính xác scale: 1 so với 2).new BigDecimal("1.0").compareTo(new BigDecimal("1.00"))trả về0(vì bằng nhau về giá trị toán học).
Hệ quả: HashSet sẽ chứa cả 2 phần tử, trong khi TreeSet sẽ coi chúng là một và chỉ giữ lại 1 phần tử!
7. Xây dựng chuỗi so sánh phức tạp bằng Java 8 Fluent Comparator như thế nào?
Từ Java 8, interface Comparator bổ sung bộ công cụ Fluent API giúp viết các tiêu chí sắp xếp phức hợp vô cùng thanh lịch:
Comparator<CatalogBook> complexComparator = Comparator
// Criteria 1: Publication year descending
.comparingInt(CatalogBook::getPublishYear).reversed()
// Criteria 2: Title ascending (case-insensitive)
.thenComparing(CatalogBook::getTitle, String.CASE_INSENSITIVE_ORDER)
// Criteria 3: Unique ISBN tie-breaker
.thenComparing(CatalogBook::getIsbn);
Xử lý phần tử null an toàn
Khi danh sách có thể chứa các giá trị null:
// Place null elements at the end of sorted list
Comparator<CatalogBook> nullSafeComparator = Comparator.nullsLast(
Comparator.comparing(CatalogBook::getTitle)
);
[!TIP] Sử dụng các hàm chuyên biệt cho kiểu nguyên thủy như
comparingInt(),comparingLong(),comparingDouble()thay vìcomparing()để ngăn ngừa chi phí đóng gói tự động (Autoboxing) khi sắp xếp danh sách lớn.
8. Đọc thêm từ tài liệu Oracle và các liên kết liên quan
- Oracle JavaDoc: Comparable: Quy định 4 điều kiện của hợp đồng so sánh tự nhiên.
- Oracle JavaDoc: Comparator: Toàn bộ các phương thức mở rộng của Comparator trong Java 8+.
- Effective Java (Joshua Bloch), Item 14: Consider implementing Comparable.
- Bài 06 — Iterator và CME: Xử lý an toàn khi duyệt tập hợp.
9. Tóm tắt các điểm mấu chốt
Comparableđịnh hình thứ tự tự nhiên của đối tượng;Comparatorđịnh nghĩa chiến lược so sánh tùy biến bên ngoài.TreeSetvàTreeMapxác định tính duy nhất dựa hoàn toàn vàocompareTo()hoặccompare(), không dùngequals().- Tránh xa phép trừ
a - bkhi so sánh số; luôn dùngInteger.compare(),Long.compare(), hoặcDouble.compare(). - Với các tập hợp sắp xếp, luôn bảo đảm tính nhất quán giữa
compareTo()vàequals(), hoặc bổ sung tie-breaker để không làm thất lạc dữ liệu. - Tận dụng
Comparator.comparingInt()và.thenComparing()để xây dựng các chuỗi so sánh đa tầng không tốn chi phí autoboxing.
10. Câu hỏi tự kiểm tra
- Q1Vì sao TreeSet<Book> chỉ lưu lại một cuốn sách duy nhất cho mỗi năm xuất bản khi class Book chỉ so sánh trường publishYear trong compareTo()?
- Q2Tại sao phép toán 'return this.id - other.id' trong compareTo() lại bị coi là cạm bẫy nguy hiểm?
- Q3Tại sao cần dùng Double.compare() thay vì dùng các toán tử so sánh thông thường (<, >) khi so sánh số thực double?
- Q4Khác biệt về hành vi giữa HashSet và TreeSet khi thêm hai đối tượng new BigDecimal("1.0") và new BigDecimal("1.00") là gì?
- Q5Lợi ích của việc dùng Comparator.comparingInt() so với Comparator.comparing() khi trích xuất khóa kiểu int là gì?
Bài tiếp theo: Immutable Collections — List.of, Set.of và unmodifiableList
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Iterator, fail-fast và ConcurrentModificationException
Bài tiếpImmutable Collections — List.of và unmodifiableList
Lộ trình Junior Java Backend Developer
Chặng 5/21 · Generics & Collections — bài 7/10 của chặng
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Thảo luận & góp ý
Hỏi đáp về bài này