「遅延評価」をざっくりイメージする
まず感覚から固めます。
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);
}
}
Javapeek は「中間操作の途中で要素を覗き見る」ためのデバッグ用のようなメソッドです。
これを使うと、実際にどこまで処理されているかがよく分かります。
このコードを実行すると、
チェック: 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
}
}
JavaStream.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が安全に扱えるのは、必要な分だけ遅延して処理するから - ストリームは一度終端操作を呼ぶと再利用できない(使い捨て)
そして、遅延評価の世界では、
「中間操作の中に副作用を書くと、いつ実行されるのかが分かりづらくなる」
という落とし穴があります。
