Comparator và Comparable — vì sao Java cần 2 interface sắp xếp
Contract compareTo/compare, pitfall tràn số với a-b, NaN trong Double, inconsistency với equals trong TreeSet/TreeMap, và Comparator fluent API Java 8+.
TL;DR: Java có 2 interface sắp xếp vì lý do thiết kế rõ ràng: Comparable định nghĩa "thứ tự tự nhiên" bên trong class (1 thứ tự duy nhất), còn Comparator là hàm so sánh ngoài class (nhiều thứ tự tuỳ nhu cầu). Mỗi interface có contract 4 quy tắc (signum, transitivity, antisymmetry, consistency với equals). Vi phạm bất kỳ quy tắc nào dẫn đến TreeMap bị kẹt, sort trả kết quả sai, hoặc TreeSet nuốt mất phần tử. Bài này đi sâu vào cơ chế, 3 pitfall production-grade (tràn số, NaN, inconsistency với equals), và cách dùng Comparator.comparing fluent API của Java 8+.
1. Scenario — bug production khi dùng TreeSet
Hệ thống quản lý người dùng cần lưu danh sách User trong TreeSet để tự động sắp xếp theo tuổi. Developer viết class User implement Comparable<User> như sau:
public class User {
private String name;
private int age;
// Constructor, getters omitted for brevity
@Override
public int compareTo(User other) {
return Integer.compare(this.age, other.age);
}
}
Tất cả test đều pass. Nhưng khi deploy production, TreeSet<User> chỉ chứa đúng 1 User cho mỗi mức tuổi — 2 user cùng tuổi 25 chỉ giữ lại 1. Code không throw exception, không warning. Dữ liệu biến mất im lặng.
Để hiểu vì sao, cần nắm contract mà cả 2 interface đòi hỏi.
2. Comparable<T> — natural ordering
java.lang.Comparable<T> (có từ Java 1.2, JLS §4.10.2, Javadoc SE 21) là interface định nghĩa thứ tự tự nhiên (natural ordering) của một class — thứ tự mặc định khi không chỉ rõ cần sắp theo tiêu chí nào khác. Interface chỉ có 1 method:
public interface Comparable<T> {
int compareTo(T o);
}
Contract: compareTo trả số âm nếu this nhỏ hơn o, 0 nếu bằng nhau, số dương nếu this lớn hơn o.
Các class trong JDK đã implement Comparable: Integer, Long, Double, String, LocalDate, BigDecimal, Enum... Đây là lý do bạn có thể gọi Collections.sort(list) hoặc dùng TreeSet<Integer> mà không cần cung cấp gì thêm.
Đặc điểm quan trọng: mỗi class chỉ có một natural ordering. Nếu cần nhiều cách sắp xếp (theo tên, theo tuổi, theo ngày tạo), cần dùng Comparator.
3. Comparator<T> — external ordering
java.util.Comparator<T> (Java 1.2, Javadoc SE 21) là interface tách biệt khỏi class — định nghĩa thứ tự từ bên ngoài mà không cần sửa class gốc. Method chính:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
Contract: trả số âm nếu o1 nhỏ hơn o2, 0 nếu bằng, số dương nếu o1 lớn hơn o2.
Vì Comparator là @FunctionalInterface (Java 8+), bạn truyền nó dưới dạng lambda:
List<User> users = new ArrayList<>(List.of(
new User("An", 25),
new User("Binh", 20),
new User("Cuong", 30)
));
// Sort theo ten A-Z
users.sort(Comparator.comparing(User::getName));
// Sort theo tuoi tang dan
users.sort(Comparator.comparingInt(User::getAge));
// Sort theo tuoi giam dan
users.sort(Comparator.comparingInt(User::getAge).reversed());
4. Khi nào dùng Comparable, khi nào dùng Comparator?
| Tiêu chí | Comparable | Comparator |
|---|---|---|
| Vị trí code | Bên trong class | Ngoài class (separate class, lambda, method ref) |
| Số thứ tự | 1 duy nhất (natural) | Nhiều tuỳ ý |
| Yêu cầu sửa class | Có — implement interface | Không — class gốc không thay đổi |
| Khi nào dùng | Class do bạn viết, có 1 cách sort rõ ràng (String theo alphabet, Integer theo giá trị) | Cần nhiều cách sort, hoặc class của thư viện bên ngoài không thể sửa |
| Dùng với TreeSet/TreeMap | Tự động dùng natural ordering | Truyền vào constructor: new TreeSet<>(myComparator) |
Nếu có "thứ tự hiển nhiên" cho class (số, ngày, tên) — implement Comparable. Nếu cần sort theo nhiều tiêu chí khác nhau tuỳ context — tạo Comparator riêng. Effective Java item 14: "Consider implementing Comparable" — không phải "luôn implement".
5. Contract của compareTo và compare — 4 quy tắc
Cả Comparable.compareTo và Comparator.compare đều phải tuân 4 quy tắc (Javadoc SE 21, Effective Java item 14):
Quy tắc 1 — Signum antisymmetry:
sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
Nếu x so với y cho kết quả dương, thì y so với x phải cho âm — và ngược lại.
Quy tắc 2 — Transitivity:
Nếu x.compareTo(y) > 0 và y.compareTo(z) > 0 thì phải x.compareTo(z) > 0.
Quy tắc 3 — Consistency of zero:
Nếu x.compareTo(y) == 0 thì sgn(x.compareTo(z)) == sgn(y.compareTo(z)) với mọi z.
Quy tắc 4 — Consistency với equals (strongly recommended, không required):
(x.compareTo(y) == 0) == (x.equals(y))
Quy tắc 4 là "khuyến nghị mạnh" chứ không phải bắt buộc theo spec. Tuy nhiên vi phạm nó gây ra hành vi bất ngờ với TreeSet, TreeMap — sẽ thấy ở section 7.
6. Pitfall 1 — phép trừ a - b gây tràn số
Cách implement compareTo sai phổ biến nhất:
// SAI -- tran so khi a = Integer.MIN_VALUE, b = 1
@Override
public int compareTo(MyObject other) {
return this.value - other.value; // khong dung
}
Vì sao sai? Integer.MIN_VALUE - 1 trong Java (32-bit signed int) cho kết quả Integer.MAX_VALUE — tràn số dương thay vì số âm cực lớn. Sort nhận kết quả sai, không throw exception.
Ví dụ cụ thể:
int a = Integer.MIN_VALUE; // -2147483648
int b = 1;
int result = a - b; // truoc: -2147483649 -- but int khong chua duoc
System.out.println(result); // In ra: 2147483647 (Integer.MAX_VALUE) -- SAI HOAN TOAN
Cách đúng:
// DUNG -- dung Integer.compare, khong tran so
@Override
public int compareTo(MyObject other) {
return Integer.compare(this.value, other.value);
}
// Hoac voi Comparator:
Comparator<MyObject> comp = Comparator.comparingInt(MyObject::getValue);
Integer.compare(x, y) được implement trong JDK như sau (không phép trừ):
public static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
Tương tự dùng Long.compare, Double.compare, Float.compare cho các kiểu số tương ứng.
7. Pitfall 2 — Double.compare và NaN
So sánh số thực bằng a < b ? -1 : ... gặp bẫy với NaN (Not a Number):
double a = Double.NaN;
double b = 1.0;
System.out.println(a < b); // false
System.out.println(a > b); // false
System.out.println(a == b); // false
System.out.println(a == a); // false -- NaN khong bang chinh no!
Mọi phép so sánh với NaN đều trả false — kể cả NaN == NaN. Nếu tự viết comparator bằng a < b ? -1 : (a == b ? 0 : 1), phần tử NaN sẽ cho kết quả 1 bất kể so với ai → sort sai.
Cách đúng:
// DUNG -- Double.compare xu ly NaN dung spec
@Override
public int compareTo(MyObj other) {
return Double.compare(this.score, other.score);
}
Double.compare theo IEEE 754 Total Order (JDK spec): NaN được coi là lớn hơn mọi giá trị khác, và NaN == NaN trong ngữ cảnh sort. Hành vi này nhất quán và không gây loop trong TreeMap.
System.out.println(Double.compare(Double.NaN, 1.0)); // 1 (NaN > 1.0)
System.out.println(Double.compare(Double.NaN, Double.NaN)); // 0 (nhat quan)
System.out.println(Double.compare(1.0, Double.NaN)); // -1 (1.0 < NaN)
8. Pitfall 3 — compareTo inconsistent với equals: TreeSet nuốt phần tử
Quay lại scenario mở đầu. TreeSet và TreeMap không dùng equals() để kiểm tra "phần tử đã tồn tại chưa" — chúng dùng compareTo() (hoặc Comparator.compare). Nếu compareTo trả 0, coi là "bằng nhau" và phần tử mới bị bỏ qua.
TreeSet<User> set = new TreeSet<>();
User u1 = new User("An", 25);
User u2 = new User("Binh", 25); // tuoi giong u1
set.add(u1);
set.add(u2);
System.out.println(set.size()); // In: 1 -- "Binh" bi nuot!
System.out.println(set); // [User{name=An, age=25}]
Vì compareTo chỉ so tuổi → u1.compareTo(u2) == 0 → TreeSet hiểu đây là phần tử trùng nhau → từ chối thêm u2.
Trong khi đó HashSet dùng hashCode() + equals() — nếu equals so theo tên lẫn tuổi thì 2 user khác tên vẫn được lưu cả 2.
Khi compareTo trả 0 nhưng equals trả false, Javadoc gọi đây là "inconsistent with equals". TreeSet/TreeMap hành xử khác HashSet/HashMap. Spec không cấm điều này (quy tắc 4 chỉ là "strongly recommended") nhưng cần ghi rõ trong Javadoc của class. Ví dụ: BigDecimal intentionally inconsistent — new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0 nhưng equals trả false vì scale khác.
Ví dụ BigDecimal inconsistency:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.compareTo(b)); // 0 -- bang nhau ve gia tri
System.out.println(a.equals(b)); // false -- scale khac nhau (1 vs 2)
TreeSet<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(a);
treeSet.add(b);
System.out.println(treeSet.size()); // 1 -- TreeSet dung compareTo
HashSet<BigDecimal> hashSet = new HashSet<>();
hashSet.add(a);
hashSet.add(b);
System.out.println(hashSet.size()); // 2 -- HashSet dung equals
Fix cho class User: nếu muốn TreeSet lưu được cả 2 user cùng tuổi, phải tạo compareTo phân biệt được cả tên lẫn tuổi, hoặc truyền Comparator riêng vào TreeSet:
// Fix 1: compareTo phan biet ca ten va tuoi
@Override
public int compareTo(User other) {
int cmpAge = Integer.compare(this.age, other.age);
if (cmpAge != 0) return cmpAge;
return this.name.compareTo(other.name); // phan biet khi cung tuoi
}
// Fix 2: dung Comparator khi tao TreeSet
TreeSet<User> set = new TreeSet<>(
Comparator.comparingInt(User::getAge).thenComparing(User::getName)
);
9. Java 8+ — Comparator fluent API
Java 8 bổ sung nhiều static/default method vào Comparator (JEP 101, "Functional Interface Improvements"), giúp viết comparator phức tạp mà không cần anonymous class:
// So sanh theo nhieu tieu chi theo thu tu uu tien
Comparator<Employee> byDeptThenSalaryDesc =
Comparator.comparing(Employee::getDepartment) // tieu chi 1: phong ban A-Z
.thenComparingInt(Employee::getYearsExp) // tieu chi 2: kinh nghiem tang
.thenComparingDouble(Employee::getSalary) // tieu chi 3: luong tang
.reversed(); // dao nguoc toan bo
List<Employee> employees = getEmployees();
employees.sort(byDeptThenSalaryDesc);
Danh sách các method hữu ích nhất:
| Method | Mô tả |
|---|---|
Comparator.comparing(keyExtractor) | Sort theo key dạng Comparable (String, Integer...) |
Comparator.comparingInt/Long/Double | Sort theo key primitive, tránh autoboxing |
.thenComparing(keyExtractor) | Tiebreak khi so sánh bằng nhau |
.reversed() | Đảo ngược thứ tự |
Comparator.naturalOrder() | Dùng natural ordering (Comparable) |
Comparator.reverseOrder() | Ngược natural ordering |
Comparator.nullsFirst(comp) | null xếp trước |
Comparator.nullsLast(comp) | null xếp sau |
Ví dụ thực tế — sort list nullable:
List<String> names = Arrays.asList("An", null, "Binh", null, "Cuong");
// null xep cuoi, con lai theo alphabet
names.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(names); // [An, Binh, Cuong, null, null]
9.1 Vì sao tránh Comparator.comparing(User::getAge) với kiểu primitive?
User::getAge trả int. Comparator.comparing nhận Function<T, U> trong đó U extends Comparable<U> — compiler phải autobox int thành Integer. Dùng Comparator.comparingInt(User::getAge) nhận ToIntFunction<T> — không autobox, performance tốt hơn khi sort list lớn.
10. Vì sao Java có 2 interface, không gộp làm 1?
Câu hỏi hay: vì sao không chỉ có Comparable và sort() luôn lấy compareTo từ đó?
Lý do thiết kế (Effective Java, Bloch):
-
Một class có nhiều thứ tự hợp lệ.
Stringcó natural ordering alphabetical, nhưng đôi khi cần sort theo length, hoặc case-insensitive, hoặc locale-aware. NếuComparablechỉ có 1, các thứ tự phụ không có chỗ. -
Class không thể sửa.
java.lang.Stringtrong JDK không thể thêmcompareTomới.Comparatorcho phép tạo thứ tự ngoài mà không cần quyền sửa class. -
Separation of concerns. Thứ tự là concern của thuật toán/context, không phải luôn là concern của domain object.
Userkhông nhất thiết "biết" nó cần sort theo tuổi, tên hay ngày đăng ký.
11. 📚 Deep Dive
ComparableJavadoc SE 21 — docs.oracle.com/.../Comparable.html — quote đầy đủ 4 contract rules và lưu ý về consistency vớiequals.ComparatorJavadoc SE 21 — docs.oracle.com/.../Comparator.html — danh sách đầy đủ các static/default method Java 8+.- Effective Java item 14 (Joshua Bloch, 3rd edition) — "Consider implementing
Comparable" — giải thích contract chi tiết, khi nào không nên implement, cách viết đúng tránh tràn số. - JLS §4.10.2 — "Subtyping among Class and Interface Types" — nền tảng type system, liên quan đến tại sao
Comparable<T>dùng generic thay vì raw type. - IEEE 754 — tiêu chuẩn số thực, định nghĩa NaN và total ordering dùng bởi
Double.compare.
12. Self-check
Q1`TreeSet<User>` lưu ít hơn số user bạn add vào. Cách debug đầu tiên là gì?▸
Q2Vì sao `return this.value - other.value` trong `compareTo` là sai dù hoạt động đúng với hầu hết test case?▸
this > other — sai hoàn toàn. Test thường dùng giá trị nhỏ, không phát hiện. Production có thể gặp giá trị biên → sort corrupt. Cách đúng: `Integer.compare(this.value, other.value)` không dùng phép trừ.Q3Bạn cần sort `List<Double>` có thể chứa `Double.NaN`. Cách nào đúng?▸
Comparator.comparingDouble(x -> x) hoặc `Double::compare`. `Double.compare` implement Total Order theo IEEE 754: `NaN` được coi là lớn hơn mọi giá trị kể cả `Double.POSITIVE_INFINITY`, và bằng chính nó. Đây là ordering nhất quán. Nếu tự viết (a, b) -> a < b ? -1 : (a == b ? 0 : 1), `NaN` luôn trả 1 bất kể đối số → vi phạm antisymmetry (`compare(NaN, NaN)` phải 0 nhưng trả 1) → `Arrays.sort` có thể vào vòng lặp vô hạn hoặc trả kết quả sai.Q4`new TreeSet<BigDecimal>()` vs `new HashSet<BigDecimal>()` — 2 set này có cùng behavior khi add `new BigDecimal("1.0")` và `new BigDecimal("1.00")` không?▸
Q5Viết `Comparator` sort `List<String>` theo độ dài chuỗi tăng dần, nếu bằng độ dài thì theo alphabet. Xử lý `null` bằng cách xếp cuối.▸
Q6Vì sao `Comparator` là `@FunctionalInterface` còn `Comparable` thì không?▸
Bài tiếp theo
Bài 08 sẽ đi sâu immutable collections — List.of, Map.copyOf, và Collections.unmodifiableList. Ba cách khác nhau về nghĩa "bất biến", khi nào view trên list gốc thay đổi mà bạn không hay, và pattern defensive copy để tránh caller mutate state bên trong class.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên