Java | Java 詳細・モダン文法:Stream API 深掘り – 例外処理と Stream

Java Java
スポンサーリンク

なぜ「例外処理と Stream」が難しく感じるのか

Stream は「関数型っぽく、処理の流れを宣言的に書く」ための仕組みです。
一方で Java の例外は「throw して try-catch で受ける」という命令的なスタイルです。

この二つは思想が少し違うので、そのまま組み合わせようとすると違和感が出ます。
特に「チェック例外(IOException など)」を mapforEach の中で投げたいときに、コンパイルエラーになって戸惑う、というのが典型パターンです。

ここでは、その違和感をちゃんと言葉にしながら、「どう設計するとスッキリするか」を順番に整理していきます。


基本の確認:Stream のラムダは「チェック例外を投げられない」

チェック例外と非チェック例外の違い

Java の例外には、大きく分けて二種類あります。

チェック例外は、メソッドシグネチャに throws を書く必要があり、呼び出し側で必ず「捕まえるか、さらに投げるか」を宣言しなければならない例外です。
IOExceptionSQLException などが代表例です。

非チェック例外(RuntimeException)は、throws を書かなくてもよく、呼び出し側で必ずしも捕まえなくてよい例外です。
NullPointerExceptionIllegalArgumentException などがこれに当たります。

Stream の関数型インターフェースは「throws しない」前提

mapfilter に渡すラムダは、FunctionPredicate といった関数型インターフェースです。
これらの applytest メソッドは、シグネチャ上 throws を宣言していません。

つまり、そこに渡すラムダは「チェック例外を投げてはいけない」という前提になっています。

例えば、次のようなコードはコンパイルエラーになります。

Files.lines(Path.of("data.txt"))      // Stream<String>
     .map(line -> parse(line));       // parse が IOException を throws する
Java

parsethrows 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 でごまかさない」ことだけは絶対に守る。

タイトルとURLをコピーしました