equals()とhashCode(): 契約と一般的な落とし穴

1. 概要

equals() と hashCode() は2つのシンプルに見えるメソッドですが、実装を誤ると HashSet が重複した要素を含むようになり、HashMap がキーを“失う”ことになります。これらのエラーは通常、例外をスローしません。プログラムは実行し続けますが、正しく動作しません。

この記事は、Javaが2つのメソッドに対して定めている契約(contract)を説明します。その後、一般的なエラーを取り上げ、各エラーに対して再現するためのコード例を含めています。この記事を読み終わった後、2つのメソッドを正しく実装できるようになり、record をいつ使うべきかを知り、テストを書いて検証する方法を理解できるようになります。

2. equals()の契約

デフォルトでは、Object.equals() は参照を比較します。つまり this == obj です。オーバーライドするときは、以下の5つの特性を、null以外のあらゆる参照に対して保つ必要があります。

  • 反射性(reflexive): x.equals(x) は常に true です。
  • 対称性(symmetric): x.equals(y) が true の場合、かつその場合に限り y.equals(x) が true です。
  • 推移性(transitive): x.equals(y) かつ y.equals(z) であれば x.equals(z) です。
  • 一貫性(consistent): オブジェクトが変わらなければ、何度呼んでも同じ結果を返す必要があります。
  • nullとの比較: x.equals(null) は常に false です。

Customer クラスの標準的な実装は次のようになります。

import java.util.Objects;

public final class Customer {
    private final String email;
    private final String name;

    public Customer(String email, String name) {
        this.email = email;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return Objects.equals(email, other.email)
                && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, name);
    }
}

各部分の説明です。

  • this == o は、2つの参照が同じオブジェクトを指しているときの短縮です。
  • instanceof Customer other はパターンマッチング(Java 16以上)を使用しています。この式は o が null のときに false を返すため、“nullとの比較”の特性は別途チェックせずに保証されます。
  • Objects.equals はフィールドが null でも安全です。
  • Objects.hash は equals() に使用した正確なフィールドからハッシュを計算します。

クラスは final で宣言されています。その理由については項目6で明らかになります。

3. hashCode()の契約

hashCode() には3つのルールがあります。

  1. 変更されていない同じオブジェクトに対して何度も呼んでも、同じ値を返す必要があります。
  2. a.equals(b) であれば a.hashCode() == b.hashCode() です。 これが最も重要なルールです。
  3. 異なる2つのオブジェクトが同じハッシュコードを持つことは許可されています(衝突)。ただし、ハッシュがよく分散していれば、コレクションの実行速度は速くなります。

ルール2の逆方向は真ではありません。同じハッシュコードでも、オブジェクトが等しいわけではありません。

ルール2がなぜ重要なのかを見るために、HashMap がキーを見つける方法を確認してください。最初に hashCode() を使ってバケットを選択します。その後、そのバケット内の要素を比較するために equals() を使用します。2つのオブジェクトが“等しい”がハッシュコードが異なる場合、それらは異なるバケットに存在し、equals() は決して呼び出されません。

4. エラー1: equals()をオーバーライドしてhashCode()を忘れる

これは古典的なエラーです。項目2のクラスから hashCode() を削除してみます。

public class Customer {
    private final String email;

    public Customer(String email) { this.email = email; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return email.equals(other.email);
    }
    // hashCode()がない
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));

System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false

2つのオブジェクトは equals() によって等しいです。しかし hashCode() はまだ Object のデフォルト実装であり、通常は各インスタンスで異なります。その結果、HashSet は“重複した”要素を含むようになり、contains() は false を返します。この結果はほぼ確実に発生します。2つのデフォルトハッシュが同じになることはめったにないため、エラーがより安定して再現するのは難しいです。

注記: IDEとSonarQubeなどのツールはこのエラーについて警告します。その警告を無視しないでください。

5. エラー2: オーバーライドではなくオーバーロード

