1. Vấn đề so sánh đối tượng
Nhắc lại: so sánh tham chiếu và so sánh đối tượng
Bạn đã biết rằng trong Java, toán tử == khi làm việc với đối tượng sẽ so sánh tham chiếu của chúng — tức là chúng có nằm ở cùng một địa chỉ bộ nhớ hay không. Hai đối tượng có các trường giống hệt nhau, nhưng được tạo bằng new, vẫn sẽ khác nhau đối với ==.
Person p1 = new Person("Sasha", 20);
Person p2 = new Person("Sasha", 20);
System.out.println(p1 == p2); // false — đây là các đối tượng khác nhau trong bộ nhớ!
Còn nếu muốn biết chúng bằng nhau theo nội dung hay không, ta dùng equals(), hashCode() ... bạn đã biết rồi. Nhưng nếu cần xác định ai “lớn tuổi hơn”, “nhỏ tuổi hơn”, “đứng trước theo bảng chữ cái” thì sao? Ví dụ, để sắp xếp danh sách người dùng theo tuổi hoặc tên.
Nhu cầu sắp xếp và tìm kiếm đối tượng
Giả sử chúng ta có danh sách người dùng và muốn sắp xếp họ theo tuổi:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
// Làm sao sắp xếp?
Collections.sort(people); // Ô kìa! Java không biết so sánh Person như thế nào!
Trình biên dịch sẽ lập tức báo lỗi: lớp Person chưa triển khai giao diện Comparable. Java không đọc được ý nghĩ và không biết “lớn hơn” hay “nhỏ hơn” đối với Person nghĩa là gì. Để dạy nó điều đó, chúng ta phải mô tả rõ ràng các quy tắc so sánh.
2. Giao diện Comparable
Khai báo giao diện
Giao diện Comparable là cách chuẩn để nói với Java: “Lớp của tôi có thể so sánh được, và đây là cách thực hiện”.
public interface Comparable<T> {
int compareTo(T o);
}
a.compareTo(b) quen thuộc sẽ trả về:
- số âm — nghĩa là a “nhỏ hơn” b.
- Nếu 0 — các đối tượng được coi là bằng nhau.
- số dương — a “lớn hơn” b.
Ví dụ: triển khai compareTo cho lớp Person
Hãy tạo lớp Person có thể so sánh theo tuổi:
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// Getter (cho các ví dụ bên dưới)
public String getName() { return name; }
public int getAge() { return age; }
// Triển khai phương thức compareTo
@Override
public int compareTo(Person other) {
// Sắp xếp theo tuổi (tăng dần)
return Integer.compare(this.age, other.age);
// Phương án khác: return this.age - other.age;
}
}
Điểm quan trọng: nếu bạn muốn sắp xếp giảm dần, chỉ cần đảo thứ tự đối số: Integer.compare(other.age, this.age).
Ẩn dụ. compareTo giống như một trọng tài trong cuộc thi: nó phải quyết định rõ ai đứng trước, ai đứng sau và ai ngang nhau. Nếu các “trọng tài” (các phương thức compareTo) phán xử khác nhau — sẽ thành hỗn loạn!
3. Sử dụng Comparable
Sắp xếp collection với Comparable
Bây giờ khi lớp của chúng ta đã triển khai Comparable, việc sắp xếp hoạt động “ngay lập tức”:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
Collections.sort(people); // Sử dụng compareTo!
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
// Petya (20)
// Vasya (25)
// Katya (30)
Tương tự, phương thức sort của danh sách cũng hoạt động:
people.sort(null); // Nếu truyền null, sẽ dùng compareTo
Sắp xếp theo tên
Nếu muốn sắp xếp theo tên — thay đổi triển khai:
@Override
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
Sắp xếp theo nhiều trường
Đôi khi cần so sánh theo một trường trước, nếu bằng nhau thì theo trường khác:
@Override
public int compareTo(Person other) {
int cmp = Integer.compare(this.age, other.age);
if (cmp != 0) return cmp;
return this.name.compareTo(other.name);
}
4. Thực hành tốt nhất khi triển khai Comparable
Tuân thủ hợp đồng (contract) của Comparable
- Nếu a.compareTo(b) == 0, thì b.compareTo(a) chắc chắn phải là 0.
- Nếu a.compareTo(b) < 0, thì b.compareTo(a) phải > 0 (và ngược lại).
- Nếu a.compareTo(b) == 0, nên để a.equals(b) là true (nhưng không bắt buộc tuyệt đối).
Vì sao điều này quan trọng?
Các collection (ví dụ, TreeSet, TreeMap) và các phương thức sắp xếp có thể hoạt động khó lường nếu contract bị vi phạm. Chẳng hạn, có thể xuất hiện “bản trùng lặp” trong collection vốn không cho phép điều đó.
Đừng quên equals và hashCode
Nếu bạn triển khai compareTo, hãy suy nghĩ: liệu equals và hashCode đã được triển khai đúng chưa? Đặc biệt nếu lớp của bạn sẽ được dùng trong các collection như HashSet hoặc Map.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person other = (Person) o;
return age == other.age && Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
Đừng dùng trong compareTo các trường có thể null mà không kiểm tra!
Nếu một trường có thể null, hãy dùng cách so sánh an toàn:
@Override
public int compareTo(Person other) {
return Objects.compare(this.name, other.name, Comparator.nullsFirst(String::compareTo));
}
Đừng thay đổi các trường tham gia vào compareTo nếu đối tượng đã nằm trong một collection đã sắp xếp
Điều này có thể khiến đối tượng bị “lạc” trong collection — ví dụ trong TreeSet hoặc TreeMap.
5. Phát triển ứng dụng học tập: sắp xếp người dùng
Bước 1: Mô tả lớp
public class Person implements Comparable<Person> {
private String name;
private int age;
// ... constructor, getter, compareTo, equals, hashCode ...
}
Bước 2: Thêm người dùng
List<Person> people = new ArrayList<>();
people.add(new Person("Masha", 23));
people.add(new Person("Grisha", 19));
people.add(new Person("Anya", 25));
Bước 3: Sắp xếp và in ra
Collections.sort(people);
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
Kết quả:
Grisha (19)
Masha (23)
Anya (25)
6. Sơ đồ hoạt động của Comparable
┌────────────────────────────┐
│ Lớp của bạn (Person) │
├────────────────────────────┤
│ implements Comparable │
│ ↓ │
│ public int compareTo(T o) │
│ ↓ │
│ (this < o) → -1 │
│ (this == o) → 0 │
│ (this > o) → 1 │
└────────────────────────────┘
│
▼
Collections.sort(list)
│
▼
Sắp xếp hoạt động!
7. Những điểm hữu ích
Collections.sort hoạt động như thế nào
- Nếu danh sách chứa các đối tượng triển khai Comparable, việc sắp xếp sẽ sử dụng phương thức compareTo của chúng.
- Nếu không triển khai — sẽ có lỗi biên dịch.
- Đối với các kiểu chuẩn (Integer, String và v.v.) thì Comparable đã được triển khai sẵn.
Có thể có nhiều cách so sánh không?
- Trong một lớp — chỉ có một “thứ tự tự nhiên” thông qua Comparable.
- Cho các thứ tự thay thế hãy dùng Comparator (bài giảng tiếp theo).
Ví dụ: compareTo cho chuỗi
String a = "apple";
String b = "banana";
System.out.println(a.compareTo(b)); // số âm, vì "apple" < "banana"
Bảng: compareTo trả về gì
| So sánh | Giá trị trả về |
|---|---|
|
|
|
|
|
|
8. Lỗi thường gặp khi triển khai Comparable
Lỗi số 1: Vi phạm contract của compareTo.
Nếu a.compareTo(b) trả về 0, còn b.compareTo(a) — không phải 0, các collection sẽ hoạt động kỳ lạ. Ví dụ, TreeSet có thể coi các đối tượng là khác nhau và thêm cả hai.
Lỗi số 2: Dùng các trường chưa khởi tạo (null).
Nếu trường bạn dùng để so sánh có thể null, mà bạn không kiểm tra — sẽ nhận NullPointerException.
Lỗi số 3: Không nhất quán giữa compareTo và equals.
Nếu compareTo nói rằng các đối tượng bằng nhau (0), còn equals — lại khác nhau (false), điều này sẽ gây lỗi khi làm việc với collection.
Lỗi số 4: Thay đổi các trường được dùng trong compareTo sau khi thêm vào collection đã sắp xếp.
Điều này giống như đổi họ trong hộ chiếu khi bạn đang xếp hàng theo thứ tự chữ cái. Collection có thể “đánh mất” đối tượng của bạn.
Lỗi số 5: Chỉ trả về -1, 0 hoặc 1.
Phương thức compareTo có thể trả về bất kỳ số âm hoặc dương nào, không nhất thiết chỉ -1 hoặc 1. Tuy nhiên, vì đơn giản người ta thường dùng -1/0/1.
GO TO FULL VERSION