はじめに:「distinctって重複を消すだけじゃないの?」

distinct()を使えば重複が消える——それは知っている。でも試験本番で「あれ、これどっちが正しい?」と手が止まった経験はないでしょうか。

実はdistinct()は、何をもって”同じ”と判断するかという内部動作を理解していないと、ひっかけ問題でそのまま失点します。本記事では、distinct()の内部動作・equals()との関係・順序付きストリームと順序なしストリームでの挙動の違いまで、試験に出るポイントに絞って解説します。


distinct()の基本:何をしているのか

Stream.distinct()は、ストリームの要素から重複を取り除いた新しいストリームを返す中間操作です。

import java.util.List;
import java.util.stream.Collectors;

List<Integer> list = List.of(1, 2, 2, 3, 3, 3, 4);
List<Integer> result = list.stream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result);
// [1, 2, 3, 4]

ここまでは直感通りです。問題は「どうやって重複を判定しているか」です。


重複判定の仕組み:equals()とhashCode()が使われる

distinct()は内部的に**Object.equals()メソッドを使って重複を判定します。さらに、パフォーマンスのためにhashCode()も合わせて使用**します。つまり、HashSetに要素を追加するときと同じ仕組みです。

これが何を意味するかというと、equals()とhashCode()を正しくオーバーライドしていないクラスは、意図通りに重複除去されないということです。

import java.util.List;
import java.util.stream.Collectors;

class Point {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
    // equals()もhashCode()もオーバーライドしていない
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

List<Point> result = List.of(p1, p2).stream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result.size());
// 2  ← 重複として扱われない!

p1とp2は同じ座標を持っていますが、equals()をオーバーライドしていないため別オブジェクトとして判定され、両方残ります。

equals()とhashCode()を正しくオーバーライドすれば、期待通りの結果になります。

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

class Point {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

List<Point> result = List.of(p1, p2).stream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result.size());
// 1  ← 正しく重複除去される

順序付きストリームと順序なしストリームでの違い

distinct()の挙動は、ストリームが順序付き(ordered)か順序なし(unordered)かによって変わります。

順序付きストリーム(List由来など)

Listから生成したストリームは順序付きです。この場合、distinct()は出現順序を保持します。つまり、最初に登場した要素が残ります。

import java.util.List;
import java.util.stream.Collectors;

List<String> list = List.of("b", "a", "b", "c", "a");
List<String> result = list.stream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result);
// [b, a, c]  ← 最初に出現した順序が保たれる

順序なしストリーム(Set由来など)

Setから生成したストリームは順序なしです。この場合、どの要素が残るかは不定です。

import java.util.Set;
import java.util.stream.Collectors;

Set<String> set = Set.of("b", "a", "c");
// 順序は保証されない
ストリームの元順序付きdistinct()後の順序
List○出現順を保持
Arrays.asList()○出現順を保持
Set✕不定
Stream.of()○出現順を保持
並列ストリーム(ordered)△保持されるが処理コストが高い

並列ストリームでのdistinct()

並列ストリームでdistinct()を使うと、正確な結果は得られますが、パフォーマンスコストが高くなります。

順序付き並列ストリームでは、重複除去後も順序を保つために各スレッドの結果をマージする処理が必要になるためです。

import java.util.List;
import java.util.stream.Collectors;

List<Integer> list = List.of(1, 2, 2, 3, 3, 3);
List<Integer> result = list.parallelStream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result);
// [1, 2, 3]  ← 結果は正しいが、内部処理はコストがかかる

unordered()を組み合わせると、順序の保証を放棄する代わりに並列処理の効率が上がります。

List<Integer> result = list.parallelStream()
        .unordered()
        .distinct()
        .collect(Collectors.toList());

// 結果に含まれる要素は同じだが、順序は不定

試験で狙われるひっかけポイント

ひっかけ①:equals()だけオーバーライドしてhashCode()を忘れる

distinct()はHashSetと同じ仕組みで判定するため、equals()のみオーバーライドしてhashCode()を忘れると、重複除去が正しく機能しません。試験コードでは意図的に片方だけオーバーライドしたクラスが登場することがあります。

📌 equals()とhashCode()は必ずセットでオーバーライドする。これはJava全般の基本ルールであり、distinct()でも同様に適用される。

ひっかけ②:順序付きストリームで「どの要素が残るか」を問われる

List.of("a", "b", "a")に対してdistinct()を呼んだとき、残るのは**最初に出現した”a”**です。「後から出現した要素が残る」という選択肢は誤りです。

ひっかけ③:distinct()は中間操作である

distinct()は中間操作であり、戻り値はStream<T>です。終端操作を呼ぶまで実際の処理は行われません(遅延評価)。

Stream<Integer> s = List.of(1, 2, 2, 3).stream().distinct();
// この時点ではまだ何も処理されていない

List<Integer> result = s.collect(Collectors.toList());
// collect()が呼ばれて初めて処理される
// [1, 2, 3]

ひっかけ④:null要素の扱い

distinct()はnullも要素として扱います。複数のnullがある場合、1つに集約されます。

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

List<String> list = Arrays.asList("a", null, "b", null);
List<String> result = list.stream()
        .distinct()
        .collect(Collectors.toList());

System.out.println(result);
// [a, null, b]  ← nullは1つに集約される

ただし、List.of()はnullを許容しないため、Arrays.asList()を使う点に注意してください。


まとめ:distinct()で押さえるべき4点

ポイント内容
重複判定の基準equals()とhashCode()の両方を使用
順序付きストリーム出現順を保持して重複除去
順序なしストリーム残る要素の順序は不定
操作の種類中間操作(遅延評価)

distinct()はシンプルに見えて、試験では**equals()/hashCode()の実装有無とストリームの順序付き・順序なしの違い**が重点的に問われます。コードを読む際は「このクラスはequals()をオーバーライドしているか」「このストリームは順序付きか」を必ず確認する習慣をつけておきましょう。