はじめに

Stream APIを使ったコードを書いていると、「想定した件数のデータが処理されていない」「フィルタ条件がうまく機能していない」といった現象に出くわすことがあります。しかし、Streamは中間操作をつなげてもすぐには実行されないため、途中にSystem.out.printlnを挟んでも値を確認できず、デバッグに苦労した経験を持つ方は多いはずです。

こうした場面で活躍するのがStream.peek()です。peekは「ストリームを流れる要素を覗き見る」ためのメソッドで、デバッグ用途に特化した中間操作です。ただし使い方を誤ると、ひっかけ問題で頻出する「peekが呼ばれない」「想定より少ない回数しか呼ばれない」という挙動に振り回されることになります。本記事では、peekの正しい使い方と、試験で問われやすい注意点を整理して解説します。

Stream.peek()とは何か

peek()はStream<T> peek(Consumer<? super T> action)という形のメソッドで、ストリームを流れてくる各要素に対して、指定したアクション(多くは出力処理)を実行しながら、要素自体はそのまま次の操作に渡す中間操作です。戻り値は元のストリームと同じ要素を持つストリームなので、処理の流れを変えずに「中身を覗く」ことができます。

import java.util.List;
import java.util.stream.Stream;

public class PeekDemo {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Carol");

        long count = names.stream()
                .peek(n -> System.out.println("peek: " + n))
                .filter(n -> n.startsWith("A"))
                .count();

        System.out.println("count = " + count);
    }
}

このコードを実行すると、各要素がpeekを通過する様子と、filterによって絞り込まれる前の段階の値を確認できます。Javadocにも、peekは主にデバッグ目的で、パイプラインを流れる要素を観察するために用意されていると明記されています。

デバッグでの具体的な使い方

peekの基本的な使い方は、確認したい中間操作の前後に挟んで、その時点での要素の状態を出力することです。

import java.util.List;
import java.util.stream.Collectors;

public class PeekUsage {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

        List<Integer> result = numbers.stream()
                .peek(n -> System.out.println("元の値: " + n))
                .filter(n -> n % 2 == 0)
                .peek(n -> System.out.println("filter通過後: " + n))
                .map(n -> n * 10)
                .collect(Collectors.toList());

        System.out.println(result);
        // 出力例:
        // 元の値: 1
        // 元の値: 2
        // filter通過後: 2
        // 元の値: 3
        // 元の値: 4
        // filter通過後: 4
        // 元の値: 5
        // 元の値: 6
        // filter通過後: 6
        // [20, 40, 60]
    }
}

この出力からわかる通り、Streamは要素を1件ずつ最後まで処理してから次の要素に移る「逐次処理」の性質を持っています。peekを複数箇所に配置することで、どの段階でフィルタ条件に引っかかっているか、どの段階で値が変換されているかを段階的に確認できます。

注意点①:終端操作がなければpeekは実行されない

Streamの中間操作はすべて遅延評価されます。peekも例外ではなく、collectやforEach、countなどの終端操作が呼ばれるまでは一切実行されません。

import java.util.List;

public class PeekNoTerminal {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob");

        names.stream()
                .peek(n -> System.out.println("peek: " + n));
        // 何も出力されない(終端操作がないため実行されない)

        System.out.println("処理終了");
        // 出力結果:
        // 処理終了
    }
}

これは試験でも頻出のひっかけポイントです。「peekを呼んでいるのに何も出力されない」という選択肢を見たときは、その後に終端操作が続いているかどうかを必ず確認してください。

注意点②:短絡操作と組み合わせると全要素を処理しない

findFirstやanyMatch、limitといった短絡操作(short-circuiting operation)と組み合わせると、条件を満たした時点で処理が止まるため、すべての要素にpeekが適用されるわけではありません。

import java.util.List;
import java.util.Optional;

public class PeekShortCircuit {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        Optional<Integer> result = numbers.stream()
                .peek(n -> System.out.println("チェック中: " + n))
                .filter(n -> n > 2)
                .findFirst();

        System.out.println("結果: " + result.get());
        // 出力結果:
        // チェック中: 1
        // チェック中: 2
        // チェック中: 3
        // 結果: 3
    }
}

要素が5件あるにもかかわらず、peekが呼ばれるのは3回だけです。条件を満たした時点でストリーム処理全体が打ち切られるため、4と5はそもそもパイプラインに乗ってきません。「peekの呼び出し回数 = 元のコレクションの要素数」と思い込むと誤った解答をしてしまうので注意してください。

注意点③:count()と組み合わせるとpeekがスキップされる場合がある

Java 9以降、ストリームのサイズが要素を実際に走査せずに算出できる場合、JVMの最適化によってパイプライン自体が実行されないことがあります。代表的なのがcount()との組み合わせです。

import java.util.List;

public class PeekCountOptimization {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        long count = numbers.stream()
                .peek(n -> System.out.println("peek: " + n))
                .count();

        System.out.println("count = " + count);
        // 出力結果(Java 9以降の典型例):
        // count = 5
        // ※ peekの出力が一切表示されないことがある
    }
}

filterやmapなど、要素数に影響を与える操作が間に入っていない場合、JVMはソースのサイズから直接件数を導き出せるため、ストリームを実際に流す必要がないと判断し、パイプライン自体をスキップすることがあります。この挙動はJavadocにも明記されている正式な仕様で、Java 8時代の挙動を前提にした問題と混同しやすいポイントです。1Z0-826の対策では「peekは必ず呼ばれるとは限らない」という前提で選択肢を読む習慣をつけておくと安全です。

peekとforEachの違い

peekとforEachはどちらも要素ごとに処理を実行できますが、役割と位置づけが異なります。

観点peekforEach
操作の種類中間操作終端操作
戻り値Stream<T>(処理続行可能)void
主な用途デバッグ、ログ出力最終的な要素ごとの処理
単独での実行可否不可(終端操作が必要)可能(呼べば即実行)
推奨される使用範囲副作用を持たない確認用途のみ副作用を伴う処理全般

peekは「ストリームの途中経過を見るための一時的な観察ポイント」であり、本来の処理ロジックをpeekの中に書くべきではありません。要素を加工したり、外部の状態を変更したりする処理はforEachやmapなど、本来の役割を持つ操作に任せるのが適切です。

本番コードでpeekを使うべきか

Javadocでは、peekの主な用途はデバッグであり、パイプラインの動作確認のために要素を覗くことを目的としていると説明されています。実装によってはpeekの呼び出しが省略される可能性がある以上、ビジネスロジックの一部としてpeekに依存したコードを書くのは避けるべきです。ログ出力やカウンタの加算など副作用を伴う処理をpeekに任せると、JVMやライブラリの実装変更によって動作が変わるリスクを抱え込むことになります。デバッグが終わったら、本番コードからpeekを取り除くか、ロギングフレームワークなど別の仕組みに置き換えるのが安全な運用です。

まとめ

peekはStreamパイプラインの途中経過を確認できる便利な中間操作ですが、いくつかの落とし穴があります。終端操作がなければ実行されないこと、短絡操作と組み合わせると全要素を処理しないこと、そしてcount()のように要素を走査せずに結果を出せる場合は呼び出し自体がスキップされることがある点は、いずれも試験で問われやすい知識です。peekの出力結果だけを見て「全要素が処理された」と判断せず、パイプライン全体の構成を踏まえて挙動を読み解く力を身につけておきましょう。