SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

Stream の遅延評価・使い捨て・パイプラインを正確に理解する

Stream の3大性質「遅延評価・使い捨て・元データ不変」が実際どういう意味かを、パイプラインの組み立てと実行タイミングから解説。終端操作が来るまで何も起きない理由と、それによる最適化の仕組み。

一言結論

Stream の中間操作(filter/map等)は呼んだ瞬間には実行されない。終端操作(collect/forEach等)が来て初めて全操作がまとめて実行される。これが「遅延評価」の正体で、不要な処理をスキップする最適化が可能になる。

Stream には3つの重要な性質がある。

  1. 遅延評価(Lazy Evaluation): 終端操作が来るまで中間操作は実行されない
  2. 使い捨て: 一度消費した Stream は再利用できない
  3. 元データ不変: Stream は元のコレクションを変更しない

この3つは試験に出るが、なぜそうなっているかを理解している人は少ない。パイプラインの仕組みを追うと全部繋がる。


1. Stream のパイプライン構造

List<String> names = List.of("Alice", "Bob", "Charlie", "Dave", "Eve");

List<String> result = names.stream()       // Stream 生成
    .filter(s -> s.length() > 3)           // 中間操作 1
    .map(String::toUpperCase)              // 中間操作 2
    .collect(Collectors.toList());         // 終端操作

これを「順番に処理している」と思いがちだが、実際は違う。

【実際の処理の流れ(遅延評価)】

× 誤解(こう動いていると思いがち):
  全要素を filter → 全要素を map → collect

○ 正解(実際はこう動く):
  要素 1 つずつ、全操作を縦断してから次の要素へ

  "Alice"  → filter(length>3)→通過 → map→"ALICE"  → collect に追加
  "Bob"    → filter(length>3)→除外 → ここで終わり(map は呼ばれない)
  "Charlie"→ filter(length>3)→通過 → map→"CHARLIE"→ collect に追加
  "Dave"   → filter(length>3)→通過 → map→"DAVE"   → collect に追加
  "Eve"    → filter(length>3)→除外 → ここで終わり

"Bob""Eve"filter で除外されるため、map呼ばれない。要素ごとにパイプラインを縦断するため、除外された要素に対する無駄な処理が発生しない。


2. 遅延評価の証明

List<String> names = List.of("Alice", "Bob", "Charlie");

Stream<String> stream = names.stream()
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.length() > 3;
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    });

System.out.println("終端操作の前");
// ↑ここまで実行しても filter/map の println は一切出力されない

List<String> result = stream.collect(Collectors.toList());
// ↑ここで初めて filter/map が動き出す

出力:

終端操作の前
filter: Alice
map: Alice
filter: Bob
filter: Charlie
map: Charlie

collect が呼ばれるまで、filtermap も一切実行されていない。中間操作は「処理の設計図」を組み立てているだけで、実際の実行は終端操作のタイミングだ。

内部実装のイメージ

stream() → filter() → map() の段階で作られるもの:

┌──────────────────────────────────────────────┐
│ Pipeline(操作の連鎖を表すオブジェクト)     │
│ ├─ source: names の参照                     │
│ ├─ stage1: filter(length > 3)             │
│ └─ stage2: map(toUpperCase)               │
└──────────────────────────────────────────────┘

collect() が来たとき:
→ Pipeline を解析して全要素をパイプラインに流す

3. 遅延評価による最適化の例

// 最初の 1 件だけほしい場合
Optional<String> first = names.stream()
    .filter(s -> s.length() > 3)
    .findFirst();

findFirst() は終端操作なので実行が始まる。しかし「最初の 1 件が見つかったら即終了」できる。

"Alice" → filter 通過 → findFirst が見つかった → 処理終了!
"Bob" 以降は一切処理されない

全要素を処理してから最初の 1 件を取るのではなく、見つかった時点で止まる。これも遅延評価のおかげだ。

limit(n) も同様:

names.stream()
    .filter(s -> s.length() > 3)
    .limit(2)           // 2 件見つかった時点で終了
    .collect(Collectors.toList());

4. 使い捨て ── 再利用できない理由

Stream<String> stream = names.stream().filter(s -> s.length() > 3);

List<String> result1 = stream.collect(Collectors.toList()); // OK
List<String> result2 = stream.collect(Collectors.toList()); // IllegalStateException!

Stream は内部に「消費済みフラグ」を持っており、終端操作を呼んだ後は「閉じた(closed)」状態になる。再度使おうとすると例外が出る。

なぜこの設計か:Stream は「ソースからデータを引き出す一方向のパイプ」として設計されている。一度データを流したパイプを再度使うには、新しい Stream を作るほうが明確で安全だ。

// 再利用したい場合は毎回 stream() を呼ぶ
List<String> result1 = names.stream().filter(...).collect(...);
List<String> result2 = names.stream().filter(...).collect(...);

5. 元データ不変

List<String> original = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));

List<String> filtered = original.stream()
    .filter(s -> s.length() > 3)
    .collect(Collectors.toList());

System.out.println(original); // [Alice, Bob, Charlie](変わっていない)
System.out.println(filtered); // [Alice, Charlie](新しいリスト)

collect は新しいコレクションを作る。original は一切変更されない。

注意: Stream 内でラムダを使って元のコレクションを変更するのは、Stream の設計思想に反する副作用だ。

// NG: Stream 処理中に元コレクションを変更
original.stream()
    .filter(s -> { original.remove(s); return true; }); // 危険!ConcurrentModificationException の可能性

6. 主要な終端操作の使い分け

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

// 集める
List<Integer> list   = numbers.stream().filter(n -> n % 2 == 0).collect(Collectors.toList()); // [2,4,6]
Set<Integer>  set    = numbers.stream().collect(Collectors.toSet());

// 集計
long count    = numbers.stream().filter(n -> n > 3).count(); // 3
int  sum      = numbers.stream().mapToInt(Integer::intValue).sum(); // 21
OptionalDouble avg = numbers.stream().mapToInt(Integer::intValue).average(); // 3.5

// 検索・判定
Optional<Integer> first = numbers.stream().filter(n -> n > 3).findFirst(); // Optional[4]
boolean anyOver5 = numbers.stream().anyMatch(n -> n > 5); // true
boolean allOver0 = numbers.stream().allMatch(n -> n > 0); // true

// 文字列結合
String joined = Stream.of("A","B","C").collect(Collectors.joining(", ")); // "A, B, C"

// グループ化
Map<Boolean, List<Integer>> grouped = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {true=[2,4,6], false=[1,3,5]}

7. Optional ── null を返さない設計

Optional<String> found = names.stream()
    .filter(s -> s.startsWith("Z"))
    .findFirst();

// 悪い使い方
String result = found.get(); // NoSuchElementException の可能性

// 良い使い方
String result = found.orElse("default");         // 存在しなければ "default"
String result = found.orElseGet(() -> compute()); // 存在しなければ計算(遅延)
found.ifPresent(s -> System.out.println(s));      // 存在する場合のみ実行

// 変換
Optional<Integer> length = found.map(String::length); // Optional[7] or empty

Optional は「この処理は値が存在しない可能性がある」という意図をメソッドシグネチャで表現する型だ。null を返すより「Optional が空かどうかチェックせよ」と明示できる。


まとめ

性質正体なぜそうなっているか
遅延評価中間操作は設計図を組み立てるだけ。終端操作で初めて実行不要な処理をスキップする最適化が可能
使い捨て終端操作後は閉じた状態になる一方向パイプとして設計。再利用は新しい stream() を呼ぶ
元データ不変collect は新しいコレクションを作る副作用のない関数型スタイルを保つ