Java Stream APIの設計レビュー - 動いて見える“構造の罠”を見抜く技術
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());
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は状態を持つ構造であり、実行後に再利用はできない。これはListやArrayと大きく異なる点。
レビュー観点
- Streamが複数回参照される構造になっていないか?
- 「値を複数の処理で使う必要があるなら、先にListなどへ評価」する設計変更を促す
8. まとめ:Stream構造のレビューは「構文」ではなく「意図と副作用の構造」を問う
✔ レビューアーが問うべき観点まとめ
観点 | 確認すべきこと |
---|---|
終端操作の有無 | 構造が評価されているか |
flatMapの有無 | ネスト構造が意図通りか |
ラムダの副作用 | 中間操作に副作用が混ざっていないか |
null混在の有無 | filterで排除しているか |
例外処理の構造 | ラムダ内で構造が崩れていないか |
Streamの再利用 | 使いまわしされていないか |
ベストプラクティス(構造的観点)
- “ラムダの中で何が起きているか”を意識する。特に副作用と例外。
- 中間操作と終端操作を意図的に設計し、データ変換の責務を整理する。
- ネスト構造(Stream<Optional<List
>>など)はflatMapで早めに整理する。 - 構造の再利用が必要なら、streamではなくListに明示的に変換する。
JavaのStreamは「少ないコードで処理できる」という魅力がある一方で、構造と副作用が混在したときに破綻しやすい設計です。
レビューアーは“コードの簡潔さ”よりも、“構造の意図と透明性”を重視し、設計者が気づいていないリスクに目を配ることが求められます。