Stream.peekが終端操作なしでは実行されない理由を遅延評価から解説する記事のアイキャッチ。

Java

JavaのStream.peekが実行されない理由|終端操作と遅延評価

AIによる要約

Streamのfilter、map、peekなどは中間操作であり、呼び出した時点では要素処理を開始しません。toList、collect、forEach、countなどの終端操作が実行されて初めて、必要な要素が流れます。そのため終端操作がなければpeekは動かず、findFirstやlimitでは全件分動くとも限りません。peekは主に処理途中の観察へ使い、保存・更新・通知などの重要な副作用を置かないのが安全です。

新人SE
新人SE
peekにログを入れたのに何も出ません。Streamの前後では「done」が出ています。
ポンコツSE
ポンコツSE
終端操作がないため、Streamの処理自体が始まっていません。Streamは必要になるまで中間操作を実行しません。

Streamは、Listの各要素をすぐ加工して結果を保存する仕組みではありません。中間操作をつなげて「何をするか」を組み立て、終端操作が呼ばれた時に必要な範囲だけ実行します。

この遅延評価を知らずにpeekへ保存処理やカウンタ更新を置くと、「動かない」「一部だけ動く」「実装変更で回数が変わる」という障害になります。

この記事のポイント

  • peekは中間操作なので終端操作がなければ動かない
  • Streamは必要な要素だけ遅延評価する
  • findFirstやlimitではpeekが全件実行されない
  • 重要な副作用はpeekではなく明示的な処理へ置く
  • デバッグログも要素数と個人情報に注意する

XのJavaクイズでpeekの動きを確認する

Listのstreamにpeekを設定したが終端操作がなく、何が出力されるかを問うJavaクイズ画像。
peekの後に終端操作がありません。要素は表示されるでしょうか。
List.of("A", "B").stream()
        .peek(System.out::println);

System.out.println("done");

出力されるのは`done`だけです。Streamオブジェクトは作られていますが、要素を流す終端操作がありません。

終端操作がないためStream.peekは実行されず、doneだけが出力されると説明する回答画像。
toListやforEachなどの終端操作があって初めて処理が始まります。

Xの出題ポスト回答ポストも参照できます。

中間操作と終端操作の違い

種類代表例役割
中間操作filter、map、peek、sorted、limit処理手順を組み立て、新しいStreamを返す
終端操作toList、collect、forEach、count、findFirst、anyMatch結果を作るためにStreamを実行する
List<String> result = List.of("A", "B").stream()
        .peek(System.out::println)
        .map(String::toLowerCase)
        .toList();

`toList`が終端操作なので、AとBがpeekで表示され、結果は`["a", "b"]`になります。peek自体が結果を進めるのではなく、終端操作から必要とされて実行されます。

peekは全件実行されるとは限らない

Optional<String> result = List.of("A", "B", "C").stream()
        .peek(value ->
                System.out.println("peek: " + value))
        .filter(value -> value.equals("B"))
        .findFirst();

findFirstはBが見つかった時点で終了できるため、Cまで処理する必要がありません。peekはAとBで実行されますが、Cでは実行されません。

List<String> result = values.stream()
        .peek(this::recordAccess)
        .limit(10)
        .toList();

`recordAccess`を全件記録のつもりで使っても、limitの後続が必要とする件数しか実行されません。最適化や終端操作の種類に依存する重要処理をpeekへ置くべきではありません。

peekへ保存や更新を置かない

List<Order> result = orders.stream()
        .filter(Order::isPending)
        .peek(orderRepository::save)
        .toList();

動く場合はありますが、読み手はpeekを観察用と考えるため、DB保存が隠れます。終端操作が消されたりfindFirstへ変わったりすると、保存件数が変わります。トランザクション境界や例外時の扱いも分かりにくくなります。

List<Order> pendingOrders = orders.stream()
        .filter(Order::isPending)
        .toList();

for (Order order : pendingOrders) {
    orderRepository.save(order);
}

保存という重要な副作用を明示します。複数件保存なら`saveAll`が適切な場合もあります。Streamを1本にまとめることより、どこで状態が変わるかが読めることを優先します。

値の変換はmapへ書く

List<OrderDto> dtos = orders.stream()
        .map(order -> toDto(order))
        .toList();

