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つのルールがあります。
- 変更されていない同じオブジェクトに対して何度も呼んでも、同じ値を返す必要があります。
a.equals(b)であればa.hashCode() == b.hashCode()です。 これが最も重要なルールです。- 異なる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 を使用したテストを追加して、ツールに契約の確認を行わせてください。