正確實現 equals() 是 Java 開發中最容易出錯的基礎任務之一。研究表明絕大多數手寫 equals 實現存在缺陷。本文系統梳理四大常見陷阱,並引入 canEqual 模式,實現滿足等價關係(自反、對稱、傳遞)且支持繼承擴展的健壯方案。
// 錯誤:參數類型為 Point,未覆寫 Object.equals(Object)
public boolean equals(Point other) { ... }
// 正確:參數類型為 Object,加 @Override
@Override
public boolean equals(Object other) { ... }
後果:集合框架(HashSet、HashMap)調用 Object.equals(Object),導致邏輯失效,contains 返回錯誤結果。
// 違反契約:equal objects must have equal hash codes
@Override public boolean equals(Object o) { ... }
// 缺少 @Override public int hashCode() { ... }
後果:作為 HashMap key 或 HashSet 元素時,無法正確定位桶位置,導致 contains 失效。
public class Point {
private int x, y; // 非 final,有 setter
// equals/hashCode 基於 x,y
}
後果:放入集合後修改 x/y,對象哈希碼改變,導致在錯誤桶中查找,出現「集合不含對象但迭代器能遍歷到」的詭異現象。
原則:equals/hashCode 僅依賴不可變字段;可變狀態比較請另寫方法(如 equalsContents)。
場景:Point(x,y) 父類,ColoredPoint(x,y,color) 子類。
核心思想:每個覆寫 equals 的類顯式聲明「我願意與哪些類型的對象比較」,由 canEqual 方法控制。
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@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 41 * (41 + x) + y;
}
// 聲明:所有 Point 實例(含未覆寫 canEqual 的子類)均可比較
public boolean canEqual(Object other) {
return other instanceof Point;
}
}
public class ColoredPoint extends Point {
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@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 41 * 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) { // 匿名子類,未覆寫 canEqual
@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 (匿名子類未改變等價語義,視為普通點)
| 比較對象 | 結果 | 原因 |
|---|---|---|
| p.equals(cp) | false | cp.canEqual(p) 返回 false |
| cp.equals(p) | false | p 不是 ColoredPoint,instanceof 失敗 |
| p.equals(anon) | true | anon.canEqual(p) 繼承自 Point,返回 true |
| cp.equals(cp2) | true/false | 顏色相同/不同,符合業務預期 |
that.canEqual(this) 確保對稱性。41 * super.hashCode() + newField.hashCode() 保持契約。final。有人認為 cp.equals(p) == false 違反 Liskov 替換原則。實際上:
Set<Point> 存儲 Point,查詢 ColoredPoint 返回 false,符合業務語義「帶顏色的點不是普通點」,不違反 LSP。canEqual = true)。掌握 canEqual 模式,是從「會寫 equals」進階到「寫對 equals」的分水嶺。它用最小的代碼增量,解決了困擾 Java 開發者二十年的繼承與等價關係衝突,體現了「顯式優於隱式」的設計智慧。