こんにちは、かつコーチです。
「HashSetに入れたはずのオブジェクトが重複して入ってしまう」といったバグに遭遇したことはありませんか。
今回はequals・hashCode・toStringを正しく実装するための考え方を、実際のバグ事例とあわせて解説します。
equalsとhashCodeが守るべき契約
デフォルトのequalsは参照比較
Objectクラスのequals()は、何もオーバーライドしないと参照が同じかどうか(==と同じ)しか見ません。
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // false(同じ値でも別インスタンスだから)
「同じ値を持つオブジェクトは等しいと判定したい」場合、自分でequals()をオーバーライドする必要があります。
equalsとhashCodeはセットで実装する
契約(Javaの仕様として守るべきルール)として、「equals()がtrueを返す2つのオブジェクトは、hashCode()も同じ値を返さなければならない」という決まりがあります。
これを守らないと、HashMapやHashSetが正しく動作しなくなります。
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
実際に遭遇したHashSetの重複バグ
equalsだけオーバーライドしてhashCodeを忘れたケース
以前、equals()だけをオーバーライドしてhashCode()を実装し忘れたクラスをHashSetに入れたところ、値が同じはずのオブジェクトが重複して登録されてしまいました。
// ❌Before:equalsだけオーバーライドしてhashCodeを忘れる
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
// hashCode()を実装していない
}
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.add(new Point(1, 2));
System.out.println(points.size()); // 2(1になってほしかった)
原因は、hashCode()をオーバーライドしないとObjectのデフォルト実装(インスタンスごとに異なる値を返す)が使われ続けるためでした。HashSetは追加前にまずhashCode()でバケットを決め、同じバケット内でequals()を比較します。hashCode()が異なればequals()がtrueを返すオブジェクト同士でも別バケットに入り、重複と判定されません。
// ✅After:equalsとhashCodeをセットで実装する
@Override
public int hashCode() {
return Objects.hash(x, y);
}
IDEの自動生成機能(IntelliJ IDEAなら「Generate > equals() and hashCode()」)を使えば、この2つを必ずセットで生成してくれるので、以来手書きせずIDE任せにするようにしています。
getClass()とinstanceofの使い分け
equals()の実装でgetClass() != o.getClass()と!(o instanceof Point)のどちらを使うか迷ったことがあります。
違いは継承関係の扱いです。
getClass()比較:サブクラスのインスタンスは別物として扱う(厳密な型一致)instanceof比較:サブクラスのインスタンスも同じ型として扱える可能性がある
継承してフィールドを追加するクラス階層をequals()で正しく扱うのは非常に難しく(対称性が崩れやすい)、一般的には「継承よりコンポジションを使う」設計を選び、getClass()比較で厳密に判定するのが安全です。
toStringの実装で得られるデバッグの効率化
デフォルトのtoStringは役に立たない
toString()をオーバーライドしないと、ClassName@ハッシュ値の16進数表現のような文字列が出力されます。
Point p = new Point(1, 2);
System.out.println(p); // com.example.Point@7229724f
デバッグ時にこれが出力されると、中身が何も分かりません。
内容が分かるtoStringを実装する
@Override
public String toString() {
return "Point{x=" + x + ", y=" + y + "}";
}
System.out.println(p); // Point{x=1, y=2}
ログ出力やデバッガでの確認時に、オブジェクトの中身が一目で分かるようになります。
特に例外発生時のログにtoString()の結果が含まれることが多いため、業務でトラブルシューティングをする際にこの一手間の差が大きく効いてきます。
3つのメソッドを自分で書かずに済ませる方法
IDEの自動生成機能を使う
先述の通り、IntelliJ IDEAやEclipseにはequals・hashCode・toStringを自動生成する機能があります。
手書きではinstanceofの判定漏れやObjects.hash()の書き忘れが起きやすいため、基本的にはIDEに任せるのがおすすめです。
recordを使うという選択肢
Java 21では、値を保持するだけのクラスならrecordを使うことで、この3つのメソッドをまとめて自動生成できます。
public record Point(int x, int y) {
}
recordは不変なデータ表現に限定されますが、該当するケースではequals・hashCode・toStringを手書きする必要が一切なくなります。
詳しい使い方は「record(Java21)でシンプルにデータを表現する」の記事で解説しています。
まとめ
この記事のポイント
equals()をオーバーライドしたら、必ずhashCode()もセットでオーバーライドする- 契約を破ると
HashSet・HashMapで重複や検索失敗といったバグが起きる equals()の型比較はgetClass()とinstanceofで挙動が異なり、継承関係があるならgetClass()が安全toString()を実装するとデバッグ・ログ出力の効率が大きく上がる- IDEの自動生成機能や
recordを使えば、この3つを手書きせずに済ませられる
次に読むべき記事
Objectクラス全体の役割は「Objectクラスの役割と共通メソッド」の記事へ- 不変データの表現方法は「record(Java21)でシンプルにデータを表現する」の記事へ
タグ: Java, 中級者向け, オブジェクト指向