なぜ「例外処理と Stream」が難しく感じるのか
Stream は「関数型っぽく、処理の流れを宣言的に書く」ための仕組みです。
一方で Java の例外は「throw して try-catch で受ける」という命令的なスタイルです。
この二つは思想が少し違うので、そのまま組み合わせようとすると違和感が出ます。
特に「チェック例外(IOException など)」を map や forEach の中で投げたいときに、コンパイルエラーになって戸惑う、というのが典型パターンです。
ここでは、その違和感をちゃんと言葉にしながら、「どう設計するとスッキリするか」を順番に整理していきます。
基本の確認:Stream のラムダは「チェック例外を投げられない」
チェック例外と非チェック例外の違い
Java の例外には、大きく分けて二種類あります。
チェック例外は、メソッドシグネチャに throws を書く必要があり、呼び出し側で必ず「捕まえるか、さらに投げるか」を宣言しなければならない例外です。IOException や SQLException などが代表例です。
非チェック例外(RuntimeException)は、throws を書かなくてもよく、呼び出し側で必ずしも捕まえなくてよい例外です。NullPointerException や IllegalArgumentException などがこれに当たります。
Stream の関数型インターフェースは「throws しない」前提
map や filter に渡すラムダは、Function や Predicate といった関数型インターフェースです。
これらの apply や test メソッドは、シグネチャ上 throws を宣言していません。
つまり、そこに渡すラムダは「チェック例外を投げてはいけない」という前提になっています。
例えば、次のようなコードはコンパイルエラーになります。
Files.lines(Path.of("data.txt")) // Stream<String>
.map(line -> parse(line)); // parse が IOException を throws する
Javaparse が throws IOException を宣言していると、map に渡すラムダの中でチェック例外を投げていることになり、Function の契約に反するからです。
パターン1:ラムダの中で try-catch する
一番ストレートなやり方
一番分かりやすいのは、「ラムダの中で素直に try-catch する」やり方です。
Files.lines(Path.of("data.txt"))
.map(line -> {
try {
return parse(line); // parse はチェック例外を投げる
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.forEach(System.out::println);
Javaここでは、チェック例外を捕まえて、非チェック例外(RuntimeException)に包み直して投げています。
このやり方のメリットは、コードが単純で、Java のルールに正面から従っていることです。
デメリットは、ラムダの中が少しゴチャっとして、Stream の「宣言的な感じ」が薄れることです。
「握りつぶさない」ことが大事
ここで絶対にやってはいけないのは、catch した例外を何もせずに無視してしまうことです。
.map(line -> {
try {
return parse(line);
} catch (IOException e) {
return null; // とりあえず null 返しとく、は危険
}
})
Javaこうすると、後続の処理で突然 NullPointerException が出たり、「本当は失敗しているのに成功したように見える」状態になったりします。
少なくともログを出すか、RuntimeException に包んで投げ直すか、「失敗した」という事実が分かる形にしておくことが重要です。
パターン2:「例外を返り値に織り込む」設計にする
Optional や Result 的な型で「成功/失敗」を表現する
もう一歩踏み込んだ設計として、「例外を throw するのではなく、結果として返す」スタイルがあります。
例えば、パース結果を Optional<T> で返すようにしてしまう方法です。
Optional<Record> tryParse(String line) {
try {
return Optional.of(parse(line)); // parse はチェック例外を投げる
} catch (IOException e) {
return Optional.empty();
}
}
Javaこれを Stream から使うと、こうなります。
List<Record> records =
Files.lines(Path.of("data.txt"))
.map(line -> tryParse(line)) // Optional<Record>
.flatMap(Optional::stream) // 成功したものだけ流す
.toList();
Javaここでは、「失敗した行」は空の Optional として表現され、flatMap(Optional::stream) によって自然にスキップされます。
このスタイルのポイントは、「例外を制御フローに使わない」ことです。
例外は「本当に異常な事態」に限定し、「よく起こり得る失敗」は戻り値(Optional や独自の Result 型)で表現する方が、Stream と相性が良くなります。
パターン3:チェック例外を「包む」ユーティリティを用意する
関数型インターフェースを自作する
毎回ラムダの中で try-catch を書くのがつらくなってきたら、「チェック例外を投げる関数型インターフェース」と「それを RuntimeException に包むヘルパー」を用意する、という手があります。
例えば、こういうインターフェースを作ります。
@FunctionalInterface
interface ThrowingFunction<T, R, E extends Exception> {
R apply(T t) throws E;
}
Javaこれを普通の Function に変換するユーティリティを用意します。
import java.util.function.Function;
class Functions {
static <T, R, E extends Exception>
Function<T, R> wrap(ThrowingFunction<T, R, E> f) {
return t -> {
try {
return f.apply(t);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
}
Java使う側はこう書けます。
List<Record> records =
Files.lines(Path.of("data.txt"))
.map(Functions.wrap(line -> parse(line))) // parse は IOException を throws
.toList();
Javaこれで、「ラムダの中に try-catch を毎回書く」苦しさを軽減できます。
ただし、このパターンも結局は「チェック例外を RuntimeException に包んでいる」だけなので、「どこで最終的に捕まえるか」を設計しておく必要があります。
設計の本質:Stream の中で「どこまで責任を持つか」を決める
Stream の中で完結させるか、外に伝えるか
例外と Stream を設計するときに一番大事なのは、「どこまでを Stream の中で処理し、どこからを外の責任にするか」をはっきり決めることです。
Stream の中で完結させるパターンでは、ラムダの中で try-catch してログを出したり、失敗した要素をスキップしたりします。
この場合、「Stream の外側から見ると、例外は飛んでこない」代わりに、「どの要素が失敗したか」は外からは分かりにくくなります。
逆に、「外に伝える」パターンでは、RuntimeException に包んで投げ直し、上位レイヤーでまとめてハンドリングします。
この場合、「どこかで失敗したら全体を止める」という設計になりやすいです。
どちらが正しいかはケースバイケースですが、「どちらにするかを意識的に選ぶ」ことが重要です。
なんとなくその場しのぎで try-catch を書き始めると、すぐに「どこで何が起きているか分からない」コードになります。
まとめ:自分の中のルールを決めておく
最後に、あなたの中で持っておくと楽になるルールを、あえてシンプルに言語化してみます。
チェック例外を投げるメソッドを Stream から呼びたいときは、まず「本当に例外であるべきか」を考える。
よく起こる失敗なら、Optional や Result 的な戻り値にして、Stream で自然に扱える形に寄せる。
どうしても例外にしたいなら、ラムダの中で try-catch して RuntimeException に包むか、ユーティリティで包む。
その場合、「どこで最終的に捕まえるか」を設計しておく。
そして、「例外を握りつぶさない」「null でごまかさない」ことだけは絶対に守る。
