JavaのSetで重複削除できない原因をequalsとhashCodeから解説する記事のアイキャッチ。Java道場風の学習空間で若手エンジニアがHashSetを学ぶイラスト。

実装・エラー解決

Setで重複削除できない原因をJavaで解説

AIによる要約

JavaのSetは重複を持たないコレクションですが、自作クラスを入れたときに期待通り重複削除されないことがあります。原因の多くは、equalsとhashCodeの実装、または何を重複とみなすかの設計です。

新人SE
新人SE
Setに入れれば重複は消えると思っていたのに、同じデータが残ります。なぜですか?
ポンコツSE
ポンコツSE
Setは魔法の重複削除ではありません。何を同じとみなすかは、入れるオブジェクトのequalsとhashCodeで決まります。

Setは重複を許さないコレクションです。文字列や数値なら、直感通りに重複が消えることが多いです。

しかし、自分で作ったクラスをHashSetに入れると、同じ内容に見えるのに重複が残ることがあります。

この記事のポイント

  • Setの重複判定はequalsとhashCodeに依存する
  • 自作クラスでは同一判定を実装しないと重複削除できないことがある
  • 何を重複とみなすかは業務ルールで決める
  • Streamのdistinctでも同じ観点が必要

Stringなら重複削除できる

まず、Stringのようにequals/hashCodeが適切に実装されている型では、Setで重複削除できます。

Set<String> codes = new HashSet<>();
codes.add("A001");
codes.add("A001");
codes.add("B001");

System.out.println(codes.size()); // 2

A001 を2回 add しても、Setに残るのは A001 と B001 の2件です。

自作クラスではそのままだと重複する

次に、商品コードを表す自作クラスを作った場合を考えます。

public class ProductCode {
    private final String value;

    public ProductCode(String value) {
        this.value = value;
    }
}
Set<ProductCode> codes = new HashSet<>();
codes.add(new ProductCode("A001"));
codes.add(new ProductCode("A001"));

System.out.println(codes.size()); // 2になる可能性がある

この例の ProductCode は equals と hashCode を上書きしていません。A001 と表示されても、別々に作った2つのオブジェクトがSetに残ります。

ProductCodeをSetに入れたとき、equalsとhashCodeがない場合はsize 2、ある場合はsize 1になることを比較する図解。
Setで重複しないかは、equalsとhashCodeの実装で変わります。

equalsとhashCodeを実装する

商品一覧に、コードが同じ A001 で表示順だけが10と20の2行があるとします。商品コードが一致したら1件にまとめる仕様なら、equals が true を返し、両方が同じ hashCode を返す必要があります。

下は修正後の ProductCode.java の全文です。表示順は値として保持し、比較とハッシュ計算には商品コードを使います。

package example.catalog;

import java.util.Objects;

public final class ProductCode {
    private final String value;
    private final int displayOrder;

    public ProductCode(String value, int displayOrder) {
        this.value = Objects.requireNonNull(value);
        this.displayOrder = displayOrder;
    }

    public int displayOrder() {
        return displayOrder;
    }

    @Override
    public boolean equals(Object obj) {
        return obj instanceof ProductCode other && value.equals(other.value);
    }

    @Override
    public int hashCode() {
        return Objects.hash(value);
    }
}

この実装で2行を HashSet に入れると、残るのは1件です。equals はそのままで hashCode() だけを Objects.hash(value, displayOrder) に変えると、同じ商品として比較できてもハッシュ値が異なります。この入力をJDK 17で実行すると、Setに2件残ります。

Streamのdistinctでも同じ

重複を含む元の List から stream().distinct() を呼び、Setと同じ2行で比較します。修正後は1件になり、順序付きストリームでは先に出た表示順10のオブジェクトが残ります。商品コードが A001 と B001 の場合は、表示順が同じでも2件とも残ります。

HashSetとdistinctの件数を同じ入力で試す

ZIPには本文の ProductCode.java、ProductCodeTest.java、pom.xml、READMEを収録しています。JDK 17とMaven 3を用意し、展開先で mvn test を実行します。

サンプルZIPをダウンロード GitHubでコード全文と実行手順を見る

ProductCodeTest.javaの全文を見る
package example.catalog;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertSame;

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

class ProductCodeTest {
    private final ProductCode first = new ProductCode("A001", 10);
    private final ProductCode second = new ProductCode("A001", 20);

    @Test
    void equalCodesHaveTheSameHashEvenWithDifferentDisplayOrder() {
        assertEquals(first, second);
        assertEquals(first.hashCode(), second.hashCode());
    }

