淺談 C 語言和 Java 的記憶體管理(九)

<< 上一篇

再談 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!

  1. 在 mtStrings.add(str1) 的時候,把 str1 指到的物件放到了 myStrings 這個 Set 裡編號是 -862545276 的架子上。
  2. 在 myPoints.add(point1) 的時候,把 point1 指到的物件放到了 myPoints 這個 Set 裡編號是 1560511937 的架子上。
  3. 由於 str2 算出來的 hashCode 也是 -862545276,所以 myStrings.contains(str2) 會先去看編號 -862545276 的架子……耶,有東西耶!什麼?!而且這東西丟給 str2.equals() 後傳回來的也是 true,這不就是我們正在找的東西嗎?快點傳回 true 給呼叫的人!
  4. 由於 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 的變數的事,這會是我們下一次的主題。

上一篇 >>

回響