🐕

Javaの検査例外・非検査例外はなぜGoなどの他の言語にはないのか

に公開

はじめに

この記事では、Javaの検査例外(checked exception)と非検査例外(unchecked exception)という仕組みが、Goといった後発の言語でなぜ採用されなかったのかを整理します。

Javaから他の言語に移ったエンジニアが「なぜこの言語には検査例外がないのか」と疑問を持ったときの答えになることを目指します。

前提:Javaが設計された時代

Javaは1990年代に設計された言語です。当時の優先事項は「安全性」と「信頼性」であり、エラーを見逃さないようにコンパイラが強制するという設計は合理的な判断でした。

後発の言語の中には、この設計が実運用でどう使われたかを踏まえて設計されたものがあります。検査例外への批判の多くは、理論的な問題ではなく実際の開発現場で積み重なった経験から来ています。

Javaの検査例外とは何か

Javaには例外を次の2種類に分類する仕組みがあります。

  • 検査例外(checked exception): コンパイラが catch または throws の宣言を強制する例外。IOExceptionSQLException が代表例。
  • 非検査例外(unchecked exception): コンパイラが処理を強制しない例外。RuntimeException のサブクラスがこれにあたる。
// 検査例外の例: コンパイラが処理を強制する
public void readFile(String path) throws IOException {
    // IOException を throws で宣言しないとコンパイルエラー
    Files.readAllBytes(Paths.get(path));
}

この設計の意図は「エラーを握りつぶさせない」ことにありました。呼び出し元に例外の存在を意識させ、適切な処理を書かせるというものです。

なぜ検査例外は批判されるのか

C#の設計者である Anders Hejlsberg は、検査例外の問題として「バージョニング」と「スケール」の2点を挙げています。Java設計者の James Gosling も、空catchが多発する実態は認識していたと述べており、これは理想と現実のギャップが設計者自身にも見えていたことを示しています。

呼び出し元を強制的に汚染する

検査例外はメソッドシグネチャに throws を書く必要があります。これが呼び出し元、さらにその呼び出し元へと連鎖していきます。

// throws が連鎖してコードが冗長になる
public void processFile() throws IOException {
    readFile("path/to/file");
}

public void run() throws IOException {
    processFile();
}

形骸化した握りつぶしを生む

コンパイラに強制されると、適切に処理できない場合でも何かを書かざるを得なくなります。結果として次のようなコードが生まれます。

// とりあえず catch して何もしない、最悪のパターン
try {
    readFile("path");
} catch (IOException e) {
    // 何もしない
}

設計の意図とは逆に、エラーを無視するコードを量産しやすい状況を生みやすいです。

実務でも、catch ブロックにコメントすら書かれていないコードを見かけることがあります。コンパイルを通すためだけに書かれた catch であり、エラーが握りつぶされていることに気づくのはバグが発生してからです。ログも出ないため、何が起きたか追跡する手がかりがまったくありません。

インターフェースの柔軟性を下げる

検査例外はインターフェースの設計に影響します。インターフェースに throws を書いた時点で、「このメソッドが投げる例外の種類」が契約として固定されます。

public interface FileService {
    void read() throws IOException;
}

たとえばファイルではなくDBからデータを読む実装を追加したくなったとします。

public class DBFileService implements FileService {
    public void read() throws SQLException {
        // コンパイルエラー:SQLException は IOException ではない
    }
}

インターフェースは IOException しか許可していないため、SQLException を投げる実装はそのままでは作れません。回避するには throws Exception に変えて型を雑にするか、SQLExceptionIOException にラップするかになります。どちらも不自然です。

後から例外の種類を追加したい場合もやっかいです。

// 変更前
void read() throws IOException;

// 変更後(SQLException も必要になった)
void read() throws IOException, SQLException;

この変更だけで、このインターフェースを使っているコード全体にコンパイルエラーが発生します。Hejlsberg が「インターフェースにメソッドを後から追加できない問題と同じ」と表現したのはこの意味です。本来インターフェースは振る舞いだけを定義するものですが、検査例外によって実装の詳細である「例外の種類」まで固定してしまいます。

Java 8以降のラムダとの摩擦

Java 8で導入されたラムダとStream APIも、検査例外との相性が悪いです。標準の関数型インターフェース(Function など)は検査例外を宣言していないため、ラムダの中で検査例外を投げると、局所的なtry/catchや RuntimeException へのラップが必要になります。

