equals と hashCode を必ずセットで実装する理由
HashSetやHashMapが先にハッシュ値で振り分けるためです。引数をObjectにする理由も扱います。
エラーメッセージの読み方
Main.java:8: error: cannot find symbol
Main.java- ファイル名
8- 行番号 — javacが異常に気づいた位置。原因の行とは限りません
error- 種別 — コンパイルエラー。実行まで到達しません
cannot find symbol- 内容 — javacが期待していたもの、または見つけられなかったもの
このエラーが出る典型パターン
パターン1
1 import java.util.Objects; 2 3 public class Main { 4 static class P { 5 int id; 6 P(int id) { this.id = id; } 7 @Override public boolean equals(Object o) { 8 return o instanceof P && ((Object) o).id == id; ^ 9 } 10 @Override public int hashCode() { return Objects.hash(id); } 11 } 12 public static void main(String[] args) { 13 System.out.println(new P(1).equals(new P(1))); 14 } 15 }
Objectのままではフィールドにアクセスできません。instanceofで確認した型へキャストします。
直し方: Object を P にします。
パターン2
1 import java.util.HashSet; 2 import java.util.Set; 3 4 public class Main { 5 static class P { 6 int id; 7 P(int id) { this.id = id; } 8 @Override public boolean equals(Object o) { return o instanceof P && ((P) o).id == id; } 9 ^ 10 } 11 public static void main(String[] args) { 12 Set<P> s = new HashSet<>(); 13 s.add(new P(1)); s.add(new P(1)); 14 System.out.println(s.size()); 15 } 16 }
equalsを上書きしたらhashCodeも必ず上書きします。HashSetは先にハッシュ値で振り分けるためです。
直し方: (空) を @Override public int hashCode() { return id; } にします。
パターン3
1 public class Main { 2 static class P { 3 int id; 4 P(int id) { this.id = id; } 5 public boolean equals(P o) { return ((P) o).id == id; } ^ 6 } 7 public static void main(String[] args) { 8 Object a = new P(1); 9 System.out.println(a.equals(new P(1))); 10 } 11 }
equalsの引数はObjectでなければオーバーライドになりません。Pにするとオーバーロードになり呼ばれません。
直し方: P を Object にします。
パターン4
1 import java.util.HashSet; 2 import java.util.Objects; 3 import java.util.Set; 4 5 public class Main { 6 static class P { 7 int id; 8 P(int id) { this.id = id; } 9 @Override public boolean equals(Object o) { return o instanceof P && ((P) o).id == id; } 10 @Override public int hashCode() { return Objects.hash(id); } 11 } 12 public static void main(String[] args) { 13 Set<P> set = new HashSet<>(); 14 P p = new P(1); 15 set.add(p); 16 p.id = 2 ; ^ 17 System.out.println(set.contains(p)); 18 } 19 }
equals/hashCodeの計算に使うフィールドを、集合に入れたあとで書き換えるとハッシュ値の格納位置とずれ、探せなくなります。
直し方: 2 を 1 にします。
パターン5
1 import java.util.Objects; 2 3 public class Main { 4 static class Point { 5 int x, y; 6 Point(int x, int y) { this.x = x; this.y = y; } 7 @Override public boolean equals(Object o) { 8 if (!(o instanceof Point)) return false; 9 Point p = (Point) o; 10 return x == p.x ; ^ 11 } 12 @Override public int hashCode() { return Objects.hash(x, y); } 13 } 14 public static void main(String[] args) { 15 System.out.println(new Point(1, 2).equals(new Point(1, 9))); 16 } 17 }
equalsでxしか比較していないため、yが違っても等しいと判定されてしまいます。hashCodeが使う全フィールドをequalsでも比較します。
直し方: (空) を && y == p.y にします。
よくある誤解
equalsだけ実装してもListのcontainsは正しく動きます。HashSetやHashMapで初めて壊れるので、発見が遅れます。
実務での勘所
equalsとhashCodeを自分で正しく書くのは簡単ではなく、実務では長らくIDEの自動生成機能を使うのが定石でした。Java 16で正式導入されたrecordを使えば、フィールド(コンポーネント)を宣言するだけでコンパイラが整合性の取れたequals・hashCode・toStringを自動生成してくれるため、単純なデータの入れ物として使う分にはこの問題そのものを回避できます。ただし独自の等価性の定義が必要な場合は、recordでも自分で上書きする必要があります。