Java | Java 詳細・モダン文法:Stream API 深掘り – distinct

Java Java
スポンサーリンク

distinct を一言でいうと

Stream#distinct() は、
「ストリームの中から“重複している要素”を取り除き、“一意な要素だけ”を残す中間操作」 です。

イメージとしては、
「同じ値が何回も流れてきたら、最初の 1 回だけ通して、あとは全部スルーするフィルター」
だと思ってください。

List に対して new HashSet<>(list) をするのと似ていますが、
Stream のパイプラインの中で、他の操作と自然につなげて使えるのが distinct() です。


distinct の基本的な動き

単純な値(String / Integer)での例

まずは一番シンプルな例から。

import java.util.List;

public class DistinctBasic {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Alice", "Charlie", "Bob");

        List<String> unique =
                names.stream()
                     .distinct()
                     .toList();

        System.out.println(unique); // [Alice, Bob, Charlie]
    }
}
Java

ここで起きていることは、とても単純です。

ストリームに流れてくる要素を、
「これまでに見たことがあるか?」で判定し、
初めて出てきた値だけを次に流します。

2 回目以降に出てきた同じ値は、そこで落とされます。


distinct が「何をもとに重複判定するか」

equals と hashCode がすべての鍵

distinct() が「同じ」とみなすかどうかは、
その要素の equalshashCode の実装に完全に依存します。

StringInteger のような標準クラスは、
すでに「値が同じなら equals も true、hashCode も同じ」という実装になっているので、
そのまま distinct() を使えば期待通りに動きます。

一方、自分で定義したクラスの場合、
equals / hashCode をちゃんとオーバーライドしていないと、
「同じ内容なのに別物扱いされる」ことになります。


自作クラスで distinct を使うときの注意点

equals / hashCode を実装していない場合

次のコードを見てください。

import java.util.List;

class User {
    private final String name;
    User(String name) {
        this.name = name;
    }
    public String getName() { return name; }
}

public class DistinctUserExample {
    public static void main(String[] args) {
        List<User> users = List.of(
                new User("Alice"),
                new User("Bob"),
                new User("Alice")
        );

        List<User> unique =
                users.stream()
                     .distinct()
                     .toList();

        System.out.println(unique.size()); // 3 になる
    }
}
Java

"Alice" という名前の User を 2 回作っていますが、
distinct() を通しても 3 件のままです。

理由は、User クラスが equals / hashCode をオーバーライドしていないため、
「インスタンスの中身」ではなく「インスタンスの参照(アドレス)」で比較されているからです。

つまり、同じ名前でも「別のインスタンス」なので、
distinct() から見ると「全部違うもの」に見えてしまいます。

equals / hashCode を実装した場合

User に「名前が同じなら同じユーザーとみなす」という意味の equals / hashCode を実装してみます。

import java.util.Objects;

class User {
    private final String name;
    User(String name) {
        this.name = name;
    }
    public String getName() { return name; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User other = (User) o;
        return Objects.equals(this.name, other.name);
    }

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

この状態で先ほどの distinct() を実行すると、

List<User> unique =
        users.stream()
             .distinct()
             .toList();

System.out.println(unique.size()); // 2 になる
Java

今度は "Alice" が 1 件にまとまり、
"Alice", "Bob" の 2 件だけが残ります。

ここから分かる一番大事なポイントは、

「distinct の“重複判定”は equals / hashCode に完全に依存する」

ということです。


「特定のキーで重複排除したい」場合の設計

distinct は「要素全体」でしか判定できない

distinct() は、あくまで「要素そのもの」で重複判定します。

「User の name だけを見て重複排除したい」
「id だけをキーにしたい」

といった、「特定のプロパティだけで重複を判定したい」場合、
distinct() 単体では対応できません。

map でキーに変換してから distinct する

一番シンプルな方法は、
「先に map でキーだけに変換してから distinct() する」やり方です。

List<String> uniqueNames =
        users.stream()
             .map(User::getName)   // User -> String(名前)
             .distinct()           // 名前で重複排除
             .toList();
Java

これで「名前の重複排除」はできます。

ただし、この方法だと「User 自体」を重複排除することはできません。

Map や Set を使って「キーで一意な User」を作る

「name をキーにして、一意な User を取りたい」場合は、
Collectors.toMapCollectors.toCollection を使うパターンがよく使われます。

例えば、最後に出てきた User を残すなら:

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

Map<String, User> byName =
        users.stream()
             .collect(Collectors.toMap(
                     User::getName,          // キー:name
                     Function.identity(),    // 値:User 自身
                     (oldV, newV) -> newV    // 同じキーが来たら新しい方を採用
             ));

List<User> uniqueUsers = List.copyOf(byName.values());
Java

ここまで来ると少し応用寄りですが、
「distinct は“要素全体”でしか判定できない」
「特定のキーで一意にしたいなら、別の設計が必要」
という感覚だけ持っておけば十分です。


distinct の位置づけとパイプライン設計

filter / map / distinct の役割分担

Stream の中での distinct() の役割は、

「すでに決まっている“等価性(equals)”に従って、重複を取り除く」

ことです。

なので、パイプラインの中では、

条件で絞り込むのは filter
形を変えるのは map
重複を消すのは distinct

というふうに、責務を分けて考えると設計がきれいになります。

例えば、「20 歳以上のユーザーの“重複しない名前リスト”」なら:

List<String> names =
        users.stream()
             .filter(u -> u.getAge() >= 20) // 絞り込み
             .map(User::getName)           // 変換
             .distinct()                   // 重複排除
             .toList();
Java

この順番にすることで、
「何をしているか」が左から右に自然に読めます。


distinct のコストと意識しておきたいこと

内部では「これまでに見た要素」を覚えている

distinct() は、内部的には「これまでに見た要素」を Set のような構造に溜め込みながら処理します。

つまり、要素数が増えるほど、
「覚えておくためのメモリ」も増えていきます。

通常のサイズのリストなら気にしなくて構いませんが、
「とんでもなく大きなストリーム」や「無限ストリーム」に対して distinct() を使うと、
メモリを食い続けることになる、ということは頭の片隅に置いておくとよいです。


まとめ:distinct を自分の言葉で整理する

distinct() をあなたの言葉でまとめるなら、

「ストリームの中から、equals / hashCode に基づいて重複を取り除き、一意な要素だけを残す中間操作」
です。

特に意識しておきたいのは、

要素が標準クラス(String / Integer など)なら、そのまま期待通りに動くこと
自作クラスで使うときは、equals / hashCode を正しく実装しておく必要があること
「特定のプロパティだけで重複排除したい」場合は、map や Collectors を組み合わせる設計になること
filter / map / distinct の責務を分けて、パイプラインの意味が読みやすい順番で並べること

あたりです。

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