再談 Java 裡的 == 和 equals()
上次我們談到了在 Java 和 C 語言裡面,== 這個運算符號比的都是變數的「值」,所以如果你在 Java 裡面,針對 Reference 型態的變數用 == 來比較時,比的其實是兩個 Reference 所儲存的記憶體地址是不是相同。
有了這樣的概念,我們現在應該可以知道為什麼下面這隻程式,用 point1 == point2 印出來的結果會是 false 了--因為在這個程式裡面,我們在 Stack 上產生了 point1 和 point2 兩個變數,分別指到 Heap 上兩個不同的 Point 物件。
在這種情況下,即便這兩個 Point 物件裡面存的 x 和 y 都是 0,但由於兩者所在的記憶體位址是不同的,所以如果用 == 來比較的話,一定會是 false。
class Point
{
public final int x;
public final int y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
public class Test
{
public static void main (String [] args)
{
Point point1 = new Point(0, 0);
Point point2 = new Point(0, 0);
System.out.println("point1 == point2:" + (point1 == point2));
System.out.println("point1.equals(point2):" + (point1.equals(point2)));
}
}
但在很多情況之下,我們的確就是希望比較 point1 和 point2 這兩個物件所描述的是不是座標軸上的同一個點,而不在乎他們是不是在記憶體中的「同一個物件實體」,就像我們之前提到用 str1.equals(str2) 來判斷兩個字串內容是不是相同……
咦?可是怎麼這次我們用了 point1.equals(point2) 之後,Java 還是告訴我們 false 呢?
會發生這種情況,是因為我們沒有在 Point 類別裡覆寫這個方法,所以這裡的 equals() 方法的行為,是繼承自 java.lang.Object 這個類別,而 java.lang.Object 中關於 equals 的實作如下:
public boolean equals(Object obj) {
return (this == obj);
}
沒錯,在預設的情況下,equals 的作用和 == 這個運算符號是相同的,都是比較兩個 Reference 是不是指到記憶體中的同一個位置。
所以,如果我們的目的是要判斷兩個不同的 Point 物件是不是描述座標軸上的同一個點,那就必須要自己在 Point 類別中複寫 public boolean equals(Object obj) 這個方法才行。
Note
在 Java Doc 關於 equals() 的說明當中,有提到當你在實作這個 equals() 方法時,這個方法的行為要符合一些條件 。由於這不是本文的重點,所以在這裡不提,但你在實作 equals() 方法,應該仍要注意到這件事。
所以我們需要把上述的程式改成下面這樣子,他的行為才會符合我們的預期:
class Point
{
public final int x;
public final int y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
public boolean equals(Object obj)
{
if (obj instanceof Point) {
Point that = (Point) obj;
return this.x == that.x &&
this.y == that.y;
}
return false;
}
}
public class Test
{
public static void main (String [] args)
{
Point point1 = new Point(0, 0);
Point point2 = new Point(0, 0);
System.out.println("point1 == point2:" + (point1 == point2));
System.out.println("point1.equals(point2):" + (point1.equals(point2)));
}
}
結果把 Point 放到 HashSet 後……
上面的程式跑起來都很正常,直到有一天,我們想要把這些點座標集合起來,放到一個 java.util.HashSet 中,但沒想到我們的程式卻開始發瘋了……
public class Test
{
public static void main (String [] args)
{
HashSet<Point> myPoints = new HashSet<Point>();
HashSet<String> myStrings = new HashSet<String>();
String str1 = new String("Hello World");
String str2 = new String("Hello World");
Point point1 = new Point(0, 0);
Point point2 = new Point(0, 0);
myStrings.add(str1);
myPoints.add(point1);
System.out.println(myStrings.contains(str2)); // 印出 true
System.out.println(myPoints.contains(point2)); // 印出 false
}
}
為什麼?為什麼明明程式的邏輯都一樣,但如果放的是 String 物件,就可以判斷 HashSet 裡有內容相同的 String 物件,但變成放 Point 物件時,Java 就認為沒有相同內容的 Point 物件呢?
我明明有覆寫了 public boolean equals(Object obj) 這個方法,而且之前也用的好好的啊。
先來說說 Hash 是啥吧!
要解答這個疑惑,就需要先知道 HashSet 當中的 Hash 到底是啥意思。
Note
題外話,我一直強烈建議,學會了基本的程式寫作後,一定要繼續去學資料結構和演算法才行--就算你不能自己實作出相同的資料結構或演算法,但你至少要知道一些常用的資料結構和演算法的特性和優缺點才行,不然你很可能傻傻的用到了根本不符合你需求的演算法或資料結構。
像我自己就曾經看過一個例子,是某隻程式明明需要依照資料輸入的順序來做批次處理,而資料的格式大致上就是一組 (ID, 資料內容) 這樣的東西,結果對方卻是用 java.util.HashMap 來儲存,把 ID 當成 HashMap 的 Key,然後資料內容當成 HashMap 的值。
想當然爾,批次執行的時候悲據就發生了--java.util.HashMap 實際上就是資料結構裡常常提的 HashTable,而 HashTable 是沒有順序性的,你不能期待你放進去的順序和取出來的順序是一樣的啊!
那 Hash 到底是什麼呢?詳細的說明可以看演算法的書或者維基百科上的說明,而如果要用比喻的話,你可以把 Hash 當成類似是一間圖書館的書籍的「分類號」。
這要怎麼說呢,例如如果你去台北市立圖書館查詢「百業職人」這本書的話,你會發現在圖書館裡總共有七本,用 Java 的角度來說,這七本書是不同的物件,但由於他們的內容是相同的,所以他們的分類號全部都是「541.2933」。
但這 541.2933 是幹啥用的呢?很簡單,如果哪一天你到了圖書館,想要找這本書的話,那你只要走到圖書館裡標著 541.2933 這個編號的書架上,再看看書架上有沒有這本書就行了。
當然,由於圖書館的書本眾多,而我們的分類號只有到小數第四位,所以這個分類號的書架上可能會有其他書籍。不過即便如此,在圖書館裡靠著分類號來找到某本書,還是會比我們把所有書架都掃過一次來得快多了!
這個時候 hashCode() 就出場了
而 HashSet 的概念也是相同的--在這個 Set 中,我們有編號從 Integer.MIN_VALUE 到 Integer.MAX_VALUE 的架子,而當我們把某個物件放進去的時候,必然會要想辦法決定要把這個物件放到哪個架子上。
而決定的辦法就呼叫那個物件的 hashCode(),然後來看他返回了哪個整數,就把他那到編號是那個整數的架子上!
舉例來說,如果我們把上述的程式改成這樣:
public class Test
{
public static void main (String [] args)
{
HashSet<Point> myPoints = new HashSet<Point>();
HashSet<String> myStrings = new HashSet<String>();
String str1 = new String("Hello World");
String str2 = new String("Hello World");
Point point1 = new Point(0, 0);
Point point2 = new Point(0, 0);
System.out.println("str1.hashCode:" + str1.hashCode());
System.out.println("str2.hashCode:" + str2.hashCode());
System.out.println("point1.hashCode:" + point1.hashCode());
System.out.println("point2.hashCode:" + point2.hashCode());
myStrings.add(str1);
myPoints.add(point1);
System.out.println(myStrings.contains(str2)); // 印出 true
System.out.println(myPoints.contains(point2)); // 印出 false
}
}
這個時候,你會發現 str1 和 str2 的 hashCode 會是相同的,但 point1 和 point2 卻是不同的,這是因為如果你沒有覆寫 hashCode 的話,在大部份的 JVM 上,hashCode 會從物件所在的記憶體地址算出來,而既然 point1 和 point2 指到的是記憶體當中兩個不同的物件,算出來的值當然就不一樣囉。
所以假設你執行的時候,算出來的 hashCode 如下:
- str1.hashCode:-862545276
- str2.hashCode:-862545276
- point1.hashCode:1560511937
- point2.hashCode:306344348
這時候我們上面的那隻的程式到底會發生什麼事呢?答案如下:
Note
下述的「把物件放到架子上」只是比喻,實際上HashSet 裡放的不是物件,而是指到物件的 Reference」,因為在 Java 裡要存取到物件一定得靠 Reference!
- 在 mtStrings.add(str1) 的時候,把 str1 指到的物件放到了 myStrings 這個 Set 裡編號是 -862545276 的架子上。
- 在 myPoints.add(point1) 的時候,把 point1 指到的物件放到了 myPoints 這個 Set 裡編號是 1560511937 的架子上。
- 由於 str2 算出來的 hashCode 也是 -862545276,所以 myStrings.contains(str2) 會先去看編號 -862545276 的架子……耶,有東西耶!什麼?!而且這東西丟給 str2.equals() 後傳回來的也是 true,這不就是我們正在找的東西嗎?快點傳回 true 給呼叫的人!
- 由於 point2 算出來的 hashCode 是 306344348,myPoints.contians(point2) 會去看編號 306344348 的架子……嗯,架子上是空的,沒東西,所以傳 false 好了。
以上,由於 point2 和 point1 算出來的架子編號不同,當然就找不到囉。如果要沿用上面圖書館的比喻,就像是明明是同樣內容的書,但總館和分館的兩本書的編目號卻不同,而你跑到分館書架上,照著總館的那本的編目號跑到書架前一樣--找不到實際上存在的那本書。
所以如果要讓上述的程式的行為符合預期,那你就要想辦法讓他在兩個 Point 物件都是描述同一個坐標的時候,hashCode 也是一樣的才行--就像在圖書館裡,同樣內容的書就算有好幾本,分類號都仍然是一樣的。
所以我們可以把上述的 Point 類別改成像下面這樣:
class Point
{
public final int x;
public final int y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
public boolean equals(Object obj)
{
if (obj instanceof Point) {
Point that = (Point) obj;
return this.x == that.x &&
this.y == that.y;
}
return false;
}
public int hashCode()
{
// 可能不是很好的做法,但至少可以 work
return this.x * 1013 + this.y;
}
}
這樣子,當兩個 Point 物件的 x 和 y 都相同的時候,算出來的 hashCode 也會相同,讓我們上述的程式在使用 HashSet 的時候,可以找到相同編號的架子。
同樣的,針對 hashCode 的實作方法,也有一些規範,其中最重要的一條就是「如果 obj1.equals(obj2) 是 true 的話,那 obj1.hashCode 和 obj2.hashCode 也要一樣」。嘛,不過其實這就是上面我們講的那一大堆就是了啦。
至於詳細的規範,一樣請參見 java.lang.Object 裡的 hashCode() 的說明。
小結
這一次我們談論了 Java 的 equals() 和 hashCode() 這兩個東西,這兩個東西可能看起來很不顯眼,但如果你經常需要把物件丟到各式各樣的 Java Collection 當中的時候,請一定要注意你的類別這兩個方法是不是都正確實作了,不然程式很可能跑出來的結果和你預期的是不同的咧。
另外,這篇只是針對 equals() 和 hashCode() 的概念性的介紹,如果想要知道各種狀況下 equals() 與 hashCode() 比較好的做法和問題,可以參考良葛格的這篇「物件相等性」一文,裡面寫得滿清楚的,也有很多例子可以看。
最後,到目前為主,我們都還沒提到 C 和 Java 語言裡面關於被宣告成 static 的變數的事,這會是我們下一次的主題。
回響