🧂

hashCode が同じはずの自作クラスが HashSet 内で重複する

2021/05/06に公開

TL;DR

HashSet で自作クラスを使用する場合、 hashCode メソッドだけでなく equals メソッドも準備する必要があります。

確認した JDK

openjdk version "1.8.0_265"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_265-b01)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.265-b01, mixed mode)

動かしてみる

次のように、各プロパティフィールドが同じ値を持つ場合は hashCode() の結果が同じになるようにしつつ、 equals は true とならないクラスを準備します。

public class MyModel {
    private String name;
    private int age;

    public MyModel(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        return false; // ちょうどよいサンプル書くのめんどうなので無理やり false します。
    }

    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + age;
        return result;
    }
}

import org.junit.Test;

import java.util.HashSet;

import static org.junit.Assert.*;

public class MyModelTest {

    @Test
    public void testHashSet() {
        HashSet<MyModel> set = new HashSet<>();

        // 同じプロパティを持つオブジェクトを2つ放り込む
        set.add(new MyModel("taro", 10));
        set.add(new MyModel("taro", 10));

        // hashCode が同じなのでサイズは 1 つになるはず
        assertEquals(1, set.size());
    }

}

しかし結果はこうなります。

java.lang.AssertionError: 
Expected :1
Actual   :2

なので hashCode と同じ挙動になるように equals をちゃんと実装すると、サイズは1になります。

public class MyModel {
    private String name;
    private int age;

    public MyModel(String name, int age) {
        this.name = name;
        this.age = age;
    }

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

        MyModel myModel = (MyModel) o;

        if (age != myModel.age) return false;
        return name != null ? name.equals(myModel.name) : myModel.name == null;
    }

    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + age;
        return result;
    }
}

なぜ

Java SE 上ではあくまで「このセット内に、(e==null ? e2==null : e.equals(e2)) となる要素e2がない場合は、指定された要素eをこのセットに追加します。」というように、 hashCode でなく equals となってるようです。 "Hash"Set なのに…

https://docs.oracle.com/javase/jp/8/docs/api/java/util/HashSet.html#add-E-

OpenJDK では HashSet は内部で HashMap を使用しており、セットする値は HashMap の key として取り扱っています。
なので HashMap の put の実装に依存するわけですね。
このあたりです。

  • HashSet::add

https://github.com/openjdk/jdk/blob/jdk8-b120/jdk/src/share/classes/java/util/HashSet.java#L219

  • HashMap::putVal

https://github.com/openjdk/jdk/blob/jdk8-b120/jdk/src/share/classes/java/util/HashMap.java#L633-L634

Discussion