【Java】Javaのequals・hashCode・toStringを正しく実装する

Java

こんにちは、かつコーチです。
「HashSetに入れたはずのオブジェクトが重複して入ってしまう」といったバグに遭遇したことはありませんか。
今回はequalshashCodetoStringを正しく実装するための考え方を、実際のバグ事例とあわせて解説します。

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()も同じ値を返さなければならない」という決まりがあります。
これを守らないと、HashMapHashSetが正しく動作しなくなります。

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にはequalshashCodetoStringを自動生成する機能があります。
手書きではinstanceofの判定漏れやObjects.hash()の書き忘れが起きやすいため、基本的にはIDEに任せるのがおすすめです。

recordを使うという選択肢

Java 21では、値を保持するだけのクラスならrecordを使うことで、この3つのメソッドをまとめて自動生成できます。

public record Point(int x, int y) {
}

recordは不変なデータ表現に限定されますが、該当するケースではequalshashCodetoStringを手書きする必要が一切なくなります。
詳しい使い方は「record(Java21)でシンプルにデータを表現する」の記事で解説しています。

まとめ

この記事のポイント

  • equals()をオーバーライドしたら、必ずhashCode()もセットでオーバーライドする
  • 契約を破るとHashSetHashMapで重複や検索失敗といったバグが起きる
  • equals()の型比較はgetClass()instanceofで挙動が異なり、継承関係があるならgetClass()が安全
  • toString()を実装するとデバッグ・ログ出力の効率が大きく上がる
  • IDEの自動生成機能やrecordを使えば、この3つを手書きせずに済ませられる

次に読むべき記事

  • Objectクラス全体の役割は「Objectクラスの役割と共通メソッド」の記事へ
  • 不変データの表現方法は「record(Java21)でシンプルにデータを表現する」の記事へ

タグ: Java, 中級者向け, オブジェクト指向

タイトルとURLをコピーしました