1. 物件比較的問題
提醒:參考相等與物件比較
你應該已經知道,在 Java 中,運算子 == 用在物件時,比較的是參考(reference)——也就是它們是否位於記憶體中的同一個位址。兩個欄位相同、但透過 new 建立的物件,對 == 來說仍是不同。
Person p1 = new Person("Sasha", 20);
Person p2 = new Person("Sasha", 20);
System.out.println(p1 == p2); // false —— 這是記憶體中不同的物件!
若想知道它們的內容是否相等,會使用 equals()、hashCode() ……這些你都熟悉了。那麼,如果我們需要判斷誰「年長」、誰「年幼」、或是誰「字典序更前」呢?例如,把使用者清單依年齡或姓名排序。
為何需要對物件進行排序與搜尋
假設我們有一個使用者清單,並希望依年齡排序:
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); // 糟了!Java 不知道如何比較 Person!
編譯器會立刻抱怨:類別 Person 沒有實作介面 Comparable。Java 不會讀心術,也不知道對於 Person 而言,「大於」或「小於」是什麼意思。要教會它,我們必須明確描述比較規則。
2. Comparable 介面
介面宣告
Comparable 介面是向 Java 宣告:「我的類別可以被比較,而且比較規則如下」的標準方式。
public interface Comparable<T> {
int compareTo(T o);
}
熟悉的 a.compareTo(b) 會回傳:
- 負數 —— 表示 a「小於」b。
- 若為 0 —— 物件視為相等。
- 正數 —— a「大於」b。
範例:為 Person 實作 compareTo
我們來建立一個可依年齡比較的 Person 類別:
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(供後續範例使用)
public String getName() { return name; }
public int getAge() { return age; }
// compareTo 方法的實作
@Override
public int compareTo(Person other) {
// 依年齡排序(遞增)
return Integer.compare(this.age, other.age);
// 替代寫法:return this.age - other.age;
}
}
重要重點:如果你想要遞減排序,只要對調參數即可:Integer.compare(other.age, this.age)。
類比。compareTo 就像比賽的裁判:它必須清楚判定誰在前、誰在後、誰並列。如果每位「裁判」(各個 compareTo 方法)標準都不同——就會天下大亂!
3. 使用 Comparable
使用 Comparable 來排序集合
現在我們的類別已經實作了 Comparable,排序就能「開箱即用」:
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); // 會呼叫 compareTo!
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
// Petya (20)
// Vasya (25)
// Katya (30)
同樣地,清單的 sort 方法也可以這樣用:
people.sort(null); // 若傳入 null,則使用 compareTo
依姓名排序
如果想依姓名排序——只要更改實作:
@Override
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
多欄位排序
有時需要先依某個欄位比較,若相等再比較另一個欄位:
@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. 實作 Comparable 的 Best practices
遵守 Comparable 的契約
- 如果 a.compareTo(b) == 0,那麼 b.compareTo(a) 一定也必須是 0。
- 如果 a.compareTo(b) < 0,則 b.compareTo(a) 必須是 > 0(反之亦然)。
- 如果 a.compareTo(b) == 0,最好讓 a.equals(b) 為 true(雖非強制)。
為什麼這很重要?
各種集合(例如 TreeSet、TreeMap)與排序方法,在契約被破壞時可能表現不穩定。例如,在不該有重複元素的集合中,可能會出現「重複項」。
別忘了 equals 與 hashCode
如果你實作了 compareTo,也該想想:equals 與 hashCode 是否實作正確?特別是當你的類別會用在 HashSet 或 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);
}
compareTo 中不要直接使用可能為 null 的欄位(請先檢查)!
如果欄位可能為 null,請使用安全的比較方式:
@Override
public int compareTo(Person other) {
return Objects.compare(this.name, other.name, Comparator.nullsFirst(String::compareTo));
}
物件已在排序後的集合中時,請勿修改參與 compareTo 的欄位
這可能導致物件在集合中被「弄丟」——例如在 TreeSet 或 TreeMap 中。
5. 擴充教學應用: 排序使用者
步驟 1:定義類別
public class Person implements Comparable<Person> {
private String name;
private int age;
// ... 建構子、getter、compareTo、equals、hashCode ...
}
步驟 2:新增使用者
List<Person> people = new ArrayList<>();
people.add(new Person("Masha", 23));
people.add(new Person("Grisha", 19));
people.add(new Person("Anya", 25));
步驟 3:排序並列印
Collections.sort(people);
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
結果:
Grisha (19)
Masha (23)
Anya (25)
6. Comparable 的運作流程圖
┌────────────────────────────┐
│ 你的類別 (Person) │
├────────────────────────────┤
│ implements Comparable │
│ ↓ │
│ public int compareTo(T o) │
│ ↓ │
│ (this < o) → -1 │
│ (this == o) → 0 │
│ (this > o) → 1 │
└────────────────────────────┘
│
▼
Collections.sort(list)
│
▼
排序能正常運作!
7. 實用細節
Collections.sort 如何運作
- 如果清單包含實作了 Comparable 的物件,排序會使用它們的 compareTo 方法。
- 若未實作——會發生編譯錯誤。
- 對於標準型別(如 Integer、String 等),已內建 Comparable 的實作。
可否提供多種比較方式?
- 在同一個類別中——只能透過 Comparable 定義一種「自然順序」。
- 替代的排序方式請使用 Comparator(下一講)。
範例:字串的 compareTo
String a = "apple";
String b = "banana";
System.out.println(a.compareTo(b)); // 負數,因為 "apple" < "banana"
表格:compareTo 會回傳什麼
| 比較 | 回傳值 |
|---|---|
|
|
|
|
|
|
8. 實作 Comparable 時的常見錯誤
錯誤 1:違反 compareTo 的契約。
若 a.compareTo(b) 回傳 0,但 b.compareTo(a) 卻不是 0,集合可能會表現怪異。例如,TreeSet 可能把物件視為不同,並同時加入兩個。
錯誤 2:使用未初始化(null)的欄位。
若用於比較的欄位可能為 null,卻未做檢查——會得到 NullPointerException。
錯誤 3:compareTo 與 equals 不一致。
若 compareTo 認為物件相等(0),而 equals 卻認為不同(false),會在使用集合時引發各種 bug。
錯誤 4:在物件加入排序後的集合之後,仍修改參與 compareTo 的欄位。
這就像你按字母順序排隊時臨時改了姓氏;集合可能會「弄丟」你的物件。
錯誤 5:僅回傳 -1、0 或 1。
compareTo 可以回傳任意負數或正數,不必嚴格是 -1 或 1。但為了簡單,很多實作會使用 -1/0/1。
GO TO FULL VERSION