このエラーははるかに見つけにくいです。

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // 誤り: パラメータはOrderで、Objectではない
        return other != null && id == other.id;
    }

    @Override
    public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);

System.out.println(a.equals(b));                  // equals(Order)を呼び出す
System.out.println(List.of(a).contains(b));       // equals(Object)を呼び出す
true
false

equals(Order) は新しいメソッド(オーバーロード)です。equals(Object) をオーバーライドしていません。コレクションは常に equals(Object) を呼び出すため、Object の参照比較がまだ使用されています。

防止方法は非常に簡単です。常に @Override を付けてください。上記のメソッドに @Override を追加すると、コンパイラはすぐにエラーを報告します。

6. エラー3: 継承によって対称性を破壊する

Point クラスとフィールド color を追加する部分クラス ColorPoint を考えます。

public class Point {
    protected final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(cp) && color.equals(cp.color);
    }

    @Override
    public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false

対称性が破壊されました。さらに、p.equals(cp) が true ですが、2つのオブジェクトのハッシュが異なるため、項目3のルール2も違反しています。

フィールドを意味のある方法でサブクラスに追加しながら、instanceof との契約全体を保つ方法はありません。3つの選択肢があります。

  • クラスを final で宣言するか、record を使用します。これが最も簡単な方法です。
  • instanceof の代わりに getClass() != o.getClass()を使用します。 この方法では、Point と ColorPoint は決して等しくなりません。契約は保たれますが、フィールドを追加しないサブクラス(例えばプロキシ)は親クラスと等しくなくなります。
  • 合成を優先します: ColorPoint が Point を継承する代わりに、ColorPoint が Point を含むようにします。

7. エラー4: キー内で変更可能なフィールドを使用する

hashCode() はオブジェクトが HashSet に追加される時点で計算されます。その後、ハッシュ計算に参加するフィールドが変更された場合、オブジェクトは古いバケットに留まります。

public class Product {
    private String sku;
    public Product(String sku) { this.sku = sku; }
    public void setSku(String sku) { this.sku = sku; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Product p && Objects.equals(sku, p.sku);
    }

    @Override
    public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);

product.setSku("SKU-2");

System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1

オブジェクトはセット内に残りますが、見つけることができず、削除することもできません。その結果、小さなメモリリークが発生し、ロジックが誤って動作します。

注記: equals()/hashCode() では不変(immutable)なフィールドのみを使用してください。変更可能なフィールドを使用する場合は、HashMap のキーまたは HashSet の要素として存在している間、オブジェクトを変更しないでください。正しい方法は、オブジェクトを削除してから、変更してから、再度追加することです。

8. エラー5: BigDecimalと配列の罠

JDK内のいくつかの型は equals() を期待どおりに実装していません。

8.1. BigDecimalはスケールも比較する

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

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

BigDecimal.equals() は、スケールが異なるため 1.0 と 1.00 を異なるものと見なします。Money クラスが equals() で BigDecimal を使用する場合は、事前にスケールを正規化してください。例えば、コンストラクタで amount.setScale(2, RoundingMode.HALF_UP) を呼び出します。stripTrailingZeros() も正規化できますが、負のスケールが出ることがあります。例えば 100 のような場合です。

8.2. 配列は参照を比較する

配列は equals() をオーバーライドしていません。したがって new int[]{1, 2}.equals(new int[]{1, 2}) は false を返します。フィールドが配列の場合は、Arrays.equals() と Arrays.hashCode() を使用してください。この罠は次の項目で見るように record にも適用されます。

9. recordを使用してほとんどのエラーを回避する

Java 16から、record は equals()、hashCode()、toString() を自動的に生成し、すべてのコンポーネント(フィールド)に基づきます。

public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);

System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true

record は final クラスであり、各フィールドは final であり、2つのメソッドは契約を正しく満たすように生成されます。項目4、5、6のエラーは解消されます。項目7のエラーもほぼ解消されます。ただし、コンポーネントが変更可能なオブジェクト(例えば可変の List)の場合を除きます。