// ラムダの中で検査例外を扱うには回避策が必要になる
List<String> paths = List.of("a.txt", "b.txt");
paths.stream()
    .map(path -> {
        try {
            // Files.readString は IOException(検査例外)を投げる
            return Files.readString(Path.of(path));
        } catch (IOException e) {
            // 非検査例外に変換しないとコンパイルエラー
            throw new RuntimeException(e);
        }
    })
    .forEach(System.out::println);

検査例外の仕組みが、後から追加された関数型スタイルとうまくかみ合わない例です。

Goはどうしているか

Goには一般的な例外ベースのエラー処理機構はありません。通常のエラーは戻り値として返します。

// エラーは戻り値として返す
func readFile(path string) ([]byte, error) {
    return os.ReadFile(path)
}

func main() {
    data, err := readFile("path/to/file")
    if err != nil {
        // エラー処理を明示的に書く
        log.Fatal(err)
    }
    _ = data
}

この設計には次の特徴があります。

  • エラーの存在がシグネチャで明示される
  • 呼び出し元は err を受け取って自分で判断できる
  • Javaのような例外処理の強制はなく、慣習として err を確認する

Goの設計思想は「シンプルさ」です。例外という特別な制御フローを持ち込まず、エラーもただの値として扱います。Go設計者の Rob Pike は「Errors are values」という考え方を示しており、if err != nil の繰り返しを単なる作法として終わらせず、エラーも抽象化・合成の対象にできると説いています。

Javaのような強制はありませんが、Goには未使用変数をコンパイルエラーとするルールがあります。err を変数に受けた場合は何らかの形で使う必要があります。ただし _ で捨てることもできるため、チェックを完全に防ぐわけではありません。実際には errcheck などのlintツールやコードレビューによって担保されることが一般的です。

panic と recover の位置づけ

Goには panicrecover という仕組みもあり、これは例外に近い動作をします。ただし一般的なエラー処理には使わず、プログラムのバグや不変条件の崩壊など、通常の制御フローでは扱えない状況に限るのが慣習です。ファイルが見つからない、DBの接続が失敗したといった通常のエラーは error 値で返します。

これも「エラーは値」という思想の一貫です。例外が通常の制御フローに混在する状態を避けています。

エラーの文脈を伝える

Goではエラーをラップして文脈を付加する方法が慣用的に使われています。

// fmt.Errorf の %w でエラーをラップし、呼び出し元で errors.Is / errors.As で判定できる
if err != nil {
    return fmt.Errorf("ファイル読み込み失敗: %w", err)
}

errors.Iserrors.As を使えば、ラップされたエラーの種類を判定できます。型システムの外でエラーの種類を扱える柔軟さがあります。

エラー処理の設計パターン

後発の言語はそれぞれ異なる方向で同じ問題に対処しています。理由や方向性はバラバラで、一括りに「検査例外を否定した」とは言えませんが、大きく3つの系統に整理できます。

  • Java: コンパイラが強制する(検査例外)。意図は正しかったが、形骸化しやすく設計が硬直化しやすいという問題が指摘されている。
  • Go: エラーを値として返す別思想。Javaの代替というよりそもそも設計の方向が違う。
  • Rust: Result 型でエラーを表現し、コンパイラが処理を促す。型システムの力で安全性を担保する。

KotlinやC#は検査例外を廃止しつつも例外機構自体は残しています。Kotlinの場合はJavaとの互換性を保ちながらコンパイラの強制を外しており、C#は最初から非採用という意図的な設計です。C#はJavaの検査例外を「良いアイデアだが、バージョニングとスケールの問題とトレードしただけ」と評価した上での判断です。

なお「例外の種類を宣言として強制する」発想はC++にもありましたが、実運用での経験が芳しくなく、C++17で削除されています。Javaに固有の問題ではなく、この方向性自体が難しさを抱えていたことがわかります。

まとめ

Javaの検査例外は「エラーを見逃させない」という正しい問題意識から生まれた設計です。検査例外は失敗した設計というより、安全性を最大化しようとした結果として進化可能性や書きやすさとトレードオフになった設計と捉えるのが正確です。

後発の言語はそれぞれの文脈でそのトレードオフを別の形で解こうとしました。Goはエラーをただの値として扱い、強制は文化とツールに委ねる方向を選びました。どちらが正解というわけではなく、言語ごとの設計思想の違いとして理解するのが実務的です。

参考

Discussion