Java | Java 標準ライブラリ:hashCode 契約

Java Java
スポンサーリンク

hashCode 契約って何のこと?

hashCode()
「オブジェクトを、整数の“番号”に変換するメソッド」
です。

主に HashMapHashSet などの「ハッシュを使うコレクション」で使われます。

そして Java には

「equals と hashCode は、こういう関係で実装しなさい」

という“契約(contract)”が決められています。
これを守らないと、HashMapHashSetわけの分からない動きをし始めます。

なので

equals をオーバーライドしたら hashCode も必ず考える
hashCode 契約を守らないと標準ライブラリを壊す

という感覚を、初心者のうちにしっかり掴んでおくのが大事です。


hashCode 契約の中身を日本語でざっくり言うと

契約の3本柱(初心者向けに訳す)

Java の公式仕様で書かれていることを、噛み砕いて言うと、hashCode は次のように振る舞うべきです。

1つ目
同じオブジェクトに対して hashCode() を何回呼んでも、「中身が変わらない限り」同じ値を返すべき。
つまり、呼ぶたびにコロコロ変わってはいけない。

2つ目
equals で等しいと判定される2つのオブジェクトは、必ず同じ hashCode を返さなければならない。
これは絶対に守らないといけないルール。

3つ目
equals で違うオブジェクト同士が、同じ hashCode を返しても「かまわない」。
ただし、違うのに同じ hashCode ばかり返すと性能が悪くなるので、できるだけ避けたほうが良い。

この「2つ目」が特に重要です。
ここが HashMap / HashSet の正しい動作の前提になっています。


なぜ equals と hashCode はセットなのか(ここが一番重要)

HashSet / HashMap の中で何が起きているかイメージする

HashSetHashMap は、内部で次のような流れで動いています(ざっくり)。

あるオブジェクトを入れる・探すときに

まず hashCode() を呼んで、「どのバケツ(グループ)に属するか」を決める
そのバケツの中で equals を使って「本当に同じかどうか」を確認する

つまり

hashCode で「場所」を絞り込み
equals で「中身の等しさ」を最終確認

という2段階です。

ここで

equals では「同じ」と言っているのに、hashCode がバラバラ
という状態だと、コレクション側はこうなります。

「探したいオブジェクトの hashCode から計算した“バケツ”を見たけど、そこにいないな…
別のバケツにいる? でもそこは見に行かない…」

結果として

入れたはずの要素が contains で見つからない
Map.get(key) で値が取れない

といった、かなり気持ち悪いバグが起こります。

だからこそ、

equals をオーバーライドしたら hashCode も必ずオーバーライドする
equals で同じとみなすものは、必ず同じ hashCode を返す

ことが「契約」として強く求められているわけです。


具体例:契約を破ったらどう壊れるか

equals だけオーバーライドして hashCode を放置した場合

値オブジェクト UserId を考えます。

public class UserId {

    private final String value;

    public UserId(String value) {
        this.value = value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserId)) return false;
        UserId other = (UserId) o;
        return this.value.equals(other.value);
    }

    // hashCode はオーバーライドしていない
}
Java

この状態で Set<UserId> を使ってみます。

UserId id1 = new UserId("U001");
UserId id2 = new UserId("U001");

Set<UserId> set = new HashSet<>();
set.add(id1);

System.out.println(id1.equals(id2));           // true(中身は同じ)
System.out.println(set.contains(id2));         // ここが問題
Java

equals によれば id1id2 は等しいので、
人間の感覚としては contains(id2)true を返してほしいですよね。

でも hashCode をオーバーライドしていないと、
id1id2 は別々の hashCode を返す可能性が高いです。

結果として

id1 はバケツ A に入れられる
contains(id2) はバケツ B を探しに行く

というすれ違いが起きて、containsfalse になってしまいます。

これはとても気づきにくいバグです。
コンパイルは通るし、equals 自体も正しいように見えるからです。

こういう事故を防ぐために「equals と hashCode はセットで実装しろ」と強く言われます。


正しい hashCode 実装の基本パターン

equals と同じフィールドを使う

hashCode で使うフィールドは

equals の比較に使っているフィールドと完全に一致させる

これが鉄則です。

例えばさっきの UserId の正しい実装はこうです。

public class UserId {

    private final String value;

    public UserId(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("UserId は必須");
        }
        this.value = value;
    }

    public String value() {
        return value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserId)) return false;
        UserId other = (UserId) o;
        return this.value.equals(other.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();   // equals に使っている value の hashCode をそのまま返す
    }
}
Java

これで

equals が true を返すとき(value が同じとき)
hashCode も必ず同じ値になる

