物件相等性(上) by caterpillar | CodeData
top

物件相等性(上)

分享:

在Java中,如果要比較兩個物件的實質相等性,並不是使用==,而是必須透過equals()方法,例如:

String s1 = new String("Java");
String s2 = new String("Java");
out.println(s1 == s2); // 顯示 false
out.println(s1.equals(s2)); // 顯示 true

兩個物件是新建構出來的,所以s1與s2是參考到不同物件,因而使用==比較會是false,要比較兩個字串的實質字元序列,必須使用equals(),這是因為String的equals()重新定義為比較兩個字串的字元序列。

如果你定義類別時,沒有重新定義equals()方法,則預設繼承自Object,Object的equals()方法是定義為:

public boolean equals(Object obj) {
    return (this == obj);
}

也就是如果你沒有重新定義equals(),使用equals()方法時,作用等同於使用==。如果你要重新定義equals(),必須注意幾個地方,例如,你可能如下定義了equals()方法:

public 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(Point that) {
        return this.x == that.x && this.y == that.y;
    }
}

如果你這麼測試:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
out.println(p1.equals(p2));  // 顯示 true

看來似乎沒錯,p1與p2座標都是同一點,所以實際上指的相同的座標,但是如果你這麼測試:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p2));    // 顯示 false

Set 中放入的p1與要測試的p2明明是指同一點,為什麼會顯示false?問題在於你沒有重新定義Object的equals(),你是另外定義了一個 equals()方法,參數是Point型態,換言之,你是重載(overload),不是重新定義(Override),Object的equals()接受的是Object型態的參數。如果你使用以下的程式測試,就可以知道原因:

Object p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
out.println(p1.equals(p2));  // 顯示 false

p1是Object宣告,看不到Point中的equals(),所以就使用Object本身的equals(),結果當然是false。

在JDK5之後,可以使用@Override避免這類錯誤,例如:

public class Point {
    public final int x;
    public final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point) {
            Point p = (Point) that;
            return this.x == p.x && this.y == p.y;
        }
        return false;
    }
}

再作同樣的測試:

Object p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
out.println(p1.equals(p2));  // 顯示 true

結果看來是正確了,不過:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p2));    // 可能顯示 false

如 果上例結果顯示false,並不用訝異,因為你在重新定義equals()時,並沒有重新定義hashCode(),在許多場合,例如將物件加入群集 (Collection)時,會同時利用equals()與hashCode()來判斷是否加入的是(實質上)相同的物件。在Object的 hashCode() 說明 指出:

  • 在同一個應用程式執行期間,對同一物件呼叫 hashCode()方法,必須回傳相同的整數結果。
  • 如果兩個物件使用equals(Object)測試結果為相等, 則這兩個物件呼叫hashCode()時,必須獲得相同的整數結果。
  • 如果兩個物件使用equals(Object)測試結果為不相等, 則這兩個物件呼叫hashCode()時,可以獲得不同的整數結果。

以HashSet為例,會先使用hashCode()得出該將物件放至哪個雜湊桶(hash buckets)中,如果雜湊桶有物件,再進一步使用equals()確定實質相等性,從而確定Set中不會有重複的物件。上例中說可能會顯示false,是因為若湊巧物件hashCode()算出在同一個雜湊桶,再進一步用equals()就有可能出現true。

在重新定義equals()時,最好重新一併重新定義hashCode()。例如:

public class Point {
    public final int x;
    public final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point) {
            Point p = (Point) that;
            return this.x == p.x && this.y == p.y;
        }
        return false;
    }
    @Override
    public int hashCode() {
        return 41 * (41 + x) + y;
    }
}

再次測試就會得到true了:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p2));    // 顯示 true

一個重要的觀念是,定義equals()與hashCode()時,最好別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?假設x、y是個允許會變動的成員,那麼就會發生這個情況:

Point p1 = new Point(1, 1);
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p1));  // 顯示 true
p1.x = 2;
out.println(pSet.contains(p1));  // 顯示 false

明明是記憶體中同一個物件,但置入Set後,最後跟我說不包括p1?這是因為,你改變了x,算出來的hashCode()也就改變了,使用contains()嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是置入p1的雜湊桶中尋找,結果就是false了。

