研究表明,Java 代碼庫中絕大多數 equals() 實現違反了等價關系約定(自反性、對稱性、傳遞性、一致性、非空性)。這不僅導致集合(HashSet、HashMap)行為異常,更是無數難以復現 Bug 的溫床。
// 錯誤:參數類型為 Point,未重寫 Object.equals(Object)
public boolean equals(Point other) { ... }
// 正確:參數必須為 Object,並配合 instanceof
@Override public boolean equals(Object other) {
if (other instanceof Point) {
Point that = (Point) other;
return this.x == that.x && this.y == that.y;
}
return false;
}
後果:集合調用 Object.equals(),導致 HashSet.contains() 失效。
HashSet 依賴 hashCode() 定桶位,再用 equals() 比較。若兩對象 equals() 返回 true 但 hashCode() 不同,會落入不同桶,導致 contains() 返回 false。
// 必須同時重寫
@Override public int hashCode() {
return 31 * x + y; // 僅依賴 equals 使用的字段
}
若對象放入 HashSet 後修改了參與 equals/hashCode 的字段,對象會「消失」:contains() 返回 false,但迭代器能遍歷到它。
對策:僅基於不可變字段實現;或不重寫 equals/hashCode,改用單獨方法如 sameContent()。
子類添加新字段並重寫 equals() 時,極易破壞約定:
parent.equals(child) == true 但 child.equals(parent) == false。red.equals(blue) == false,但兩者都等於同一 parent。引入 canEqual(Object other) 方法,顯式聲明「我願意與哪類對象比較」:
public class Point {
private final int x, y;
// ... 構造器、getter ...
@Override public boolean equals(Object other) {
if (other instanceof Point) {
Point that = (Point) other;
return that.canEqual(this) // 關鍵:雙向確認
&& this.x == that.x
&& this.y == that.y;
}
return false;
}
@Override public int hashCode() {
return 31 * x + y;
}
// 默認:允許與所有 Point 實例比較(含未重寫 equals 的子類)
public boolean canEqual(Object other) {
return other instanceof Point;
}
}
public class ColoredPoint extends Point {
private final Color color;
// ... 構造器、getter ...
@Override public boolean equals(Object other) {
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
return that.canEqual(this) // 關鍵:雙向確認
&& this.color.equals(that.color)
&& super.equals(that);
}
return false;
}
@Override public int hashCode() {
return 31 * super.hashCode() + color.hashCode();
}
// 關鍵:限制只與 ColoredPoint 比較,拒絕與父類 Point 比較
@Override public boolean canEqual(Object other) {
return other instanceof ColoredPoint;
}
}
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.RED);
Point anon = new Point(1, 1) { @Override public int getY() { return 2; } };
Set<Point> set = new HashSet<>();
set.add(p);
set.contains(p); // true
set.contains(cp); // false —— 色彩點不等於普通點
set.contains(anon); // true —— 匿名子類未重寫 canEqual,視為普通點
完美滿足所有等價關系約定,且保持了里氏替換原則的語義正確性。
有人質疑 canEqual 違反 LSP。實則不然:LSP 要求子類行為符合父類約定,而非行為完全一致。ColoredPoint 明確語義為「帶顏色的點不等於無色點」,這是領域邏輯決定,而非協違約定。
equals(Object) + @OverridehashCode(),僅用不可變字段canEqual() 處理繼承層次掌握 canEqual 模式,是寫出正確、可擴展 equals() 的關鍵。下次重寫時,請務必帶上它。