    @Test
    void hashSetKeepsOneEntryForTheSameCode() {
        var set = new HashSet<>(List.of(first, second));
        assertEquals(1, set.size());
    }

    @Test
    void orderedStreamKeepsTheFirstEntryForTheSameCode() {
        List<ProductCode> unique = List.of(first, second).stream().distinct().toList();
        assertEquals(1, unique.size());
        assertSame(first, unique.get(0));
        assertEquals(10, unique.get(0).displayOrder());
    }

    @Test
    void differentCodesStaySeparateEvenWithTheSameDisplayOrder() {
        ProductCode different = new ProductCode("B001", 10);
        List<ProductCode> inputs = List.of(first, different);
        assertNotEquals(first, different);
        assertEquals(2, new HashSet<>(inputs).size());
        assertEquals(2, inputs.stream().distinct().count());
    }
}

修正版で4テストが成功します。hashCode() だけに表示順を混ぜて再実行すると、ハッシュ値の一致、HashSetの件数、distinct() の件数を確かめる3件が失敗します。distinct() でもこの入力は2件残るので、同一性の実装を直す箇所は同じです。

業務上の重複とJava上の重複は違う

現場で大事なのは、Javaの仕組みだけではありません。業務上、何を重複とみなすかを決める必要があります。

public class Customer {
    private String customerNo;
    private String name;
    private String phoneNumber;
}

このクラスで、顧客番号が同じなら重複なのか、電話番号が同じなら重複なのか、名前まで同じなら重複なのかで実装は変わります。

重複判定を決める質問

  • 業務上の一意キーは何か
  • 画面に同じように見えるだけで、本当に同じデータか
  • DBのユニーク制約と同じ基準か
  • 大文字小文字や前後空白をどう扱うか
  • 削除済みデータや履歴データを同じ扱いにしてよいか

現場でよくある失敗

CSV取込や外部API連携では、重複排除がよく出てきます。新人がSetに入れれば解決すると考えて実装すると、レビューで止まることがあります。

// レビューコメント例
// Setで重複排除していますが、ImportUserにequals/hashCodeがありません。
// 今回はメールアドレスが同じなら重複とする仕様なので、
// emailを基準にするか、emailだけをSetに入れる形にしてください。

ここで大事なのは、Setを使うかどうかではなく、重複判定の基準が仕様と一致しているかです。

単純なキーだけSetに入れる方法もある

自作クラスのequals/hashCodeを実装するより、単純なキーだけをSetに入れる方が分かりやすい場合もあります。

Set&lt;String&gt; emails = new HashSet&lt;&gt;();

for (ImportUser user : users) {
    if (!emails.add(user.getEmail())) {
        errors.add(&quot;メールアドレスが重複しています: &quot; + user.getEmail());
    }
}

この書き方なら、メールアドレスの重複を見ていることが明確です。新人のうちは、読み手がすぐに理解できる実装を選ぶのも大事です。

レビュー前のセルフチェック

Set重複削除の確認リスト

  • 何を重複とみなすかを仕様で確認したか
  • 自作クラスならequals/hashCodeがあるか
  • equalsとhashCodeの比較フィールドが揃っているか
  • 可変フィールドを重複判定に使っていないか
  • 単純なキーだけをSetに入れる方が読みやすくないか

この記事とあわせて読みたい

関連記事JavaのequalsとhashCode入門 equals はオブジェクトの同一性を判断する重要なメソッドですが、 hashCode とセットで考えないと HashSet や HashMap で不具合につながります。この記事では、初学者がつまずきやすい理由を現場目線で整理します。 記事を読む 関連記事HashMapで値が取れない原因はequalsかも HashMap に値を入れたのに get で取れない場合、キーに使っているクラスの equals と hashCode が原因かもしれません。この記事では、初学者向けにMapの仕組みと現場で起きる失敗を整理します。 記事を読む 関連記事Javaのequalsをオーバーライドする注意点 Javaで equals をオーバーライドするときは、単にIDEで生成すればよいわけではありません。何を同じとみなすのか、 hashCode と揃っているか、変更される値を比較に使っていないかを確認する必要があります。 記事を読む

まとめ

Setは便利ですが、何を同じとみなすかを自動で判断してくれるわけではありません。自作クラスを使う場合はequals/hashCodeを確認し、業務上の重複基準と実装が一致しているかをレビュー前に見直しましょう。

-実装・エラー解決
-, ,