forEach を一言でいうと
forEach は「Stream の最後に、要素 1 つずつに対して“なにかする”ための終端操作」です。System.out.println したり、外部のリストに追加したり、DB に書き込んだり――いわゆる「副作用」を書く場所になります。
だからこそ便利なのですが、同時に「Stream の良さを壊しやすい」「バグを埋め込みやすい」危険な場所にもなります。
ここでは、その落とし穴を具体例で見ながら、「どこまでなら使ってよくて、どこからが危険か」をはっきりさせていきます。
落とし穴1:forEach を「何でも屋」にしてしまう
本来の役割は「最後の一押し」
Stream の設計思想としては、
「中間操作(map, filter, sorted, distinct, …)で“何をしたいか”を宣言し、
最後に終端操作(collect, reduce, forEach, …)で“どう終わらせるか”を決める」
という流れになっています。
この中で forEach は、「最後にちょっと副作用を起こす」ための終端操作です。
例えば、ログを出す、画面に表示する、ファイルに書く――そういう「出口」のイメージです。
ところが、よくある悪いパターンは、forEach の中に「本来は中間操作でやるべきロジック」を全部詰め込んでしまうことです。
悪い例:forEach の中で全部やる
List<String> names = List.of("Alice", "Bob", "Charlie");
names.stream()
.forEach(name -> {
if (name.length() >= 4) {
String upper = name.toUpperCase();
System.out.println(upper);
}
});
Javaここでは、
長さでフィルタする
大文字に変換する
表示する
という 3 つの責務が、全部 forEach の中に押し込まれています。
これを素直に書き直すと、こうなります。
names.stream()
.filter(name -> name.length() >= 4)
.map(String::toUpperCase)
.forEach(System.out::println);
Javaこの方が、
「4 文字以上の名前を、大文字にして、表示する」
という意図が一目で分かります。
forEach の中にロジックを詰め込み始めたら、「それ、中間操作に出せない?」と一度立ち止まるクセをつけると、コードの見通しが一気によくなります。
落とし穴2:forEach で外部のコレクションを更新する
「副作用で集める」は Stream 的にはアンチパターン
次によくあるのが、「forEach の中で外部の List や Map に add する」パターンです。
List<String> names = List.of("Alice", "Bob", "Charlie");
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.length() >= 4)
.forEach(result::add);
Java一見すると普通に動きますが、これは Stream の世界ではあまり良い書き方ではありません。
理由はシンプルで、「集めるなら collect を使うべきだから」です。
同じ処理は、こう書けます。
List<String> result =
names.stream()
.filter(name -> name.length() >= 4)
.toList(); // あるいは collect(Collectors.toList())
JavaforEach で外部のコレクションを更新し始めると、
どこで何が追加されているのか分かりにくくなる
並列ストリームにした瞬間にスレッドセーフでなくなる
という問題が一気に出てきます。
「集める」「グルーピングする」「集計する」といった処理は、collect / groupingBy / partitioningBy などの Collector に任せる。forEach は「最後にちょっと副作用を起こすだけ」にとどめる。
この線引きをしておくと、設計がかなり安定します。
落とし穴3:並列ストリームでの forEach と順序
forEach と forEachOrdered の違い
parallelStream() を使って並列処理をするとき、forEach は「順序を保証しない」ことがあります。
List<Integer> nums = IntStream.rangeClosed(1, 10)
.boxed()
.toList();
nums.parallelStream()
.forEach(System.out::println); // 1〜10 がバラバラの順で出る可能性
Java並列ストリームでは、複数スレッドが同時に要素を処理するため、forEach の実行順序は基本的に保証されません。
「順番通りに出力されるだろう」と思っていると、環境や負荷によって結果が変わって見えて驚くことになります。
順序を守りたい場合は、forEachOrdered を使います。
nums.parallelStream()
.forEachOrdered(System.out::println); // 1〜10 の順で出る
Javaただし、forEachOrdered は順序を守るために並列処理の自由度が下がるので、パフォーマンス面では不利になることがあります。
「順序が本当に必要か?」を考えた上で、forEach と forEachOrdered を使い分けるのが大事です。
落とし穴4:例外処理を forEach の中に埋め込んでしまう
例外を握りつぶす forEach は危険
forEach の中で try-catch を書き始めると、例外が見えにくくなります。
list.stream()
.forEach(x -> {
try {
dangerous(x); // 例外を投げるかもしれない処理
} catch (Exception e) {
e.printStackTrace(); // とりあえず出しておく
}
});
Javaこれをやると、「どこかで失敗しているのに、全体としては成功したように見える」状態になりがちです。
ログだけ出して処理を続けるのが本当に正しいのか?
どこかでまとめて失敗として扱うべきではないか?
といった設計の判断を、forEach の中で安易に決めてしまうのは危険です。
例外をどう扱うかは、「Stream の外側」で決めた方が筋が良いことが多いです。
例えば、「失敗した要素を別リストに集める」「成功したものだけを次に渡す」など、戻り値の設計で表現できないかをまず考えると、forEach の中がスッキリします。
落とし穴5:「forEach で書くくらいなら拡張 for でよくない?」問題
Stream を使う意味が薄いケース
正直な話、「単に全要素に対して副作用を起こすだけ」の処理なら、拡張 for 文の方が読みやすいことも多いです。
for (User u : users) {
sendMail(u);
}
Javaこれを無理に Stream で書くと、こうなります。
users.stream()
.forEach(this::sendMail);
Javaこれだけならまだいいのですが、ここに条件分岐や例外処理を足し始めると、
「それ、for 文で書いた方が素直じゃない?」
という状態になりがちです。
Stream を使う意味が出てくるのは、
複数の中間操作を組み合わせて「変換のパイプライン」を作るときcollect や groupingBy で「集約・集計」をきれいに書きたいとき
です。
「ただ全件ループして何かするだけ」なら、拡張 for 文の方が意図が伝わりやすいことも多い。forEach を見たら、「これは本当に Stream で書く価値がある処理か?」と一度自分に問いかけてみると、コードの質が上がります。
まとめ:forEach をどう位置づけるか
最後に、forEach を自分の中でどう扱うか、ルールを決めてしまいましょう。
forEach は「Stream の出口で、ちょっとした副作用を起こすための最後の一押し」。
ロジックや条件分岐を詰め込む場所ではない。
データを集めるなら collect、変換するなら map / filter、グルーピングするなら groupingBy。
それでも残った「表示する」「ログを書く」「外部 API を叩く」などだけを forEach に任せる。
並列ストリームでは順序が保証されないこと、外部コレクションの更新や例外の握りつぶしが危険なことを頭の片隅に置いておく。
