テックブログ

JavaのequalsとhashCode入門:SetとMapでハマらないために

JavaのequalsとhashCode入門:SetとMapでハマらないために

Javaのequalsは、オブジェクト同士を「同じもの」として扱うかを判定するメソッドで、hashCodeHashSetHashMapなどが要素を効率よく探すために使う値です。equalsだけを実装してhashCodeを揃えないと、Setで重複排除できない、Mapのキーで検索できないといった問題が起きます。実務では、equalsとhashCodeを暗記項目ではなく、コレクションの動作を支えるセットの仕組みとして理解することが重要です。

1. equalsとhashCodeとは何か

1-1. equalsは「同じもの」と判定するためのメソッド

equalsは、2つのオブジェクトを同じものとして扱うかを判定するためのメソッドです。Javaでは、オブジェクトの中身を比較したいときに重要になります。

何も実装しない場合、Objectクラスのequalsは基本的に参照の同一性を見ます。つまり、同じ内容を持っていても、別々にnewされたインスタンスなら別物として扱われます。実務では、ユーザーIDや商品コードのような値が同じなら同一とみなしたい場面が多いため、クラスごとにequalsをオーバーライドすることがあります。

String a = new String("Java");
String b = new String("Java");

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

このコードでは、==は参照が同じかを見て、equalsは文字列の内容が同じかを見ています。注意点は、自作クラスでは自動的に中身比較になるわけではないことです。何をもって同じとするかは、クラスの設計として明確にする必要があります。

1-2. hashCodeはHash系コレクションで使われる値

hashCodeは、HashSetHashMapなどのHash系コレクションが、要素やキーを探すために使う整数値です。単なるおまけのメソッドではありません。

Hash系コレクションは、すべての要素を先頭から順番に比較するのではなく、まずhashCodeを使って探す場所を絞ります。そのうえで、同じ場所にある候補に対してequalsで本当に同じかを確認します。つまり、hashCodeは検索の入口、equalsは最終確認に近い役割です。

ここで大事なのは、hashCodeが完全に一意である必要はないことです。異なるオブジェクトが同じhashCodeになる「衝突」は起こりえます。大切なのは、equalsで等しいと判定されるオブジェクトは、必ず同じhashCodeを返すことです。

1-3. なぜ2つをセットで考える必要があるのか

equalshashCodeは、HashSetやHashMapで正しく動かすためにセットで考える必要があります。片方だけ実装すると、見た目には正しそうでもコレクション内で期待通り動かないことがあります。

equalsで「同じ」と判定する2つのオブジェクトが、異なるhashCodeを返すと、Hash系コレクションは別の場所を探しに行く可能性があります。その結果、本来同じものとして扱いたい要素が重複したり、Mapに入れたキーを別インスタンスで取り出せなかったりします。

たとえば、学生番号が同じなら同じ学生とみなすクラスを作るなら、equalshashCodeも学生番号を基準にする必要があります。比較基準が片方だけにしか反映されていない状態は、HashSetやHashMapでのバグにつながります。

2. equalsだけでは不十分な理由

2-1. HashSetで重複排除できない例

equalsだけを実装してhashCodeを実装しないと、HashSetで重複排除が期待通りに動かないことがあります。Setは重複を許さないはずなのに、同じ意味のオブジェクトが複数入ることがあります。

HashSetは、まずhashCodeで格納場所を判断し、その後にequalsで同じ要素かを確認します。equalsでは同じと判定されるのにhashCodeが違うと、そもそも同じ候補として扱われにくくなります。その結果、同じ学生番号のオブジェクトを2つ入れても、別要素として残ることがあります。

Set<Student> students = new HashSet<>();

students.add(new Student("S001", "山田"));
students.add(new Student("S001", "山田"));

System.out.println(students.size()); // 期待は1だが、2になることがある

このコードは、Student側でequalsだけを実装していると問題が起きる例です。注意点は、Listでは気づきにくい問題が、HashSetを使った瞬間に表面化することです。重複排除したいなら、比較基準とhashCodeを必ず揃えましょう。

2-2. HashMapでキー検索できない例

HashMapでも、equalshashCodeが揃っていないと、putした値をgetできないことがあります。これは実務でもかなり分かりにくいバグになります。

HashMapは、キーを登録するときも検索するときもhashCodeを使います。登録時と検索時で同じ意味のキーを使っているつもりでも、hashCodeが一致しなければ、Mapは違う場所を探しに行く可能性があります。そのため、equalsでは同じはずなのにgetnullになることがあります。

Map<Student, String> scores = new HashMap<>();

scores.put(new Student("S001", "山田"), "A");

String result = scores.get(new Student("S001", "山田"));
System.out.println(result); // 期待は"A"だが、nullになることがある

このコードでは、同じ学生番号と名前を持つ別インスタンスで検索しています。注意点は、同じ変数を使ってgetする場合は問題が見えにくいことです。別インスタンスでも同じキーとして扱いたいなら、equalshashCodeの両方が必要です。

2-3. equalsとhashCodeの契約

