🎉

equals,hashcodeについて

2021/09/08に公開

はじめに

オブジェクトの比較を行う上で重要となる等値、等価の違い、およびそれに関係する
equals,hashcodeの使い方が曖昧だったので勉強がてらまとめてみる。

等値、等価の違い

等値とは

2つの変数が参照しているオブジェクトが同一であることを等値という。
参照型の変数はオブジェクトを格納しているメモリのアドレスを保持しており、
アドレスをもとにメモリに格納されたオブジェクトの情報を参照する。
(だからこそ、参照型というのだが。。。)
そのため、図の通り、同じアドレスを保持している変数同士は同じインスタンスを指し示す。
等値であることを判定するためには変数に格納されているアドレスを比較するので、
==演算子を利用して比較する。

等価とは

2つの変数が参照している異なるインスタンス同士の中身が等しいことを等価という。
基本的に等価であることを判定するための条件はクラスごとに開発者自身が
実装する必要がある。
それらの条件定義に使われるのがequals()である。
equals()はObjectクラスに備わっているが、
基本的には==演算子を利用した等値判定しか行っていない。

Object.java
public class Object {
    public boolean equals(Object obj) {
	// ==演算子では変数のアドレスが等しことを判定するので、厳密な等価判定が行えない。
        return (this == obj);
    }
}

これは等値であれば等価であることが言えるので、そのための苦肉の策と考えられる。
ただ、このままでは正確な等価判定を行えない。
そのため、オブジェクトの中身が等しいことを調査するために、equalsメソッドを
オーバーライドして実装する必要がある。
また、以下で説明するようにequalsメソッドを実装した時は、hashcodeメソッドも実装しなければならない。
hashcodeメソッドの説明とオーバーライドする理由についてこれから説明していく。
※ちなみにプリミティブ型は等値=等価である。

hashcodeメソッドとは

オブジェクトのハッシュ値を取得するためのメソッドである。
ハッシュ値とはオブジェクトに紐付けられた値であり、オブジェクトを特定するための値。
等価との関係性として、等価→ハッシュ値が等しいという関係性が成り立たなければならない。
ただし、ハッシュ値はすべてのオブジェクトに対して一意ではないため、
ハッシュ値が等しい→等価という関係は必ずしも成り立たない。

hashcodeメソッドを実装する理由

Effective Java(原本)ではhashcodeメソッドを実装する理由を
以下のように述べている。

You must override hashCode() in every class that overrides equals(). Failure to do so will result in a violation of the general contract for Object.hashCode(), which will prevent your class from functioning properly in conjunction with all hash-based collections, including HashMap, HashSet, and Hashtable.

もともと、hashcodeメソッドのお約束事として、equalsメソッドとhashcodeメソッドは同時に
実装する事になっており、それを守らないと、HashMapやHashSetのようなhashコレクションが
正常に機能しなくなると書いてある。

また、オブジェクトがたくさんのフィールドを持っていた場合、それらを1つ1つ比較すると時間がかかる。
その点、ハッシュ値であれば、計算した値を単純に比較するだけであり、
ハッシュ値が等しい場合にオブジェクトの中身を比較すればよいので、等価判定にかかる時間が
短くなる。

HashMap,HashSetについて

これらのクラスは要素ごとに対応するハッシュコードから対象の要素を検索している。
要素がオブジェクトであった場合、オブジェクトでhashcodeが定義されていなかった場合、
同じオブジェクトでなければ、hashcodeは異なるものとして計算されてしまう。
これはObjectクラスの標準実装でそのような処理になっているからである。
そのため、本来であれば等価であるオブジェクトが異なるものとして(等価ではない)判定されてしまう。
そのため、hashcodeメソッドを実装する必要がある。

例えば、HashSetを例にしてみると、本来であれば等価であるオブジェクトが代入された場合、
重複を許さないSetは等価であるオブジェクト同士を1つのものとしてみなさなければならないが、
hashcodeメソッドが適切に実装されてないと、オブジェクトの中身に関係なく、新規作成されたオブジェクトを異なるものとしてみなしてしまう。

Book book1 = new Book(1, '三四郎');
Book book2 = new Book(1, '三四郎');
Set<Book> books = new HashSet<>();
books.add(book1)
books.add(book2)

等値、等価の実装方法

ここでは以下のBookクラスを例にして、等値、等価をコード上で実装する方法を説明していく。

Book.java
public class Book {
  int id;

  String title;

  Book(int id, String title) {
    this.id = id;
    this.title = title;
  }
}

等値

オブジェクト同士を==演算子で比較して、等しいことを確認する。

Main.java
public class Main {
  public static void main(String[] args) {
    Book book1 = new Book(1, "三四郎");
    // book1と同じオブジェクトを参照するようにしている。
    Book book2 = book1;
    // 両方とも同じオブジェクトを参照しているため、等値となる。
    System.out.println(book1 == book2); // true
  }
}

等価

等価の実装は基本的には手動で実装するよりも、IDEの自動生成やライブラリなどを利用したほうが
わざわざ面倒な実装をしなくてすむので生産性が高まり、おすすめである。
今回はそれらの方法を紹介していく。

IDEの自動生成