入力から別の値を作る処理はmapです。peek内でオブジェクトを書き換え、同じ参照を後続へ流すと、変換なのかデバッグなのか分かりません。

// 避けたい例
orders.stream()
        .peek(order -> order.setStatus("SENT"))
        .toList();

// 意図を明示する例
orders.forEach(order -> order.changeStatus(SENT));

Entityの状態変更が業務処理なら、Serviceメソッドやドメインメソッドとして名前を付けます。JPAのダーティチェックで更新される場合も、peekに隠すとレビューで見落とされます。

デバッグ用途でもログ量に注意する

List<OrderDto> result = orders.stream()
        .peek(order ->
                log.debug("変換前 orderId={}", order.getId()))
        .map(this::toDto)
        .peek(dto ->
                log.debug("変換後 orderId={}", dto.id()))
        .toList();

変換前後を一時的に確認する用途ならpeekは便利です。ただし大量データで1件ずつログを出すと、ログ量と性能へ影響します。Entity全体を出せば個人情報が混ざる可能性もあります。必要なIDだけに絞り、調査後に残すべきログか見直します。

同じStreamを2回使うことはできない

Stream<String> stream = values.stream()
        .peek(System.out::println);

long count = stream.count();
List<String> list = stream.toList();

終端操作を実行したStreamは消費済みです。2回目の`toList`で`IllegalStateException`になります。再利用したい場合は元のCollectionからStreamを作り直すか、1回目の結果をListへ保存します。

テストでは結果と副作用を分ける

@Test
void 終端操作がなければpeekは実行されない() {
    AtomicInteger calls = new AtomicInteger();

    Stream<String> stream = List.of("A", "B").stream()
            .peek(value -> calls.incrementAndGet());

    assertEquals(0, calls.get());
}

@Test
void findFirstは必要な位置まで処理する() {
    AtomicInteger calls = new AtomicInteger();

    Optional<String> result = List.of("A", "B", "C").stream()
            .peek(value -> calls.incrementAndGet())
            .filter("B"::equals)
            .findFirst();

    assertEquals(Optional.of("B"), result);
    assertEquals(2, calls.get());
}

このテストはStreamの性質を理解する学習用です。業務テストではpeekの回数に依存する設計を避け、最終結果や明示的なService呼び出しを検証します。

現場レビューでよくある指摘

// レビューコメント例
終端操作がないため、このStreamとpeekは実行されません。
結果をどこで利用する設計か確認してください。

// レビューコメント例
peek内にRepository保存があり、副作用が見えにくくなっています。
対象抽出と保存処理を分けてください。

// レビューコメント例
findFirstで短絡するため、peekが全件実行される保証はありません。
全件処理が要件なら別の処理として明示してください。

提出前のセルフチェック

  • 終端操作があるか
  • peekの実行回数へ業務処理が依存していないか
  • findFirst・anyMatch・limitで短絡しないか
  • 保存・更新・通知をpeekへ置いていないか
  • 変換処理はmapで表せないか
  • ログ量と機密情報を確認したか
  • 消費済みStreamを再利用していないか
  • for文の方が明確ではないか

Streamを実践的に学ぶ参考書

Streamは中間操作と終端操作、遅延評価を一緒に理解すると、短いコードに引っ張られず適切に使えます。

書籍「スッキリわかるJava入門 実践編 第5版」の表紙
商品画像:Amazon.co.jp
PR Java基礎学習後の若手向け

スッキリわかるJava入門 実践編 第5版

中山 清喬 (著), 株式会社フレアリンク (監修)

基礎文法の次に必要な、現場寄りのJava知識を補う。

コレクション、ジェネリクス、ラムダ式、ストリームなど、業務コードで出会いやすい機能を入門編の次に学べます。

  • Java基礎の次に何を学ぶか迷っている
  • コレクションやStreamを整理したい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

Stream.peekは中間操作なので、終端操作がなければ実行されません。またfindFirstやlimitなどで処理が短絡すれば、全要素に対して動くとは限りません。

peekは処理途中の観察に限定し、保存・更新・通知などの副作用を置かないようにします。変換はmap、全件処理はforEachや明示的なループへ分け、実行タイミングが読めるコードにしましょう。

-Java
-, , , ,