record を使用するときに覚えておくべき2つのポイントがあります。

  • コンポーネントが配列の場合も、参照による比較が行われます。代わりに List を使用してください。
  • コンポーネント BigDecimal はまだスケールの罠に陥ります。コンパクトコンストラクタで正規化してください。
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

この宣言では、new Money(new BigDecimal("1.0"), "VND") と new Money(new BigDecimal("1.00"), "VND") は等しくなります。両方ともスケール2に正規化されるためです。

10. equals()とhashCode()のテスト

メインの動作に対するテストを行う必要があります。以下は、項目2の Customer クラスのテストで、JUnit 5とAssertJを使用したものです。

import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class CustomerTest {

    @Test
    void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
        var a = new Customer("an@example.com", "An");
        var b = new Customer("an@example.com", "An");

        assertThat(a).isEqualTo(b);
        assertThat(b).isEqualTo(a);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());

        Set<Customer> set = new HashSet<>(Set.of(a));
        assertThat(set).contains(b);
    }

    @Test
    void customer_shouldNotEqualNullOrOtherType() {
        var a = new Customer("an@example.com", "An");

        assertThat(a).isNotEqualTo(null);
        assertThat(a).isNotEqualTo("an@example.com");
    }
}

テスト1は対称性、“等しいなら同じハッシュ”ルール、および HashSet 使用時の実際の動作を確認します。テスト2は null の場合と別の型の場合を確認します。

すべてのケースを手作業で書くのはかなり手間がかかります。EqualsVerifierライブラリは、1行だけで全体の契約を確認します。

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.16.1</version>
    <scope>test</scope>
</dependency>
@Test
void equalsContract() {
    EqualsVerifier.forClass(Customer.class).verify();
}

フィールドを hashCode() で忘れた場合、変更可能でない(not final)フィールドを使用した場合、または instanceof を使用しながらクラスが final でない場合、テストは失敗し、どのルールが違反されたかを示すメッセージが表示されます。

11. OVERRIDEすべきではない時

すべてのクラスが equals()/hashCode() をオーバーライドする必要があるわけではありません。

  • インスタンスによる自然な識別を持つクラス, such as Thread, PaymentService, or Spring beans. 2つのサービスは別々のオブジェクトであり、参照比較が正しいです。
  • JPAエンティティ: すべてのフィールドを使用しないでください。また、自動生成された @Id を無思考に使用しないでください。id が persist 前に null であるため、保存後にハッシュが変わります。一般的な方法は、変更不可な業務キーによって比較することです。別の方法は、id が null でないときは id で比較し、hashCode() は常にクラスの定数を返す(例:getClass().hashCode())ことです。
  • Lombok の @Data をエンティティまたは双方向関係を持つクラスに使用する: このアノテーションは equals()/hashCode() を生成し、すべてのフィールドを使用します。これにより、2つのエンティティが互いに参照する場合に StackOverflowError が発生したり、意図しない遅延ロードがトリガーされたりする可能性があります。

12. 結論

equals() と hashCode() 間の契約は、1つのコア ルールを中心に回転します。2つの等しいオブジェクトは同じハッシュコードを持つ必要があります。実際のほとんどのエラーは5つの原因から発生します。

  • hashCode() をオーバーライドするのを忘れる。
  • equals(Order) をオーバーロードして equals(Object) をオーバーライドしない。
  • 継承によって対称性を破壊する。
  • キーで変更可能なフィールドを使用する。
  • BigDecimal と配列の罠。

新しいコードでは、value objectに対して record を優先してください。通常のクラスでは、final と宣言し、常に @Override を付け、不変なフィールドのみを使用してください。最後に、EqualsVerifier を使用したテストを追加して、ツールに契約の確認を行わせてください。

javaequalshashcodecollectionsrecord