1. はじめに:レビューでは「動いているか」ではなく「構造が壊れていないか」を問う

JavaのStream APIは、データ操作を簡潔に記述できる利点がある一方で、構造的な“罠”も数多く含んでいます。とくに以下のような性質は、設計判断と結びついていないとレビューで見落としやすく、リリース後の不具合原因になりがちです。

  • 遅延評価による“動作の不確実性”
  • flatMap忘れによる意図しないネスト
  • 副作用がラムダ内に混在する構造
  • 終端操作の有無による無実行バグ
  • Streamの再利用不可性とスコープ管理

この記事では、レビューアーとして「Stream構文の見た目の簡潔さ」に惑わされず、その裏にある構造判断・副作用管理・型の破綻をどのように見抜くかを解説します。

2. 動いて見えるコードに潜む“評価されない構造”の罠

まずは以下のコードを見てください。

Stream<String> names = Stream.of("Tom", "Ken", "Lisa")
    .peek(System.out::println)
    .map(String::toUpperCase);

一見すると "Tom" などが出力されそうに見えます。しかし、これは何も出力しません。

評価されていない

Streamは終端操作(terminal operation)が行われるまで、実行されません(遅延評価)。つまりこのコードは“構造を作っただけ”で、まだ何も実行されていないのです。

レビュー観点

  • peekやmapが使われているとき、それが「評価される構造」かを必ず確認する
  • 終端操作(forEach、collectなど)が含まれているかをコード構造で視認する

3. flatMapを忘れたことで生まれる“ネストの罠”

以下のコードでは、Optionalを返す処理をそのままmapしてしまった例です。

List<String> values = list.stream()
    .map(this::toOptional) // Stream<Optional<String>>
    .collect(Collectors.toList());

この結果は List<Optional<String>> です。本来は List<String> を期待していたにもかかわらず、Optionalの“殻”を割らずに持ち続けてしまう構造になっています。

// 正しい構造
List<String> values = list.stream()
    .map(this::toOptional)
    .flatMap(Optional::stream)
    .collect(Collectors.toList());
flatMapは「構造の層」を解消する操作

map:変換
flatMap:入れ子構造を1段 flatten する

レビュー観点

  • Optional / List / Streamなどの「ラップ系型」がmapされているとき、flatMapの意図を確認する
  • ネストされた型構造が意図的なものかを常に疑う(Stream<List> → List<Stream> → Stream<Optional> など)

4. 副作用の混在:ラムダは純粋か?

以下のように、map()の中でログや通信、リスト操作が行われているコードは要注意です。

stream.map(item -> {
    auditService.sendLog(item); // 副作用
    return item.toResult();
})

このような副作用は、streamの評価タイミング次第で「複数回実行されたり」「一度も呼ばれなかったり」することがあり、構造が壊れます。

副作用が構造的に“保証されていない位置”に存在している
  • map, filter, peek などは本来「変換」「中継」のみを担うべき
  • 副作用の発生位置が「構造の外」に追い出されていないと、再現性が損なわれる

レビュー観点

  • ラムダ式の内部で、外部リソースアクセス(ログ、DB、通信など)が混在していないか
  • 副作用が必要なら、構造的に“終端側に寄せる”設計変更を促す

5. forEachとnullの組み合わせで“静かに落ちる”構造

list.stream().forEach(item -> System.out.println(item.toString()));

ここで、リストの中にnullが含まれていると、toString()でNullPointerExceptionが発生します。しかもforEachなのでループ中に落ちて、残りの処理が完全にスキップされます

対策
  • nullを許容するなら、事前にfilterで除外する構造にすべき
list.stream()
    .filter(Objects::nonNull)
    .forEach(item -> System.out.println(item.toString()));

レビュー観点

  • forEach使用時に、nullが許容されている構造であるかを確認する
  • null除外を事前にfilterで行っているか、それとも“落ちる可能性”を許容しているかを判断する

6. 例外とstream:ラムダ式の例外処理は構造崩壊の原因

list.stream()
    .map(item -> {
        if (item.isInvalid()) throw new CustomException("invalid");
        return item.toResult();
    })

stream中のラムダは基本的に関数インターフェース(Functionなど)で例外を投げられない(checked例外を扱えない)ため、try-catchを入れると構造が汚染されることがあります。

レビュー観点

  • 処理中に発生する例外は、mapやfilterに内包させず、streamの“外”で構造化するほうが安全
  • FunctionWithExceptionのようなラップ戦略を導入する必要があるかどうかを評価する

7. 再利用不可能なStreamを複数回使おうとする設計の罠

Stream<String> s = list.stream();
processA(s);
processB(s); // ← IllegalStateException: stream has already been operated upon

Streamは一度しか消費できない構造ですが、再利用される設計になっているケースが現場では非常に多いです。

“データ”と“stream構造”は別物

Streamは状態を持つ構造であり、実行後に再利用はできない。これはListやArrayと大きく異なる点。

レビュー観点

  • Streamが複数回参照される構造になっていないか?
  • 「値を複数の処理で使う必要があるなら、先にListなどへ評価」する設計変更を促す

8. まとめ:Stream構造のレビューは「構文」ではなく「意図と副作用の構造」を問う

✔ レビューアーが問うべき観点まとめ

観点 確認すべきこと
終端操作の有無 構造が評価されているか
flatMapの有無 ネスト構造が意図通りか
ラムダの副作用 中間操作に副作用が混ざっていないか
null混在の有無 filterで排除しているか
例外処理の構造 ラムダ内で構造が崩れていないか
Streamの再利用 使いまわしされていないか

ベストプラクティス(構造的観点)

  • “ラムダの中で何が起きているか”を意識する。特に副作用と例外。
  • 中間操作と終端操作を意図的に設計し、データ変換の責務を整理する。
  • ネスト構造(Stream<Optional<List>>など)はflatMapで早めに整理する。
  • 構造の再利用が必要なら、streamではなくListに明示的に変換する。

JavaのStreamは「少ないコードで処理できる」という魅力がある一方で、構造と副作用が混在したときに破綻しやすい設計です。
レビューアーは“コードの簡潔さ”よりも、“構造の意図と透明性”を重視し、設計者が気づいていないリスクに目を配ることが求められます。