AIによる要約
Javaの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に残ります。

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 を実行します。
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<String> emails = new HashSet<>();
for (ImportUser user : users) {
if (!emails.add(user.getEmail())) {
errors.add("メールアドレスが重複しています: " + user.getEmail());
}
}
この書き方なら、メールアドレスの重複を見ていることが明確です。新人のうちは、読み手がすぐに理解できる実装を選ぶのも大事です。
レビュー前のセルフチェック
Set重複削除の確認リスト
- 何を重複とみなすかを仕様で確認したか
- 自作クラスなら
equals/hashCodeがあるか equalsとhashCodeの比較フィールドが揃っているか- 可変フィールドを重複判定に使っていないか
- 単純なキーだけをSetに入れる方が読みやすくないか
この記事とあわせて読みたい
まとめ
Setは便利ですが、何を同じとみなすかを自動で判断してくれるわけではありません。自作クラスを使う場合はequals/hashCodeを確認し、業務上の重複基準と実装が一致しているかをレビュー前に見直しましょう。



