PR

Java:equalsメソッドの基本を1からわかりやすく

Java

JavaのObjectクラスが提供するequalsメソッドは、Javaを学ぶ上で必ず理解しておきたい重要なメソッドです。equalsメソッドの仕組みや使い方を誤解したまま現場に出ると、バグを招いたり、コードの保守性が下がったりする可能性があります。

このページでは、equalsメソッドの基本的な概念から実装原理、そして現場で押さえておきたい注意点やベストプラクティスまでを1から順を追って解説します。

スポンサーリンク

Javaのequalsメソッドとは

Objectクラスのequalsとは

Javaの最上位クラスであるObjectクラスには、すべてのクラスが継承できる汎用的なメソッドがいくつか用意されています。equalsメソッドもそのひとつで、「2つのオブジェクトが等しいかどうかを判定する」ためのメソッドです。

public boolean equals(Object obj) {
    return (this == obj);
}

Objectクラスでのデフォルト実装は上記のように単純で、this == obj(==演算子)を使って参照が同じかを比較するだけです。つまり、同じインスタンス(同一のメモリアドレス)であるかどうかを判定しています。したがって、デフォルトのequalsでは「実体が同じかどうか」ではなく「同じオブジェクトを参照しているかどうか」を見ています。

== と equalsの違い

Javaを学び始めたときに混乱しがちなのが==演算子とequalsメソッドです。(参考 Javaの演算子の基本

  • ==演算子: 参照型に対しては「同じオブジェクトかどうか(参照先が同じかどうか)」をチェックする。
  • equalsメソッド: 「論理的に等価かどうか」をチェックすることを意図したメソッド。ただし、デフォルト実装では結局==と同じく参照比較を行う。

多くのクラス(例えばStringやIntegerなどのラッパークラス)はequalsメソッドをオーバーライドし、「中身の値が同じであれば等価とみなす」という実装をしています。自作クラスでも同じように「このクラスの論理的な同一性とは何か」を定義してequalsメソッドをオーバーライドするのが一般的です。

equalsメソッドの実装例とポイント

equalsメソッドの一般的な実装方法

自分のクラスでequalsをオーバーライドする典型的な例として、次のようなパターンをよく目にします。Javaの公式ドキュメントでも広く推奨されるテンプレートです。

@Override
public boolean equals(Object o) {
    // 1. 自身と同じオブジェクトかチェック(同じ参照ならtrue)
    if (this == o) {
        return true;
    }
    // 2. nullチェック(nullならfalse)
    if (o == null) {
        return false;
    }
    // 3. クラスが異なるならfalse
    if (this.getClass() != o.getClass()) {
        return false;
    }
    // 4. キャストして型をそろえる
    MyClass other = (MyClass) o;
    
    // 5. フィールドごとに論理的な等価性を判定
    // 例: Stringやintを比較
    if (!Objects.equals(this.fieldA, other.fieldA)) {
        return false;
    }
    if (this.fieldB != other.fieldB) {
        return false;
    }
    // ... 比較対象のフィールドをすべて比較
    return true;
}

上記のステップに示したように、

  1. まず同じ参照かどうかを判定します。同じ参照なら確実に同じオブジェクトですからtrue。
  2. 渡された引数がnullならfalse (null参照と何を比較しても等価にはならない)。
  3. クラス(型)が異なるならfalse。本来同じクラスのオブジェクト同士のみ比較すべきなので型が違う時点で等価ではありえない。(※継承やインターフェイスの場合はもう少し考慮が必要な場合があります。)
  4. キャストして同じ型に変換してからフィールドを比較します。
  5. 自分のクラスにとって「論理的に等価」かどうかの指標となるフィールドをすべて比較します。

このとき、フィールド比較においてオブジェクト型のフィールドであればObjects.equals(a, b)を使うと便利です。基本型でも特殊ケース(例えば浮動小数点数)は誤差などを考慮して比較方法をよく考える必要があります。

クラス階層をまたぐequals(継承がある場合)

継承があるクラス階層でequalsをオーバーライドする際は、型チェックの部分(上記でthis.getClass() != o.getClass()としていたところ)について、慎重な設計が必要です。

  • 同じクラス同士だけで比較する場合はgetClass()の一致をチェックする。
  • 継承関係でも比較を許容したい場合はinstanceofを使う方法もある。

ただし、継承構造が複雑になると、「あるクラスでは等価だが、サブクラスでは等価でない」といった状況になりうるため混乱を招きがちです。Javaのコアライブラリ(例: java.util.Date など)でもそうした問題が散見されます。実務では、クラス階層で無闇にequalsの動作を変えないのが無難です。

equalsの契約 (Equivalence Relation)

equalsをオーバーライドするなら、次の5つの特性を必ず守る必要があります。これはJava言語仕様や公式ドキュメントにも明記されています。

  1. 反射律 (Reflexive)
    x.equals(x) は常にtrueであるべき。つまり同じインスタンスは自分自身と等価。
  2. 対称律 (Symmetric)
    x.equals(y)がtrueなら、y.equals(x)もtrueであるべき。
  3. 推移律 (Transitive)
    x.equals(y)y.equals(z)が共にtrueなら、x.equals(z)もtrueであるべき。
  4. 一貫性 (Consistent)
    x.equals(y)の結果は、xまたはyのフィールドが変更されない限り、一貫して同じ結果を返すべき。
  5. nullに対する比較
    x.equals(null)は常にfalseを返すべき (xがnullでない場合)。

これらの特性を満たさないequalsメソッドを実装してしまうと、コレクションの振る舞いがおかしくなるなどの副作用が大きく、バグの温床になりやすいです。
特に対称律推移律が崩れやすいので注意が必要です。例えば、x.getClass() == y.getClass()でなくinstanceofを使う実装をすると、サブクラス同士の比較で破綻する可能性があります。

equalsとhashCodeの関係

equalsとhashCodeはセットでオーバーライド

HashMapやHashSet、LinkedHashMapといったハッシュベースのコレクションを扱う際に、equalsとともに重視されるのがhashCodeメソッドです。
Javaの仕様では以下のように定められています:

「equalsがtrueを返す2つのオブジェクトは、同じhashCodeを返さなければならない」

言い換えれば、equalsをオーバーライドしたら必ずhashCodeも整合性を保つようにオーバーライドしなければなりません。でないと、ハッシュベースのコレクションに格納したときに誤動作を起こす可能性があります。

hashCodeの実装例

equalsで使うフィールドをすべて考慮し、一貫性ある値を生成する必要があります。実装例:

@Override
public int hashCode() {
    int result = 17;
    // 例: int fieldB, String fieldAを考慮
    result = 31 * result + fieldB; 
    result = 31 * result + (fieldA == null ? 0 : fieldA.hashCode());
    // 他のフィールドもすべて掛け合わせる
    return result;
}

このように、一意性を担保しやすい素数(31など)を使って積み重ねるのが一般的です。ここでもObjects.hash(...)を使って書くことも可能です。
Lombokの@EqualsAndHashCodeアノテーションを使えば、自動生成できるので便利ですが、内部的には同様のロジックを行っています。

equalsメソッドの裏側の概念とJavaの動作原理

ここからはもう少し低レベルな視点、すなわちJavaのJVM上でのオブジェクトの扱いやメモリアドレス、ガーベジコレクションなどについて概観してみます。

equalsの理解を深めるには、「Javaでのオブジェクト参照の仕組み」を知ることが非常に重要です。

参照型とメモリアドレス

Javaでは、あるクラスのインスタンスを生成するとヒープ領域にオブジェクトが確保されます。ローカル変数やフィールドとして宣言された「クラス型の変数」には、そのヒープ上のオブジェクトへの参照(実装的にはポインタに近いもの)が格納されます。

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();

上記の場合、obj1obj2は同じMyClass型のオブジェクトですが、ヒープ領域上で異なるアドレスを指しているため、obj1 == obj2はfalseになります。しかし、そのクラスでequalsをオーバーライドして「全フィールドの値が同じならtrue」と定義していれば、obj1.equals(obj2)はtrueになり得ます。
したがって、==は「同じアドレスを指すか」を見ており、equalsは(オーバーライドされているなら)「論理的に等価か」を見ているというわけです。

GC(ガーベジコレクション)が参照に及ぼす影響

Javaにはガーベジコレクター(GC)があり、不要となったオブジェクトを自動的に回収します。「不要」とは、どの変数からも到達できなくなったオブジェクトのことを指します。GCによってオブジェクトが回収されると、以降は参照不可となります。
equalsメソッドを呼び出す段階では、当然ながらオブジェクトは生きている(参照が存在している)のでGCの影響は特に受けません。ただし、equalsの実装が複雑すぎると実行時間が伸びGCのタイミングに影響が出る場合はあります。このようにパフォーマンスやメモリ管理と隣接する考え方も、実務レベルでは必要になります。

Stringやラッパークラスなどの特別な扱い

StringやIntegerなどのラッパークラスは、equalsメソッドをすでに論理的な等価判定でオーバーライドしています。例えば、

String s1 = "Java";
String s2 = "Java";
System.out.println(s1 == s2);      // true (リテラルプールの仕組み)
System.out.println(s1.equals(s2)); // true

String s3 = new String("Java");
System.out.println(s1 == s3);      // false
System.out.println(s1.equals(s3)); // true

このように、==は常に参照が同じかどうかですが、equalsは中身の文字列が同じならtrueとなるよう実装されています。また、リテラルプール(Intern Pool)によって、同じ文字列リテラルは同じオブジェクトを使い回すなどの最適化があります。
この仕組みがあるため、Stringリテラル同士の==がたまたまtrueになるケースはありますが、意図的に参照の同一性を確認したい場合を除いては、基本的に文字列比較はequalsを使うというのが定石です。

equalsが現場で重要となるシーン

実務では以下のようなケースでequalsの正しい実装・理解が必要になることが多いです。

  1. コレクション操作 (HashMap, HashSetなど)
    • 重複判定にequalsとhashCodeが使われるため、正しい実装でないと要素が正しく保存・検索されない。
  2. ユニットテスト
    • テストコードでオブジェクトの等価性をアサートするとき(例: assertEquals(expected, actual))、内部でequalsが呼ばれる。正しいequalsが実装されていないとテストが通らない。
  3. ビジネスロジックの重複チェック
    • 例えば「同じ顧客レコードかどうか」を判定するためにフィールドの値を比較するロジックが必要になるとき、equalsを定義するとシンプルに書ける。
  4. 特殊な値型の実装
    • ID型や、金額を表すMoneyクラスなど、「値オブジェクト」と呼ばれるクラスを実装するときもequalsは必須。中身(値)が同じなら等価として扱うのが典型だからです。

よくある落とし穴

equalsをオーバーライドしてhashCodeをオーバーライドしない

前述の通り、equalsとhashCodeはセットで整合性をとって実装しなければなりません。これを怠ると、HashMapやHashSetなどハッシュを使うコレクションで問題を起こしやすいです。
テストの段階では気付けず、リリース後に深刻なバグを招くケースもあるため、実装時に必ず両方を確認しましょう。

同じフィールドを漏らして比較していない

equalsで比較対象となるフィールドを一つでも忘れてしまうと、本来等価でないオブジェクトがequalsでtrueを返してしまう可能性があります。自分で書くよりIDEやLombokのコード生成機能を使うほうが漏れを防止できます。

instanceofとgetClass()の使い分け

if (o instanceof MyClass)として型チェックすると、MySubClass(MyClassを継承したサブクラス)とも比較が実行される可能性が生まれます。
これが型階層によっては対称律・推移律を破壊する可能性をはらむため、原則として同一クラス内のオブジェクトのみ比較したい場合はgetClass()でチェックするのが安全と言われています。
継承構造を含めた柔軟性が必要かどうか、要件によって判断するところです。

浮動小数点型の比較

doublefloatなどの浮動小数点型は誤差を伴うため、==演算子や通常のequals比較で完全一致を判定するのは危険です。実務でも金額計算にfloat/doubleを使うと誤差が積み重なって正確性を失う恐れがあるため、BigDecimalを使うケースが多いです。equalsの判断でも誤差許容範囲を設けて比較するなど、工夫が必要になることがあります。

まとめ JavaのObjectクラスのequalsメソッド

  • デフォルトのequalsは参照(==)の比較しか行わない。
  • 論理的な同一性を定義したい場合はequalsをオーバーライドする。
  • equalsをオーバーライドするときはhashCodeも必ずセットで整合性を保つ。
  • equalsの契約(反射律・対称律・推移律など)を破らないように注意。
  • 参照型と値の比較は別物なので、==equals の違いを使い分ける。
  • 現場ではコレクション操作やビジネスロジックの重複判定などで特に重要。
タイトルとURLをコピーしました