前言

如果你想要自定義物件判斷是否相等的邏輯,就改equals吧!
如果你想要修改HashMap或是HashSet中,不重複物件的邏輯,那就修改hashCode吧!

在物件導向的世界裡面,勢必逃不了自己建立的物件(你可能把某些功能包起來,放在某個class裡面)又或是想要建立一個Employee.class這樣的類別,方便你建立員工的資料存入資料庫中。

而這些xxxx.class都其實繼承Object,可以說是Object是所有Java類別的超類(Abstract Class),而我會想寫這篇的原因是因為,剛好碰到需要自定義HashSet的需求,修改放入Set的自定義物件時,判斷物件是否相等的邏輯

什麼意思呢?簡單來說,在程式語言中,我希望不要採用Object本身的hashCodeequals方法,我希望只要某些屬性的值一樣,我就視這個物件已經存在於Set裡面。而不是比較記憶體中的位置、所有屬性的value都要一樣等,我希望自定義物件相等的邏輯判斷

如果你好奇…

  1. 我複寫equals的方法為什麼還要管hashCode?
  2. equals跟hashCode的關係是什麼?

那你可以參考這個篇文章,繼續看下去。

equals 是什麼?

可以從原始碼中看到一長串的東西,沒關係你可以跳過,讓我來跟你娓娓道來 …

equals 的特性

他裡面說了啥?面試這個應該要說得出來以下幾種特性呦!

簡單來說,equals主要遵循了以下規則來判斷物件的相等性:

  1. 自反性Reflexive: 我就是我
    • x.equals(x) 應該總是返回 true。
  2. 對稱性Symmetric: 我是你 你就是我 順序沒差
    • 如果 x.equals(y) 返回 true,則 y.equals(x) 也應返回 true。這意味著比較的順序不重要。
  3. 傳遞性Transitive: 爺爺的精神跟孫子是一樣的
    • 相較於Symmetric更深一層,如果 x.equals(y) 返回 true,且 y.equals(z) 返回 true,則 x.equals(z) 也應返回 true
    • 換句話說,如果兩個物件分別與第三個物件相等,則它們之間也應該相等。
  4. 一致性Consistent:只要內容不變還是一樣
    • 只要比較時使用的信息未被修改,多次調用 x.equals(y) 應始終返回相同的結果。
  5. 與 null 的比較:永遠不同
    • 對於任何非 null 的引用值 x,x.equals(null) 應該返回 false。物件不應該與 null 相等。

到底怎麼樣算equals?

你說的我都懂:但是我怎麼知道 equals 到底是比什麼?

按照原本equals的內容,如果滿足下面三個條件的話,我就說這兩個物件是一樣的

  1. 位置 一樣
  2. 類別 一樣
  3. 屬性 一樣

上面的例子中,你可能最不懂的是比較位置
舉例來說,下面有一個程式範例:

1
2
3
4
5
6
7
 public static void main(String[] args) {
// 每次 new 的時候就是 assign 一個新位置給他
Employee emp1 = new Employee("shannon");
Employee emp2 = new Employee("shannon");
// 這時就會返回 false
System.out.println(emp1.equals(emp2));
}

大概是這種感覺:當new一個物件的時候,就會給他安排在不同的位置,所以他們當然就是獨立的個體囉,就像是同名同姓的人住在不同地區一樣,但是他們還是不同人

但是如果你把程式改成這樣:

1
2
3
4
5
6
7
 public static void main(String[] args) {
// 每次 new 的時候就是 assign 一個新位置給他
Employee emp1 = new Employee("shannon");
Employee emp2 = emp1;
// 這時就會返回 true
System.out.println(emp1.equals(emp2));
}

為啥要改寫equals?

今天你不想要,判斷位置,我希望只要屬性值一樣,就識別這兩個物件是一樣的
這時候你可以考慮override equlas 方法

  • 簡單來說,我有一個IdNumber.class,用來記錄身分證資料,因此我只需要知道id如果一樣的話,一定是同一個人,不用多說。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class IdNumber {
private String id;

@Override
public boolean equals(Object o) {
// 如果“位置”一樣就不用看了 肯定同一個人
if (this == o) return true;
// 如果“類別”不可為null,同時如果"類別"不一樣,也太怪了,一定不是同個人
if (o == null || getClass() != o.getClass()) return false;
// 注意:上面這段絕對不要使用 instanceof !(o instanceof IdNumber)

// 比較自定義類別的各種“屬性值”
// 透過Objects.equals 判斷 就不用寫成這樣:(o.id != null && o.id.equals(that.id))
IdNumber that = (IdNumber) o
return Objects.equals(id,that.id);
}

public static void main(String[] args) {
// 這時候就能打破「位置」的框架了,只針對「屬性」還有「類別」來比較。
IdNumber id1 = new IdNumber("A123");
IdNumber id2 = new IdNumber("A123");
System.out.println(id1.equals(id2));
}
}