という関係が作れます。

フィールドが複数ある場合

例えば「金額 + 通貨」の Money を考えます。

public class Money {

    private final long amount;
    private final String currency;  // "JPY", "USD" など

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money other = (Money) o;
        return this.amount == other.amount
                && this.currency.equals(other.currency);
    }

    @Override
    public int hashCode() {
        int result = Long.hashCode(amount);
        result = 31 * result + currency.hashCode();
        return result;
    }
}
Java

ここでは

amount と currency の両方で equals している
hashCode でも amount と currency の両方を使っている

ので、契約を守れています。

整数フィールドなら Integer.hashCode(value)Long.hashCode(value)
オブジェクトフィールドなら Objects.hashCode(field) を使うと楽です。


「良い hashCode」と「悪い hashCode」の違い

契約を守っていても、偏りすぎると性能が落ちる

契約的には

equals が true になるペアは同じ hashCode
equals が false でも同じ hashCode を返してOK(ただしほどほどに)

です。

極端な例として、こんな hashCode 実装を考えてみます。

@Override
public int hashCode() {
    return 1;
}
Java

これは一応「契約は守って」います。
equals が true なら hashCode も同じ 1 だからです。

でも、全てのオブジェクトが同じ hashCode なので

HashMapHashSet のバケツが全部一つに固まる
実質的に「リンクリスト or 配列の線形探索」のように遅くなる

という性能問題が起こります。

つまり、hashCode 契約を満たしていても
「違うオブジェクト同士が同じハッシュになるケースが多すぎる」と
パフォーマンスが悪くなります。

普通は「標準 API を素直に使う」で十分

初心者がいきなり「ハッシュ関数の質」を気にしすぎる必要はありません。

まずは

equals に使うフィールドを元に、
素直に hashCode を合成する(31 * result + … の定番パターン)

だけ守れば十分です。

Java 7 以降は java.util.Objects に便利メソッドがあります。

import java.util.Objects;

@Override
public int hashCode() {
    return Objects.hash(amount, currency);
}
Java

これを使えば
「複数フィールドからほどほどに良い hashCode を作る」
のは勝手にやってくれます。

重要なのは「フィールドと equals の整合性」であって、
ハッシュのアルゴリズムを自作することではありません。


ミュータブル(可変)なオブジェクトと hashCode の危険な関係

hashCode の元になるフィールドを変えてはいけない

もう一つ重要な点があります。

HashSet や HashMap に入れたあとで、
そのオブジェクトの hashCode に影響するフィールドを変更すると
コレクションが壊れます。

例として、こんなクラスを想像します。

public class User {

    private String email;  // equals / hashCode で使っているとする

    @Override
    public boolean equals(Object o) { ... email で比較 ... }

    @Override
    public int hashCode() { return email.hashCode(); }
}
Java

これを HashSet に入れてから email を変えるとどうなるか。

User u = new User("old@example.com");
Set<User> set = new HashSet<>();
set.add(u);

// ここで equals/hashCode の基準となるフィールドを変更
u.changeEmail("new@example.com");

System.out.println(set.contains(u));  // false になることがある
Java

最初に入れたときは「old の hashCode のバケツ」に入れられます。
ところが、後から email を変えると、hashCode は「new の hashCode」に変わります。

でも、HashSet は「old の hashCode のバケツ」に u を置きっぱなしなので、
contains は「new の hashCode のバケツ」を探しにいって、
「いないじゃん」となります。

これも非常に気づきにくいバグです。

値オブジェクトは「不変」にするのが安全

だから、hashCode をオーバーライドするような「値オブジェクト」は
基本的に「不変(immutable)」にするのがおすすめです。

フィールドを final にして、
コンストラクタで一度セットしたら二度と変えないようにする。

さっきの UserId や Money のサンプルも、フィールドは final でした。

private final String value;
Java

不変にしておけば

一度 HashSet / HashMap に入れたオブジェクトの hashCode が後から変わらない

ので、hashCode まわりのバグをかなり防げます。


まとめ:初心者が「hashCode 契約」で絶対に外さないポイント

最後に、あなたが今覚えておくとよいポイントを整理します。

equals をオーバーライドしたら、hashCode も必ずオーバーライドする
equals で比較に使うフィールドだけを元に、hashCode を計算する
equals が true になる2つのオブジェクトは、必ず同じ hashCode を返すようにする
hashCode に使っているフィールドを、HashSet / HashMap に入れたあとで書き換えない(できれば不変にする)
実装では Objects.hash(...)フィールドの hashCode を素直に使う だけでも十分

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