1. 遅延評価とは?
遅延評価(lazy evaluation)とは、結果が本当に必要になるまでデータに対する処理を先送りにする原則です。Stream API の文脈では、コレクションに対して変換チェーンを書いても、Java はすぐには実行しません — 終端操作が呼び出されるまで待ちます。そしてそのとき初めて、チェーン全体が評価されます。
なぜ必要か? 第一にリソースの節約です。最終的に不要な要素は処理されません。第二にパフォーマンスの向上です — 多数の中間コレクションを作らずに長いチェーンを構築できます。最後に「短絡評価」が使えます。条件に合う最初の要素が見つかったら、それ以降の処理は停止されます。
たとえ話:怠け者のウェイターを想像してください。あなたが「メニューを持ってきて、それからコーヒー、そしてケーキ」と言うと、彼はうなずきますが…「じゃあ本当に持ってきて」と付け加えるまで何もしません。そのとき初めて注文を実行しに行きます — もしケーキがもう無ければコーヒーだけを持ってくるかもしれません。ストリームの遅延評価もだいたいこれと同じです。
2. 中間操作と終端操作
中間操作(intermediate):
- filter
- map
- sorted
- distinct
- peek(デバッグ用)
- など
中間操作は新しい Stream を返しますが、計算は開始しません。いわば処理の「計画」を組み立てるだけです。
終端操作(terminal):
- collect
- forEach
- reduce
- count
- findFirst、findAny
- anyMatch、allMatch、noneMatch
- など
終端操作だけがチェーン全体の実行を開始します。
例: 終端操作がないと何も起きない
List<String> names = List.of("Alisa", "Bob", "Vasya");
names.stream()
.filter(name -> {
System.out.println("フィルタ中 " + name);
return name.startsWith("A");
});
// 何も表示されません! 上のコードはチェーンを"組み立てている"だけです。
終端操作を追加してみましょう:
names.stream()
.filter(name -> {
System.out.println("フィルタ中 " + name);
return name.startsWith("A");
})
.forEach(System.out::println);
// これでコンソールに出力が表示されます!
結果:
フィルタ中 Alisa
フィルタ中 Bob
フィルタ中 Vasya
Alisa
3. 遅延評価の利点
リソース節約
遅延評価により、不要な要素に時間やメモリを費やさずに済みます。たとえば最初に条件に合うオブジェクトを探す場合、最初の一致で処理は停止します。
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
String firstA = names.stream()
.filter(name -> {
System.out.println("確認中: " + name);
return name.startsWith("A");
})
.findFirst()
.orElse("見つかりません");
System.out.println("結果: " + firstA);
出力:
確認中: Alisa
結果: Alisa
注意: 残りの要素はチェックすらされません!
中間コレクションなしの長いチェーン
多数の操作(filter、map、sorted など)を、各ステップでコレクションを作らずに組み合わせられます。
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Java 16+、それ以前は — .collect(Collectors.toList())
短絡(ショートサーキット)
「要素の中に条件を満たすものがあるか」だけが分かればよい場合、残りはチェックされません:
boolean hasLongName = names.stream()
.anyMatch(name -> {
System.out.println("確認中: " + name);
return name.length() > 10;
});
// 最初の要素が長ければ — 残りはチェックされません!
4. 例: 遅延評価はこう動く
例 1: 終端操作がないと何も起きない
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> {
System.out.println("フィルタ中 " + n);
return n % 2 == 0;
});
// 出力なし!
例 2: 終端操作を含むチェーン
numbers.stream()
.filter(n -> {
System.out.println("フィルタ中 " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("掛け算 " + n);
return n * 10;
})
.forEach(System.out::println);
出力:
フィルタ中 1
フィルタ中 2
掛け算 2
20
フィルタ中 3
フィルタ中 4
掛け算 4
40
フィルタ中 5
重要なポイント: 操作は要素ごとに実行されます。つまり各要素に対して順に filter、次に map、次に forEach の順です。これは「まず全部をフィルタしてから、次に全部を変換する」という2回の全体走査ではありません。
例 3: peek を使ったデバッグ
numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("フィルタ通過: " + n))
.map(n -> n * 10)
.peek(n -> System.out.println("map 後: " + n))
.forEach(System.out::println);
5. 有用な注意点
副作用のあるストリームは使わない
遅延評価は、即時実行を当てにしていると裏目に出ることがあります。map、filter、peek の中での副作用(ファイル書き込み、外部状態の変更など)は、順序が期待通りでなかったり、すべての要素に対して実行されなかったり、終端操作がないと実行されないことさえあります。
できるだけ早くフィルタする
余分な要素を早めに切り捨てて後続の作業量を減らすために、filter はチェーンの先頭に近い位置に置きましょう。
最初の結果だけ必要? 適切な終端操作を使う
最初に条件に合う要素だけが欲しいなら、findFirst や findAny を呼び出しましょう。これにより、結果が見つかった時点でストリームは停止できます。
ストリームは元コレクションの変更用ではない
ストリームは元のコレクションに要素を追加・削除する用途には向きません。コレクションの構造を変更するには、他の仕組みを使いましょう。
遅延ストリームの可視化
List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");
words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
処理の流れ:
| 段階 | cat | dog | elephant | fox | giraffe |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | 出力 | — | 出力 |
表: eager と lazy の比較
| アプローチ | 処理はいつ実行される? | メモリ使用量 | パフォーマンス |
|---|---|---|---|
| Eager(貪欲) | 呼び出し時に即時 | 多くなりがち | 場合によっては遅い |
| Lazy(遅延) | 必要になった時のみ | 最小限 | 通常は高速 |
Eager(貪欲)なアプローチ — たとえば自分でコレクションを複数回走査し、中間リストを作るようなケースです。
Lazy(遅延)なアプローチ — それがストリームです。最終結果が必要になるまで何も行いません。
6. 遅延ストリームでよくあるミス
エラー №1: 即時結果を期待する。 初心者は filter や map の呼び出しがすぐ実行されると思いがちです。しかし終端(例: collect、forEach)がなければ何も起きません — そのため「デバッグが動かない」「何も出力されない」となります。
エラー №2: 中間操作での副作用。 map/filter/peek の中でのファイル書き込みや外部変数の変更は悪いプラクティスです。遅延評価や最適化の影響で、こうした副作用は完全に実行されなかったり、期待しない順序になったり、終端操作がないと実行されないことさえあります。
エラー №3: 終端操作の呼び出し忘れ。 ストリームのチェーンを書いたのに、collect や forEach などの終端で完了させていない。結果は「無音」です。
エラー №4: すべての要素が処理されると期待する。 findFirst や anyMatch のような操作は、最初の結果でパイプラインを打ち切ります。残りの要素は処理されません — そのため「なぜ println が全要素で動かなかったの?」と驚くことになります。
エラー №5: 元コレクションの変更にストリームを使う。 ストリームは元のコレクションを変更(要素の追加・削除)するためのものではありません。コレクションの専用メソッドやイテレータを使いましょう。
GO TO FULL VERSION