Java | Java 標準ライブラリ:Stream の遅延評価

Java Java
スポンサーリンク
スポンサーリンク

「遅延評価」をざっくりイメージする

まず感覚から固めます。

Stream の「遅延評価(lazy evaluation)」とは、

「stream().filter().map() と“パイプラインを組み立てただけ”では、
まだ一切処理は実行されていない。
“最後のゴール(終端操作)”を呼んだ瞬間に、初めて必要な分だけ流れが動き出す」

という性質のことです。

つまり、
filter や map をいくら並べても、それだけではまだ「準備段階」。
collect や forEach などの「終端操作」を呼ぶまで、実行は先延ばしにされています。

これが「遅延評価」です。


中間操作と終端操作を見分ける

中間操作(intermediate operation)

filter, map, sorted, distinct, limit などは「中間操作」です。

特徴は、

戻り値として「新しい Stream」を返す
それ自体を呼んでも、まだ実行されない(パイプラインの“設計図”を増やしているだけ)

ということです。

例を見ましょう。

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

public class LazyIntermediate {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3);

        Stream<Integer> stream =
                list.stream()
                    .filter(n -> {
                        System.out.println("filter: " + n);
                        return n % 2 == 1;
                    })
                    .map(n -> {
                        System.out.println("map: " + n);
                        return n * 2;
                    });

        System.out.println("ここまで来ても、まだ何も実行されていない");
    }
}
Java

このコードを実行しても、コンソールには何も出ません。

filter や map の中で println しているのに、です。

なぜかというと、
stream() → filter → map まででは、まだ「終端操作」が呼ばれていないからです。

「こういう流れで処理してね」というパイプラインを組み立てただけで、
“実際に水は流していない状態”だと思ってください。

終端操作(terminal operation)

collect, forEach, count, findFirst, anyMatch, allMatch, noneMatch, sum などが終端操作です。

特徴は、

戻り値が「値」だったり「void」であり、新しい Stream は返さない
呼ばれた瞬間に、パイプライン全体が一気に評価される(流れが実行される)

ということです。

さっきのコードに forEach を付けてみます。

import java.util.Arrays;
import java.util.List;

public class LazyWithTerminal {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3);

        list.stream()
            .filter(n -> {
                System.out.println("filter: " + n);
                return n % 2 == 1;
            })
            .map(n -> {
                System.out.println("map: " + n);
                return n * 2;
            })
            .forEach(x -> System.out.println("forEach: " + x));

        System.out.println("ここで初めて全部実行された");
    }
}
Java

今度は、

filter: 1
map: 1
forEach: 2
filter: 2
filter: 3
map: 3
forEach: 6

のように出てきます。

forEach という終端操作を呼んだ瞬間に、
filter → map → forEach の流れが動き出して、
要素が 1 つずつ先頭から処理されていきます。


「1 要素ずつ、段階ごとに処理される」という実行イメージ

ここが初心者には特に大事なポイントです。
Stream は「段階ごとに全件処理 → 次の段階に全件渡す」ではありません。

さっきの例で、要素 1, 2, 3 の流れを追ってみます。

まず 1 が filter に入る
→ 条件を満たすので map に渡る
→ 2 に変換され forEach に渡され、出力される

次に 2 が filter に入る
→ 条件を満たさないので、その先には進まず、ここで捨てられる

次に 3 が filter に入る
→ 条件を満たすので map に渡る
→ 6 に変換され forEach に渡され、出力される

つまり、要素ごとに

filter → map → forEach
filter → map → forEach

と「縦方向に」処理されています。

「まず filter で全部処理し終わってから、map に全部渡る」ではない、ということです。

これも「遅延評価」の一部で、

必要になった時に、必要な分だけ、パイプラインを通して処理していく

という振る舞いになっています。


遅延評価だからこそできる「途中でやめる」処理(短絡評価)

anyMatch / findFirst などの「途中で終わる」終端操作

遅延評価の大きなメリットの一つは、

「結果が決まったら、残りの要素の処理をスキップできる」

ことです。

例えば、「3 の倍数が一つでも存在するか」を調べるコード。

import java.util.Arrays;
import java.util.List;

public class LazyShortCircuit {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6);

        boolean exists =
                list.stream()
                    .peek(n -> System.out.println("チェック: " + n))
                    .anyMatch(n -> n % 3 == 0);

        System.out.println("結果: " + exists);
    }
}
Java

peek は「中間操作の途中で要素を覗き見る」ためのデバッグ用のようなメソッドです。
これを使うと、実際にどこまで処理されているかがよく分かります。

このコードを実行すると、

チェック: 1
チェック: 2
チェック: 3
結果: true

と出て終わります。

6 までチェックしていないことに注目してください。

anyMatch は「条件を満たす要素を見つけたら、そこで処理を打ち切る」終端操作です。
遅延評価で、1 要素ずつ処理されているからこそ、
3 で true になった時点で、4, 5, 6 を見る前に終了できるわけです。

