1. equals, hashCode, toString 的自動產生
為什麼需要這些方法?
在 Java 中與物件打交道時,你很快就會遇到相同的幾個需求。有時需要檢查兩個物件是否相等。例如,想知道某個物件是否已存在於像 Set 或 Map 之類的集合中。在其他情況下,物件被用作 HashMap 的鍵,這時就離不開特定的比較規則。還有,幾乎總是會想把物件印在日誌或螢幕上,而且不想只是看到像 MyClass@7b23ec81 這樣的亂碼,而是一些有意義的內容。
針對這些情況,Java 中的每個類別都有三個特殊的方法:
- equals(Object o) 負責檢查相等性。
- hashCode() 產生物件的數值「指紋」,供雜湊表等集合使用。
- toString() 回傳物件的易讀字串表示,對除錯與列印非常有幫助。
為什麼在一般類別裡會這麼痛苦?
在一般類別中,這些方法得手動撰寫。此時無聊與頭痛就開始了:產生一堆樣板程式碼,只會讓類別變得臃腫,而且非常容易在某處出錯——忘了比較某個欄位、錯算了 hashCode,然後就得面對各種詭異的 bug。若之後在類別中新增欄位——還得再回去修改這些方法,全部重來。
一般類別的範例
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return 31 * x + y;
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}
看起來很眼熟吧?是的,這還只是兩個欄位!如果有二十個呢?
record 怎麼做
Record 類會幫你完成這一切。只要宣告:
public record Point(int x, int y) { }
Java 會自動產生:
- 建構子
- getter(x()、y())
- equals、hashCode、toString
自動產生的方法
- equals 會逐一比較 record 的所有元件值。
- hashCode 會依所有元件計算。
- toString 回傳格式為 Point[x=1, y=2] 的字串。
來實際看一下吧!
public record Point(int x, int y) {}
public class Demo {
public static void main(String[] args) {
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // true
System.out.println(p1.hashCode() == p2.hashCode()); // true
System.out.println(p1); // Point[x=1, y=2]
}
}
輸出:
true
true
Point[x=1, y=2]
一切如預期運作,且不必多寫任何一行程式碼!
2. 為什麼這很重要:集合、除錯與健壯性
在集合中的正確運作
想像你把物件當成 HashMap 的鍵或 HashSet 的元素。如果 equals 與 hashCode 的實作不正確——集合就會表現得很怪:找不到你剛加進去的元素,或者反過來,把兩個不同的物件當成一樣。
使用 record 類時你可以放心:比較與雜湊總會涵蓋所有 record 元件(按照宣告時的順序)。
範例:把 record 當成鍵
import java.util.HashMap;
import java.util.Map;
public class Demo {
public static void main(String[] args) {
record Point(int x, int y) {}
Map<Point, String> map = new HashMap<>();
Point p1 = new Point(3, 4);
map.put(p1, "Hello!");
Point p2 = new Point(3, 4);
System.out.println(map.get(p2)); // "Hello!" — 可以運作!
}
}
請注意:p1 與 p2 是不同物件(不同參考),但欄位值相同,因此被視為相等。關於 Map 與 HashMap 的更多內容,你會在第 26 級學到 :P
方便的除錯與記錄
與其看到乏味的 Point@1a2b3c4d(一般類別的預設),record 類會輸出漂亮又具資訊性的格式:
Point[x=3, y=4]
這能大幅節省除錯與記錄時的時間。
3. record 內部的 equals, hashCode, toString 是怎麼運作的
equals 方法
Record 類的 equals 讓兩個物件在以下情況下被視為相等:
- 它們是相同型別(同一個 record 類)
- 所有元件都相等(對於基本型別用 ==,對於物件用 equals())
比較範例
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = new Point(1, 3);
System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false
hashCode 方法
雜湊碼會根據 record 的所有元件計算,通常使用標準方法 Objects.hash(...)。
System.out.println(p1.hashCode()); // 例如,994
System.out.println(p2.hashCode()); // 也是 994
System.out.println(p3.hashCode()); // 不同的數字
toString 方法
字串表示永遠是這個格式:
ClassName[field1=value1, field2=value2, ...]
System.out.println(p1); // Point[x=1, y=2]
4. 覆寫 equals, hashCode, toString:何時、如何做?
有時候(很少,但確實會)需要改變這些方法的預設行為。比方說,你想讓 toString 回傳不同的格式,或只用部分欄位來比較。
注意:如果你要覆寫 equals/hashCode,務必非常謹慎!破壞它們的「契約」可能導致很難抓的 bug。
如何覆寫方法
只要在 record 類的主體內宣告你自己的方法即可:
public record Point(int x, int y) {
@Override
public String toString() {
return "(" + x + "; " + y + ")";
}
}
Point p = new Point(3, 5);
System.out.println(p); // (3; 5)
可以覆寫 equals/hashCode 嗎?
可以,但如果不確定自己在做什麼,強烈不建議。比方說,你想只用欄位 x 來比較(這其實很奇怪):
public record Point(int x, int y) {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point other)) return false;
return x == other.x;
}
@Override
public int hashCode() {
return Integer.hashCode(x);
}
}
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 999);
System.out.println(p1.equals(p2)); // true (!)
但請務必小心:如果你覆寫了 equals,也一定要覆寫 hashCode——否則集合就會工作不正常。
Best practice
- 如果不確定為什麼要覆寫——就不要覆寫!
- 對於 toString——如果想要自訂格式,可以放心去做。
- 對於 equals/hashCode——只有在有充分理由且理解其後果時才這麼做。
5. 實作:物件比較與在集合中使用 record
範例:比較兩個 record 物件
public record User(String name, int age) {}
public class Demo {
public static void main(String[] args) {
User u1 = new User("Alice", 20);
User u2 = new User("Alice", 20);
User u3 = new User("Bob", 25);
System.out.println(u1.equals(u2)); // true
System.out.println(u1.equals(u3)); // false
System.out.println(u1.hashCode() == u2.hashCode()); // true
System.out.println(u1); // User[name=Alice, age=20]
}
}
範例:將 record 當作 HashMap 的鍵
讓我們假設有個應用程式,會依使用者的名字與年齡來儲存造訪次數(說不定俱樂部裡有兩個「Ivan 20 歲」)。
import java.util.HashMap;
import java.util.Map;
public class Demo {
public static void main(String[] args) {
record User(String name, int age) {}
Map<User, Integer> visits = new HashMap<>();
User ivan20 = new User("Ivan", 20);
User ivan22 = new User("Ivan", 22);
visits.put(ivan20, 5);
visits.put(ivan22, 2);
// 來確認依值查找是否能正確運作
System.out.println(visits.get(new User("Ivan", 20))); // 5
System.out.println(visits.get(new User("Ivan", 22))); // 2
}
}
如果 equals 與 hashCode 沒有正確實作,查找就不會成功。更多關於 Map 與 HashMap 的內容,請參考第 26 級的課程 :P
6. 在 record 類中使用 equals, hashCode, toString 的常見錯誤
錯誤 №1:以為建立之後還能修改欄位。
Record 的欄位永遠是 final,比較會依其在建構子中給定的值進行。如果你用某種取巧方式「改變」內部狀態(例如欄位裡放可變物件並修改其內容),比較與雜湊就可能變得不正確。
錯誤 №2:覆寫了 equals,卻忘了 hashCode。
如果你覆寫了其中一個方法——一定也要覆寫另一個!否則集合(HashSet、HashMap)會表現得不可預期。
錯誤 №3:期待 toString 會有其他格式。
如果你需要特定格式——直接覆寫 toString。預設格式永遠是 ClassName[field1=value1, field2=value2]。
錯誤 №4:將 record 用在含有可變欄位的複雜類別。
Record 的欄位應該是不可變的。如果作為欄位的是 ArrayList 之類的型別,而且有人修改了其內容——比較與雜湊碼就可能「壞掉」。Record 最好只搭配不可變型別。
錯誤 №5:把 record 用在不屬於 value-object 行為的類別上。
Record 並不是「語法更短的小類別」。它就是 value object,專門用來保存一組值。如果你的類別有複雜邏輯、可變狀態或需要繼承——請使用一般類別。
GO TO FULL VERSION