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

Java Java
スポンサーリンク

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

まず感覚から固めます。

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

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

という性質のことです。

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

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


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

中間操作(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

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

filtermap の中で println しているのに、です。

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

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

終端操作(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 という終端操作を呼んだ瞬間に、
filtermapforEach の流れが動き出して、
要素が 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.generateStream.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 を書くと、いつ実行されるか分かりにくい

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

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

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

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

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

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

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

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

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


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

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

遅延評価は「必要なときにだけ一度だけ流す」という設計なので、
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 の遅延評価」をまとめると、こうなります。

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

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

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

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

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