Javaでは、equalshashCodeには守るべき契約があります。特に重要なのは、equalsで等しい2つのオブジェクトは、同じhashCodeを返さなければならないという点です。

この契約があるからこそ、HashSetやHashMapは高速に要素を探せます。まずhashCodeで候補を絞り、必要に応じてequalsで比較するという流れが成り立ちます。逆に契約を破ると、コレクションの内部では「同じもののはずなのに別の場所にある」ような状態になり、期待通りに検索や重複排除ができなくなります。

ただし、同じhashCodeなら必ずequalsもtrueである必要はありません。hashCodeの衝突は許されます。必要なのは、「equalsがtrueならhashCodeも同じ」という向きのルールです。この違いを理解しておくと、過度にhashCodeの一意性を気にしすぎずに済みます。

3. 実装例で理解する

3-1. 悪い例:equalsだけを実装したクラス

悪い例として、equalsだけを実装し、hashCodeを実装していないクラスがあります。この状態では、通常の比較では正しく見えても、HashSetやHashMapで問題が起きます。

たとえば、学生番号が同じなら同じ学生とみなしたい場合、equalsで学生番号を比較するのは自然です。しかし、hashCodeを実装しないと、Object由来のhashCodeが使われ、同じ学生番号でも別インスタンスなら違う値になることがあります。これがHash系コレクションでの不具合につながります。

import java.util.Objects;

public class Student {
    private final String studentNo;
    private final String name;

    public Student(String studentNo, String name) {
        this.studentNo = studentNo;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Student student)) return false;
        return Objects.equals(studentNo, student.studentNo);
    }

    // hashCodeを実装していない
}

このコードでは、studentNoが同じならequalsはtrueになりますが、hashCodeが揃いません。注意点は、コンパイルエラーにならないことです。動いているように見えて、HashSetやHashMapでだけ不自然な挙動になるため、見落としやすいバグです。

3-2. 良い例:equalsとhashCodeを両方実装したクラス

良い例は、equalsで使うフィールドと同じ基準でhashCodeも実装することです。これにより、HashSetやHashMapでも同じオブジェクトとして扱いやすくなります。

学生番号で同一性を判断するなら、equalshashCodestudentNoを基準にします。名前は変更される可能性がある、または同一性に関係しないと決めるなら、比較基準から外します。大切なのは、「何をもって同じとするか」を両方のメソッドで揃えることです。

import java.util.Objects;

public class Student {
    private final String studentNo;
    private final String name;

    public Student(String studentNo, String name) {
        this.studentNo = studentNo;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Student student)) return false;
        return Objects.equals(studentNo, student.studentNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentNo);
    }
}

このコードでは、equalshashCodeの両方でstudentNoを使っています。注意点は、あとからequalsの比較フィールドを変えたら、hashCodeも見直すことです。片方だけ変更すると、契約が崩れてHash系コレクションで問題が起きます。

3-3. IDEで自動生成するときの注意点

equalshashCodeは、IDEで自動生成するのが実務では一般的です。ただし、どのフィールドを比較対象にするかは自分で判断する必要があります。

IntelliJ IDEAやEclipseなどのIDEでは、フィールドを選ぶだけでequalshashCodeを生成できます。これにより、書き間違いや片方だけの実装を減らせます。ただし、すべてのフィールドを何となく選ぶと、変更される値まで比較対象になり、SetやMapで問題が起きることがあります。

たとえば、学生番号は変わらないが名前は変更される可能性があるなら、同一性の基準は学生番号だけにする判断もあります。IDEに任せるのは実装の形であって、設計判断ではありません。生成前に「このクラスは何が同じなら同じなのか」を決めることが重要です。

4. SetとMapでの動き方

4-1. HashSetが重複を判断する流れ

HashSetは、hashCodeで候補を探し、equalsで重複かどうかを判断する流れで動きます。Setだからといって、いきなり全要素をequalsで比較するわけではありません。

要素を追加するとき、HashSetはまず追加対象のhashCodeを使って格納場所を決めます。同じ場所に既存要素があれば、equalsで同じかを確認します。同じと判定されれば追加されず、違うと判定されれば別要素として扱われます。この流れにより、高速な重複判定ができます。

そのため、equalsだけ正しくても、hashCodeが不適切だと重複判定までたどり着きにくくなります。HashSetで重複が消えないときは、equalsだけでなくhashCodeも確認しましょう。

4-2. HashMapがキーを探す流れ

HashMapは、キーのhashCodeで探す場所を絞り、equalsで目的のキーかを確認する流れで値を取得します。Mapのキーに自作クラスを使うときは特に重要です。

putするとき、HashMapはキーのhashCodeを使って保存場所を決めます。getするときも、検索キーのhashCodeを使って同じ場所を探します。その場所に候補があればequalsで一致確認をします。つまり、保存時と検索時で同じ意味のキーが同じhashCodeを返す必要があります。

たとえば、社員番号をキーにしたEmployeeオブジェクトをMapのキーにするなら、社員番号を基準にequalshashCodeを実装します。別インスタンスでも同じ社員番号なら同じキーとして扱いたい、という要件があるなら、この実装が必須になります。