在Object的 equals() 說明 中有提到,實作equals()時要遵守的約定:

  • 反身性(Reflexive):x.equals(x)的結果要是true。
  • 對稱性(Symmetric):x.equals(y)與y.equals(x)的結果必須相同。
  • 傳遞性(Transitive):x.equals(y)、y.equals(z)的結果都是true,則x.equals(z)的結果也必須是true。
  • 一致性(Consistent):同一個執行期間,對x.equals(y)的多次呼叫,結果必須相同。
  • 對任何非null的x,x.equals(null)必須傳回false。

目前定義的Point,其equals()方法滿足以上幾個約定(你可以自行寫程式測試)。現在考慮繼承的情況,你要定義3D的點:

public class Point3D extends Point {
    public final int z;
    public Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point3D) {
            Point3D p = (Point3D) that;
            return super.equals(p) && this.z == p.z;
        }
        return false;
    }
}

這看來似乎沒什麼問題,3D的點要再比較z座標是沒錯。不過來測試一下:

Point p1 = new Point(1, 1);
Point p2 = new Point3D(1, 1, 1);
out.println(p1.equals(p2));   // 顯示 true
println(p2.equals(p1));   // 顯示 false

結 果該是true或false需要討論一下。3D的點與2D的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那p1.equals(p2)為 true就可以接受,在此假設之下,再來看p2.equals(p1)為false,這違反equals()對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改:

public class Point3D extends Point {
    public final int z;
    public Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point3D) {
            Point3D p = (Point3D) that;
            return super.equals(p) && this.z == p.z;
        }
        if(that instanceof Point) {
            return that.equals(this);
        }
        return false;
    }
}

再次運行上面的測試,就可以得到都是true的結果,但如果是這個:

Point p1 = new Point(1, 1);
Point p2 = new Point3D(1, 1, 1);
Point p3 = new Point3D(1, 1, 2);
out.println(p2.equals(p1));  // 顯示 true
out.println(p1.equals(p3));  // 顯示 true
out.println(p2.equals(p3));  // 顯示 false

p2等於p1,p1等於p3,但p2不等於p3,這違反傳遞性合約。問題點在於,2D的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了。

一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計:

public class Point {
    public final int x;
    public final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point) {
            Point p = (Point) that;
            return this.getClass() == p.getClass() &&
                   this.x == p.x && 
                   this.y == p.y;
        }
        return false;
    }
    @Override
    public int hashCode() {
        return 41 * (41 + x) + y;
    }
}
public class Point3D extends Point {
    public final int z;
    public Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point3D) {
            Point3D p = (Point3D) that;
            return super.equals(p) && this.z == p.z;
        }
        return false;
    }
}

直接判斷類別,讓不同類別的實例視為不相等,就這個例子而言,使得Point只能與Point比,Point3D只能與Point3D比,直接解決了不同繼承階層下equals()的合約問題。

不過在以下這種需求時,這樣的定義也許不符合你的需求:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1) {
           @Override
            public String toString() {
                return "(" + x + ", " + y + ")";
            }
};
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p1));   // 顯示 true
out.println(pSet.contains(p2));   // 顯示 false,但你想顯示 true

你也許是在某處建立了個匿名類別物件,然後在程式中某處又打算測試看看Set中是否含有相同座標的點,但結果並不是顯示true,這是因為你嚴格地在equals()中檢查了實例的類別名稱。

你可以將定義改為以下:

public class Point {
    public final int x;
    public final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point) {
            Point p = (Point) that;
            return p.canEquals(this) &&
                   this.x == p.x && 
                   this.y == p.y;
        }
        return false;
    }
    public boolean canEquals(Object that) {
        return that instanceof Point;
    }
    @Override
    public int hashCode() {
        return 41 * (41 + x) + y;
    }
}

在equals()中,你不僅檢查傳入的實例是否為Point,也反過來讓傳入的實例取得this的型態進行測試(這是 Visitor 模式 的實現)。而在 Point3D 中:

public class Point3D extends Point {
    public final int z;
    public Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }
    @Override
    public boolean equals(Object that) {
        if(that instanceof Point3D) {
            Point3D p = (Point3D) that;
            return p.canEquals(this) && 
                   super.equals(p) && this.z == p.z;
        }
        return false;
    }
    @Override
    public boolean canEquals(Object that) {
        return that instanceof Point3D;
    }
    @Override
    public int hashCode() {
        return 41 * super.hashCode() + z;
    }
}

如果p1是Point物件,而p2是Point3D物件,p1.equals(p2)時,由於傳入的實例可以取得this的型態進行測試,p2反過來測試p1是不是Point3D,結果不是,所以equals()傳回false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性。如果是直接繼承Point類別的匿名類別物件,則直接繼承canEquals()方法,由於匿名類別物件還是一種Point實例,因此equals()的結果會是true。

一個測試的結果如下:

Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1) {
            @Override
            public String toString() {
                return "(" + x + ", " + y + ")";
            }
        };
Point p3 = new Point3D(1, 1, 1);
Set pSet = new HashSet();
pSet.add(p1);
out.println(pSet.contains(p1));   // 顯示 true
out.println(pSet.contains(p2));   // 顯示 true
out.println(pSet.contains(p3));   // 顯示 false

後續 >> 物件相等性(下)

轉載自 Java Essence: 物件相等性

分享:
按讚!加入 CodeData Facebook 粉絲群

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

Valentino Young12/19


談及物件的相等性時,總是很自然地會提及 Object#hashCode(),總是很自然地會想提醒 「若要讓 x.equals(y),則也讓 x.hashCode() == y.hashCode() 能夠成立吧」。當然,這是無庸置疑的好習慣,無論是為了立刻感覺得到的準確性,還是為了經年累月才能體會到的品質度。

另一方面,談及物件的相等性時,總是很自然地會以 Collection#contains(Object) 做為素材。畢竟,若是只著眼在個體與個體之間的對稱性傳遞性等議題,應用其實極為有限;唯有納入「個體對群體」「群體對群體」的應用,逐步利用 Collection的#contains(Object), #remove(Collection), #retainAll(Collection)... 等等方法去組構出業務邏輯,Object#equals(Object) 的設計才能真正發揮所長。

只不過,個人並不太建議進一步讓上述兩頭馬車交錯並行,因為很容易演變出...諸如『例如將物件加入群集 (Collection)時,會同時利用equals()與hashCode()來判斷是否加入的是(實質上)相同的物件...』的文詞,而造成誤解。

Object#equals(Object)是偏向抽象性概念性思考後的設計,Object#hashCode()則是偏向實用性的考量下的產物;
Collection#contains(Object)的基本邏輯運作只有考慮Object#equals(Object)而已,
會受 Object#hashCode() 干涉的,實為 HashSet以及類似類別的 #contains(Object) 實作,而非抽象性更高的Collection。

雖然概念上的Set位於Collection族譜之中,然而實作出來被活用的類別(HashSet等等),實際上是包著Collection外衣的Map,
(換言之,通常實用化的Set會以「只在乎K不在乎V的Map<K,V>」作為基礎)。
為了強調「讓 x.equals(y)的話也讓 x.hashCode() == y.hashCode()」的好習慣,舉HashSet作例子固然無可厚非,
但這不應該被誤認為是Collection#contains(Object)的基本規則。

Comparable#compareTo(T)與Comparator#compare(T,T)兩者,也有類似提及 Object#equals(Object) 時所談到的對稱性傳遞性等等的理論,也有如同「若要讓 x.equals(y),則也讓 x.compareTo(y) == 0 能夠成立吧」的建言,
但也同樣的,那最後也是只影響實作出來的TreeSet(另一個只在乎K不在乎V的Map<K,V>)以及類似類別的個性,而非Collection#contains(Object)的基本規則。

Map與Collection,既相近又疏遠...
當Collection#contains(Object) 以 ArrayList等等實現時,既不在乎#hashCode()也不在乎#compareTo();
而HashSet#retainAll(TreeSet)與TreeSet#retainAll(HashSet),由於在乎的東西不一樣,相同結果反而是預期之外...
Collection#contains(Object)家族雖然以Object#equals(Object)為出發,但不是個在談論Object#equals(Object)之中佔個小篇幅即可的規模。

熱門論壇文章

熱門技術文章