這時候你可能會不太了解為什麼不要使用instanceof來進行類別的比較?
因為在父類別層次進行比較時,會出現問題

Ans:
違反對稱性:user 不是 employee 的子類,但是employeeuser的子類,我們希望不管employee還是user放在被比較的那方,還是比較方,結果應該都要一樣!
因此才使用getClass來進行類別的比較,不考慮父類別之間的關係,反正class不一樣,我一率覺得這是不一樣的物件,拒絕!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Parent {
// Parent class definition
}

class Child extends Parent {
// Child class definition
}

public class Main {
public static void main(String[] args) {
Parent parent = new Parent();
Child child = new Child();

// print: parent.getClass() = class Parent
System.out.println("parent.getClass() = " + parent.getClass());
// print: child.getClass() = class Child
System.out.println("child.getClass() = " + child.getClass());
// print: child.getClass() == parent.getClass() ? false
System.out.println("child.getClass() == parent.getClass() ? " + (child.getClass() == parent.getClass()));
}
}

關於HashCode ?

hashCode也叫雜湊碼(雜湊碼),它用來計算物件中所有屬性的雜湊值,哪裡會使用到呢?從字面上就可以看到,Hash有關的,就會呼叫到這個函式。例如,HashMap或是HashSet。在 Java 或 Kotlin 語言中,hashCode() 方法的主要目的是用來在使用哈希表(如 HashMapHashSet)時提供一種快速查找的方式

hash 的特性

在計算機科學中,雜湊(Hash)是一種特殊的函數,具有以下幾個主要特性:

  1. 確定性(Deterministic):
    • 對於同一個輸入,Hash 函數每次運算都將產生同一個輸出。也就是說,如果你有一個輸入 A,每次將 A 輸入 Hash 函數,你都會得到同樣的結果。
  2. 快速計算(Fast to Compute):
    • 對任何給定的輸入,計算其 Hash 值都應該是非常迅速的。
  3. 不可逆性(Irreversibility):
    • 當知道了 Hash 函數的輸出,我們卻無法推算出其對應的輸入。這是密碼學中特別重要的一個特性
  4. 隨輸入微小變化產生大變化(Avalanche Effect):
    • 即使輸入的微小變化,也應該導致 Hash 值的劇烈變化。這有助於確保相似的輸入在經過雜湊後得到的結果將顯著不同。

儲存物件於哈希表

當您在哈希表(HashMap 或是 HashSet)中存儲物件時,hashCode() 方法被用來確定物件應該被存放在哈希表的哪個位置。這通常通過將物件的 hashCode() 返回值對哈希表的大小進行取模運算來實現。

透過hashCode檢索儲存於哈希表的物件

  1. 當您試圖從哈希表中檢索物件時,hashCode() 方法也會被調用以確定應該在哪裡查找該物件
  2. 如果哈希碼與表中存儲的任何物件的哈希碼不匹配,那麼哈希表可以立即確定該物件不在表中,而無需進行進一步的比較
  3. 如果在該哈希碼對應的位置找到了一個或多個物件,哈希表將使用 equals() 方法來確定哪一個物件(如果有的話)與查詢的物件匹配。這是因為多個不同的物件可能會有相同的哈希碼(也就是所謂的哈希碰撞)。

這就是為什麼當我們在自定義類別中覆寫 equals() 方法時,我們應該始終確保也覆寫 hashCode() 方法,並確保如果兩個物件根據 equals() 方法是相等的,那麼它們的 hashCode() 方法也應該返回相同的值。如果這兩種方法之間的契約被違反,那麼依賴這些方法的資料結構(如 HashMapHashSet)可能無法正確地工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.HashSet;
import java.util.Objects;

class Player {
private String name;

Player(String name) {
this.name = name;
}

public String getName() {
return name;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Player player = (Player) o;
return Objects.equals(name, player.name);
}

@Override
public int hashCode() {
// 我們用 name 來進行 hash
return Objects.hash(name);
}
}

public class Main {
public static void main(String[] args) {
HashSet<Player> players = new HashSet<>();

players.add(new Player("Alice"));
players.add(new Player("Bob"));
players.add(new Player("Charlie"));

// Attempt to add a new player with the same name as Alice
players.add(new Player("Alice"));

// 這時候 players 只會有三個 因為 name 是一樣的
System.out.println(player.getSize());
}
}

參考連結