4-3. 可変フィールドをキーに使う危険性

HashSetの要素やHashMapのキーに使うオブジェクトでは、equalshashCodeに使うフィールドを後から変更しないことが重要です。変更すると、コレクション内で見つけられなくなることがあります。

HashMapは、登録時のhashCodeをもとに場所を決めます。その後、キーのフィールドが変わってhashCodeも変わると、検索時には別の場所を探しに行ってしまいます。オブジェクト自体はMapの中にあるのに、getできないという分かりにくい状態になります。

Map<Student, String> map = new HashMap<>();

Student student = new Student("S001", "山田");
map.put(student, "A");

// 仮にstudentNoを後から変更できる設計だと危険
// student.setStudentNo("S999");

System.out.println(map.get(student)); // 見つからない可能性がある

このコードは、同一性に使うフィールドが可変だと危険であることを示しています。注意点は、Mapに入れたあとにキーの状態を変えないことです。キーに使うクラスでは、比較に使うフィールドをfinalにするなど、不変に寄せる設計が安全です。

5. よくある落とし穴

5-1. equalsだけ実装してhashCodeを忘れる

最もよくある落とし穴は、equalsだけを実装してhashCodeを忘れることです。これはコンパイルエラーにならないため、実行時の挙動で初めて気づくことがあります。

通常のequals比較だけをテストしていると、問題なく見えることがあります。しかし、HashSetやHashMapで使った瞬間に、重複排除できない、キー検索できないという問題が起きます。つまり、単体の比較では正しくても、コレクションで正しいとは限りません。

自作クラスでequalsをオーバーライドしたら、必ずhashCodeもセットで実装する、と習慣化しましょう。IDEの警告も出ることが多いので、無視せず確認することが大切です。

5-2. hashCodeに可変フィールドを使う

hashCodeに可変フィールドを使うと、HashSetやHashMapに入れたあとで見つからなくなる危険があります。これは実務でもかなり厄介なバグです。

たとえば、名前やステータスのように後から変わる値をhashCodeに含めると、その値が変わった時点でhashCodeも変わります。HashMapやHashSetは登録時の位置を前提に探すため、変更後のhashCodeでは別の場所を探してしまいます。その結果、containsやgetが失敗することがあります。

比較に使うフィールドは、できるだけ不変なIDやコードに寄せると安全です。どうしても可変フィールドを使う場合は、HashMapのキーやHashSetの要素として使ったあとに変更しない運用が必要になります。

5-3. 比較に使うフィールドの基準が曖昧になる

equalshashCodeの設計では、何をもって同じオブジェクトとみなすかを曖昧にしないことが大切です。基準が曖昧だと、実装者によって比較対象が変わります。

たとえば、学生を学生番号で同一とみなすのか、学生番号と名前の両方で同一とみなすのかでは、実装が変わります。商品なら商品IDだけでよいのか、バージョンや店舗IDも含めるのかを決める必要があります。この判断は文法ではなく、業務ルールやドメイン設計の問題です。

落とし穴は、「なんとなく全フィールドを比較する」ことです。全フィールド比較は一見正確そうですが、変更可能な表示名や説明文まで含めると、同じエンティティを別物として扱ってしまうことがあります。比較基準は、クラスの役割に合わせて決めましょう。

6. まとめ

6-1. equals/hashCodeで最初に押さえること

equalshashCodeで最初に押さえるべきことは、HashSetやHashMapの動作に直接関わる仕組みであるという点です。暗記項目ではなく、コレクション利用時の実務的な基礎です。

equalsは同じものかを判定し、hashCodeはHash系コレクションが探す場所を決めるために使います。equalsで等しいオブジェクトは、必ず同じhashCodeを返す必要があります。一方で、同じhashCodeだから必ずequalsがtrueである必要はありません。

自作クラスをHashSetの要素やHashMapのキーにするなら、equalshashCodeを必ずセットで確認しましょう。特にIDやコードを持つクラスでは、「何を同一性の基準にするか」を先に決めることが重要です。

6-2. 実装時のチェックリスト

equalshashCodeを実装するときは、比較基準、可変性、Hash系コレクションでの利用を確認すると失敗を減らせます。

  • equalsを実装したらhashCodeも実装しているか
  • equalshashCodeで同じ比較基準を使っているか
  • 何をもって同じオブジェクトとみなすかを説明できるか
  • 可変フィールドをhashCodeに含めていないか
  • HashSetの重複排除で期待通りに動くか
  • HashMapのキーとして別インスタンスでもgetできるか
  • IDE自動生成時に不要なフィールドまで含めていないか
  • hashCodeの衝突を過度に恐れず、equalsとの契約を守っているか

このチェックリストを使うと、equalshashCodeの実装漏れや比較基準のズレに気づきやすくなります。JavaのSetやMapでハマらないためには、コレクションの裏側でこの2つがどう使われるかを理解しておくことが大切です。

7. 参考リンク