Java Tips | コレクション:distinctByKey

Java Java
スポンサーリンク

distinctByKey は「“このキーで一意”をコードに刻む」技

Stream の distinct() は、「要素そのものが同じかどうか」で重複を消します。
でも業務では、「ユーザーIDが同じなら重複」「メールアドレスが同じなら重複」のように、
“あるキーだけを見て一意にしたい”場面がとても多いです。

そのときに使うパターンが、ここでいう distinctByKey です。
「要素からキーを取り出す関数」を渡して、そのキー単位で重複を取り除くイメージを持ってください。


まずは素朴な例:「ユーザーIDで一意にする」

User の List から「IDごとに1件だけ」残す

次のような User クラスを考えます。

class User {
    private final String id;
    private final String name;

    User(String id, String name) {
        this.id = id;
        this.name = name;
    }
    public String getId()   { return id; }
    public String getName() { return name; }
}
Java

同じ ID を持つユーザーが複数いる List。

List<User> users = List.of(
        new User("u001", "山田"),
        new User("u002", "佐藤"),
        new User("u001", "山田(重複)")
);
Java

これを「ID ごとに1件だけ」にしたいとします。
distinct() だと、equals / hashCode の実装次第になってしまい、
「IDだけ見て重複判定したい」という意図を表現しづらいです。

そこで distinctByKey(User::getId) のようなユーティリティを用意します。


distinctByKey ユーティリティの基本実装

「見たことのあるキーかどうか」を Set で管理する

よく使われる実装は、ConcurrentHashMapSet を使って
「このキーはもう見たことがあるか?」を覚えておく方式です。

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;

public final class Distincts {

    private Distincts() {}

    public static <T> Predicate<T> distinctByKey(
            Function<? super T, ?> keyExtractor
    ) {
        Set<Object> seen = ConcurrentHashMap.newKeySet();
        return t -> seen.add(keyExtractor.apply(t));
    }
}
Java

使い方はこうなります。

List<User> distinct =
        users.stream()
             .filter(Distincts.distinctByKey(User::getId))
             .toList();

distinct.forEach(u ->
        System.out.println(u.getId() + " : " + u.getName()));
// u001 : 山田
// u002 : 佐藤
Java

ここで深掘りしたいポイントは三つです。

一つ目は、「keyExtractor が“要素からキーを取り出す関数”である」ことです。
User::getId を渡せば、「ID をキーとして一意にする」ことになります。

二つ目は、「seen.add(キー) の戻り値(boolean)をそのまま Predicate の結果にしている」ことです。
Set#add は、初めての要素なら true、すでにある要素なら false を返します。
つまり、「初めて見たキーだけ true → filter で残る」という仕組みです。

三つ目は、「ConcurrentHashMap.newKeySet() を使っているので、並列Streamでも安全に使える」ことです。
HashSet だとスレッドセーフではないため、parallelStream() と組み合わせると危険です。


どの要素を残すか:「先勝ち」か「後勝ち」か

distinctByKey は「最初に出てきた要素」を残す

上の実装では、「同じキーが複数回出てきたとき、最初の1件だけが残る」動きになります。

users の順番が
u001(山田), u002(佐藤), u001(山田・重複)
なら、残るのは最初の u001(山田) です。

これは「先勝ち」のルールです。

業務によっては、「最後に出てきたものを優先したい(後勝ち)」こともあります。
その場合は、toMapvalues() のパターンも候補になります。

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

List<User> distinctLast =
        users.stream()
             .collect(Collectors.toMap(
                     User::getId,
                     Function.identity(),
                     (u1, u2) -> u2 // 後勝ち
             ))
             .values()
             .stream()
             .toList();
Java

ここでの重要ポイントは、
「distinctByKey は“先勝ち”であることが多いので、“後勝ち”が欲しいときは別パターンを選ぶ」ことです。

「どの要素を残すか」は業務ルールそのものなので、
“先勝ちでいいのか?”を一度立ち止まって考えるクセをつけてください。


distinctByKey をユーティリティとして使うメリット

「何で一意にしているか」がコードから一目で分かる

distinctByKey(User::getId) と書いてあると、
「この Stream は“ID で一意にしている”んだな」とすぐ分かります。

もしこれを毎回手書きすると、こうなります。

Set<String> seen = new HashSet<>();
List<User> distinct = new ArrayList<>();
for (User u : users) {
    if (seen.add(u.getId())) {
        distinct.add(u);
    }
}
Java

やっていることは同じですが、
「ID で一意にしている」という意図が、コードのノイズに埋もれてしまいます。

distinctByKey ユーティリティの一番の価値は、
「一意性の基準(キー)を、ラムダでハッキリ書ける」ことです。


よくある応用例

例1:メールアドレスで一意なユーザー一覧を作る

List<User> uniqueByMail =
        users.stream()
             .filter(Distincts.distinctByKey(User::getEmail))
             .toList();
Java

「同じメールアドレスに複数アカウントが紐づいている」ようなデータから、
メールアドレス単位で代表1件だけを取りたいときに使えます。

例2:日付で一意な売上日一覧を作る

class Sale {
    LocalDate date;
    int amount;
    // getter...
}

List<LocalDate> saleDates =
        sales.stream()
             .filter(Distincts.distinctByKey(Sale::getDate))
             .map(Sale::getDate)
             .sorted()
             .toList();
Java

「売上明細は複数行あるけれど、“売上があった日付の一覧”だけ欲しい」
というような集計前処理にも、distinctByKey はよく使われます。


まとめ:distinctByKey で身につけてほしい感覚

distinctByKey は、
単に「便利なフィルタ関数」ではなく、
「“何をもって同一とみなすか”を、コードに明示するための道具」です。

Function<? super T, ?> keyExtractor で、「要素からキーを取り出す」ルールを渡す。
内部では Set で「見たことのあるキー」を覚え、初めてのキーだけ true を返す。
並列Streamでも安全に使えるように、ConcurrentHashMap.newKeySet() などスレッドセーフな構造を使う。
「先勝ちでいいのか? 後勝ちがいいのか?」を業務ルールとして決めたうえで、distinctByKey か toMap かを選ぶ。

あなたのコードのどこかに、
「一意にしたいけど、とりあえず distinct() している」箇所があれば、
それを一度「distinctByKey(User::getId)」のように書き換えられないか眺めてみてください。

その小さな書き換えが、
「データの“同一性”を意識して設計できるエンジニア」への、
確かな一歩になります。

タイトルとURLをコピーしました