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 で管理する
よく使われる実装は、ConcurrentHashMap や Set を使って
「このキーはもう見たことがあるか?」を覚えておく方式です。
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(山田) です。
これは「先勝ち」のルールです。
業務によっては、「最後に出てきたものを優先したい(後勝ち)」こともあります。
その場合は、toMap+values() のパターンも候補になります。
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)」のように書き換えられないか眺めてみてください。
その小さな書き換えが、
「データの“同一性”を意識して設計できるエンジニア」への、
確かな一歩になります。