同様に、

findFirst … 最初に見つかった要素で終わる
allMatch … 条件を満たさない要素を見つけた時点で終わる

といった「短絡評価(ショートサーキット)」系の終端操作も、
「必要な分だけ処理して終わり」にできるという利点があります。


遅延評価だからこそ可能な「無限ストリーム」の扱い

無限ストリーム+limit の典型例

Stream.generate や Stream.iterate で「いつまでも続くストリーム(無限ストリーム)」を作ることができます。

例えば、1, 2, 3, 4, … と自然数が無限に続くストリーム。

import java.util.stream.Stream;

public class InfiniteStream {
    public static void main(String[] args) {
        Stream<Integer> naturalNumbers =
                Stream.iterate(1, n -> n + 1);

        naturalNumbers
            .limit(5)
            .forEach(System.out::println);  // 1, 2, 3, 4, 5
    }
}
Java

Stream.iterate(1, n -> n + 1) だけだと、「終わりのないストリーム」です。
でも、limit(5) を挟んでいるので、「最初の 5 つだけ取り出したら終わる」処理になります。

ここで遅延評価が効いています。

limit(5) がなければ、遅延評価でも永遠に次の値を生成し続けるしかない
limit(5) があることで、「必要な 5 要素を生成したら、それ以上は生成しない」

つまり、

要素を本当に使う瞬間まで値が生成されない
「どこまで使うか」が終端操作で決まっていて、その分だけしか実際に処理されない

これが、無限ストリームを安全に扱える理由です。

もし「先に全部作ってから処理する」方式だったら、
無限ストリームなんてそもそも扱えませんよね。


遅延評価と「副作用」の相性の悪さ

中間操作の中で println や add を書くと、いつ実行されるか分かりにくい

さっきの例で見た通り、遅延評価によって
実際の処理の実行タイミングは「終端操作を呼んだ瞬間」に決まります。

そのため、filter や map の中に「副作用(外部への出力、外部変数の変更など)」を書き始めると、
「いつ、何回実行されるのか」が見えづらくなります。

例えば、こんなコードを考えてみます。

List<Integer> list = Arrays.asList(1, 2, 3, 4);

List<Integer> result =
        list.stream()
            .filter(n -> {
                System.out.println("filter: " + n);
                return n % 2 == 0;
            })
            .map(n -> {
                System.out.println("map: " + n);
                return n * 10;
            })
            .toList();
Java

これはまだ分かりやすいですが、
ここに sorted や distinct などの「内部で複雑な処理をする中間操作」が挟まってくると、
どのタイミングで println が呼ばれるのか、人間の目では追いづらくなります。

したがって、良いスタイルとしては、

中間操作(filter/map)は「純粋な変換・判定」だけを書く
副作用(ログ出力、外部変数の変更など)は、できれば forEach や peek などで外側に追い出す

ようにしておくと、安全で理解しやすいコードになります。

遅延評価を前提とした世界では、「いつ実行されるのか明確でない場所に副作用を書かない」という感覚がとても大事です。


遅延評価と「一度しか使えないストリーム」

一度終端操作を呼んだストリームは、二度と使えない

遅延評価は「必要なときにだけ一度だけ流す」という設計なので、
Stream は「使い捨て」です。

例えば、こんなコードは例外になります。

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

public class StreamReuseError {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3);

        Stream<Integer> stream = list.stream();

        long count = stream.count();   // 終端操作 → ここでパイプラインは実行済み
        System.out.println("count = " + count);

        stream.forEach(System.out::println);  // ここで IllegalStateException
    }
}
Java

一度 count() を呼んだ時点で、その Stream は「消費済み」になっています。
再利用しようとすると、IllegalStateException: stream has already been operated upon or closed になります。

これも「遅延評価+一度きりの流れ」という性質からきています。

もし同じような処理を複数回したければ、

毎回 list.stream() から新しいストリームを作る
あるいは、一度 List に collect し直しておいて、そこから別の Stream を作る

などの方法を取る必要があります。


まとめ:Stream の遅延評価をどう頭に定着させるか

初心者向けに「Stream の遅延評価」をまとめると、こうなります。

  • filter や map は“準備”であって、それだけでは実行されない(中間操作)
  • collect や forEach などの終端操作を呼んだ瞬間に、初めて流れ全体が動き出す
  • 処理は「要素ごとに filter → map → ・・・ → 終端操作」という縦方向に行われる
  • anyMatch や findFirst などは、条件が満たされた時点で残りを処理しない(短絡評価)
  • 無限ストリームと limit が安全に扱えるのは、必要な分だけ遅延して処理するから
  • ストリームは一度終端操作を呼ぶと再利用できない(使い捨て)

そして、遅延評価の世界では、

「中間操作の中に副作用を書くと、いつ実行されるのかが分かりづらくなる」

という落とし穴があります。

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