ここではIntellijを利用した実装方法について紹介していく。
Intellijには標準で対象となるクラスにequals,hashcodeを実装してくれる機能が備わっている。
基本的には等価で行う処理は値の比較だけなので、自動生成で事足りるはずだ。

なお、Intellijで自動生成を行う手順は以下の通りである。

  1. Command + Nで自動生成できるメソッド一覧を表示する。

  2. equals and hashcodeを選択する。

後は、等価比較の対象となるフィールドを選択すれば、自動生成してくれる。

まず、最初にIDによる比較で等価を判定する方法を実装していく。

IDによる比較

Book.java
public class Book {
  /**
   * オブジェクト同士が等価であることを判定するためのメソッド
   * 等価の方法によって、内容は多少異なるが、基本的にやる流れとしては、
   * 
   * 1. 引数との等値比較
   * 2. 引数の型となるクラスの比較
   * 3. 等価判定(基本的にはインスタンス変数の値比較)
   * 
   * @param o 比較対象オブジェクト
   * @return 比較結果
   */
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    Book book = (Book) o;

    return id == book.id;
  }

  @Override
  public int hashCode() {
    return id;
  }
}

ID同士による比較のため、equalsメソッドの最後でIDの比較を行っている。
また、hashcodeメソッドに関しては、IDが異なれば、等価ではないので、
ハッシュコードの値をIDそのものとして返している。

**IDとタイトルによる比較

IDとタイトルを比較して、等しい場合に等価と判定するように実装する。
なお、どのテンプレートを選択するかによって、実装内容が異なるので、
テンプレートごとの違いも紹介する。

Intellij Defaultの場合

public class Book {
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    Book book = (Book) o;

    if (id != book.id) return false;
    return title.equals(book.title);
  }

  @Override
  public int hashCode() {
    int result = id;
    result = 31 * result + title.hashCode();
    return result;
  }
}

equalsに関しては、最後にタイトル文字列による比較処理が加わっただけでそれ以外に違いが見られない。
一方、hashcodeではtitleのハッシュ値とidのハッシュ値*31を加えた値を最終的なハッシュ値としている。

Java7+の場合

public class Book {
  // equalsはほぼIntellij Defaultと同じため省略

  @Override
  public int hashCode() {
    return Objects.hash(id, title);
  }
}

こちらはさきほどのような計算処理が入っておらず、Objectsクラスに実装されているhashメソッドを呼び出してハッシュコードを計算している。こちらのほうが、複雑な計算処理を記述することなく、標準のメソッドを呼び出すだけで済むのでシンプルである。実際、以下のようにjavadocにも複数フィールドのハッシュ値計算に便利であると書かれている。

This method is useful for implementing Object.hashCode() on objects containing multiple fields.

ただし、以下に書かれているように1つのフィールドだけからなるオブジェクトの等価比較の場合は
Objects.hashとObjects.hashcodeでの実行結果が異なってしまう。

Warning: When a single object reference is supplied, the returned value does not equal the hash code of that object reference.

これはそれぞれの実装の中身を見てみるとよくわかる。

Objects.java
// hashcode()メソッドからハッシュコードを出力している。
public static int hashCode(Object o) {
	return o != null ? o.hashCode() : 0;
}

public static int hash(Object... values) {
	return Arrays.hashCode(values);
}
Arrays.java
public static int hashCode(Object a[]) {
	if (a == null)
	    return 0;

	int result = 1;

	// hashcode()メソッドのハッシュコードに対して更に計算を行って、
	// ハッシュのハッシュを計算している。
	for (Object element : a)
	    result = 31 * result + (element == null ? 0 : element.hashCode());

	return result;
}

このように、hashcodeではハッシュコードを出力しているだけなのに対し、hashではハッシュのハッシュを計算している。
そのため、以下のような使い分けをするのがベストであろう。

オブジェクトが

  • 1つのメンバで構成されている場合、フィールドの値そのものをハッシュ値として定義する。
  • 2つ以上のメンバで構成されている場合、Objects.hashメソッドでハッシュ値を計算する。

3つのハッシュコード関連メソッドの違い

  • Objects.hash(Object... values):複数メンバのハッシュ値を計算するのに最適。
  • Objects.hashcode :オブジェクトがnullの場合、0を戻り値とする。
  • Object.hashcode:オブジェクトがnullの場合、NullPointerExceptionが例外として発生する。

lombokを利用した方法

lombokライブラリを利用すれば、わざわざ面倒な実装をしなくても、
対象となるクラスに@EqualsAndHashCodeアノテーションをつければ等価判定に必要な処理を
ライブラリ側で勝手に実装してくれる

Book.java
// オブジェクトクラスに以下のアノテーションを付ける。
@EqualsAndHashCode
public class Book {
  int id;

  String title;

  Book(int id, String title) {
    this.id = id;
    this.title = title;
  }
  
  // equals,hashcodeメソッドをオーバライドする必要なし。
}

ただし、この方法ではライブラリ側の実装に依存してしまうため、システム固有の処理を組みたい場合には向かない。

参考記事

equalsとhashcodeを同時にオーバライドする理由
ObjectとObjectsクラスのハッシュ関連メソッドの特徴

Discussion