本ページはプロモーションが含まれています

Java入門

もう迷わない!Java検査例外の基本と3つのベストプラクティス

トム

・SaaS勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

Javaプログラミングを学び始めると、多くの人が「検査例外」の壁にぶつかります。コンパイラに「例外処理をしろ」と怒られ、意味も分からずtry-catchで囲んだ経験はありませんか。

実は、私もJava開発を始めたばかりの頃はそうでした。10年以上の開発経験を積んだ今だからこそ断言できますが、検査例外はJavaの堅牢性を支える非常に優れた仕組みです。ただ強制されるルールではなく、その設計思想を理解すれば、コードの品質を格段に向上させる武器になります。

この記事を読めば、あなたは以下の悩みを解決できます。

  • Javaの検査例外とは何か、なぜ必要なのかが分かる
  • try-catchthrowsの正しい使い分けが理解できる
  • 現場で役立つ例外処理のベストプラクティスが身につく

検査例外とは何か

最初に、Javaの検査例外の基本的な概念を解説します。なぜこのような仕組みが存在するのか、その設計思想まで掘り下げてみましょう。

Javaにおける例外の種類(検査例外と非検査例外)

Javaの例外は、大きく分けて「検査例外」と「非検査例外」の2種類に分類されます。

この2つを分ける最も大きな違いは、コンパイラが処理を強制するかどうかです。

検査例外

  • コンパイル時にチェックされ、プログラマに例外処理を強制します
  • Exceptionクラスのサブクラス(RuntimeExceptionを除く)がこれにあたります
  • 回復可能性のある、予期すべきエラー通知に使われます

非検査例外

  • コンパイル時にチェックされず、処理を強制されません
  • RuntimeExceptionErrorの2種類があります
  • RuntimeException: プログラムのバグなど、主にプログラマのミスが原因です
  • Error: システムの致命的な問題で、プログラム側では回復不可能な状況を示します

検査例外の定義と特徴

検査例外の最大の特徴は、メソッド内で発生する可能性がある場合、必ず対処しなければならない点です。対処法は以下の2つしかありません。

ポイント

  1. try-catchブロックで例外を捕捉し、その場で処理する
  2. throws宣言を使い、メソッドの呼び出し元に例外処理の責任を転嫁する

どちらかの対応をしないと、コンパイルエラーとなりプログラムを実行できません。これは、開発者に対して「この処理は失敗する可能性があるから、きちんと備えておきなさい」というJavaからのメッセージなのです。

なぜ検査例外が存在するのか

では、なぜJavaはわざわざこのような面倒に思える仕組みを導入したのでしょうか。

その理由は、堅牢で信頼性の高いプログラムを作るためです。

ファイル操作やネットワーク通信など、プログラムの外部環境に依存する処理は、成功が保証されません。例えば、読み込もうとしたファイルが存在しないかもしれませんし、接続先のサーバがダウンしている可能性もあります。

このような「起こりうる問題」を開発者に意識させ、事前に対処コードを書かせるのがjava検査例外の狙いです。例外処理を強制することで、プログラムが予期せぬエラーで突然停止してしまう事態を防ぎ、より安定したアプリケーション開発を促しています。

代表的な検査例外の例

ここでは、実際の開発でよく目にする代表的な検査例外を3つ紹介します。これらの例外がどのような場面で発生するのかを知っておくと、エラー発生時の対応がスムーズになります。

IOException

IOExceptionは、データの入出力(I/O)処理で問題が発生した場合にスローされる検査例外です。これは非常によく使われる例外で、以下のような場面で登場します。

  • ファイルの読み書き(FileReader, FileWriter
  • ネットワーク通信(Socket, URL
  • 外部プロセスとのやりとり

例えば、存在しないファイルを読み込もうとした場合や、書き込み先のディスク容量が不足している場合などに発生します。これらの事態はプログラムの実行中に起こりうるため、開発者はIOExceptionを処理し、エラー発生時の代替処理を実装する必要があります。

SQLException

SQLExceptionは、データベース連携時に発生する問題を通知するための検査例外です。JDBC APIを利用してデータベースを操作する際に発生します。

  • データベースへの接続失敗
  • 実行したSQL文の構文エラー
  • 一意制約違反(重複したデータを登録しようとした場合など)

業務システムの多くはデータベースを利用するため、SQLExceptionの処理はJavaでのシステム開発に不可欠です。トランザクション管理と組み合わせ、データの一貫性を保つための重要な役割を担います。

その他のよく使われる検査例外

上記2つの他にも、特定の場面で使われる重要な検査例外が存在します。

  • ClassNotFoundException: クラスパス上に対象のクラスが見つからない場合に発生します。リフレクション(プログラム実行時にクラス情報を動的に扱う仕組み)などでよく見られます。
  • InterruptedException: あるスレッド(処理の実行単位)が待機、スリープ、またはその他の方法で占有されている間に、別のスreadから割り込みがかけられた場合にスローされます。マルチスレッドプログラミングで重要な例外です。

検査例外の使い方と注意点

検査例外を正しく扱うための具体的な方法と、守るべきベストプラクティスを解説します。コードの品質を左右する重要なポイントです。

throws宣言とtry-catchの違い

検査例外の処理方法はthrowstry-catchの2つですが、それぞれ役割が異なります。

  • try-catch: その場で例外を処理する方法です。例外が発生する可能性のあるコードをtryブロックで囲み、発生した例外をcatchブロックで捕捉して対処します。エラー回復処理を自身で行う場合に選択します。
public void readFile() {
    File file = new File("test.txt");
    try {
        FileReader reader = new FileReader(file);
        // ファイルを読み込む処理...
    } catch (FileNotFoundException e) {
        // 例外をここでキャッチして処理する
        System.err.println("ファイルが見つかりませんでした。");
        // スタックトレースを出力してデバッグに役立てる
        e.printStackTrace();
    }
}
  • throws: 例外処理の責任を呼び出し元に転嫁する方法です。メソッドのシグネチャ(メソッド名や引数の情報)にthrowsキーワードと例外クラスを記述します。これにより、このメソッドを利用する側が例外処理を強制されます。
// 呼び出し元にFileNotFoundExceptionの処理を委譲する
public FileReader readFile() throws FileNotFoundException {
    File file = new File("test.txt");å
    FileReader reader = new FileReader(file);
    return reader;
}

// 呼び出し側
public void processFile() {
    try {
        FileReader reader = readFile();
        // 処理を続ける...
    } catch (FileNotFoundException e) {
        System.err.println("ファイルの処理中にエラーが発生しました。");
    }
}

メソッド内でエラーに対処できる場合はtry-catchを、対処できず呼び出し元に判断を委ねたい場合はthrowsを使うのが基本です。

複数の検査例外を処理する方法

1つのtryブロック内で、複数の種類の検査例外が発生する可能性があります。その場合、2つの書き方があります。

1つ目は、catchブロックを複数記述する方法です。例外の種類ごとに異なる処理を行いたい場合に有効です。

try {
    // 複数の検査例外が発生しうる処理
} catch (IOException e) {
    // IOException発生時の処理
} catch (SQLException e) {
    // SQLException発生時の処理
}

2つ目は、マルチキャッチです。複数の例外に対して同じ処理を行いたい場合、コードを簡潔に記述できます。

try {
    // 複数の検査例外が発生しうる処理
} catch (IOException | SQLException e) {
    // IOExceptionまたはSQLExceptionが発生した場合の共通処理
}

例外処理のベストプラクティス

質の高い例外処理を実装するための、3つの重要なベストプラクティスを紹介します。

  1. catchブロックを空にしない最もやってはいけないのが、catchブロックを空にすることです。これは「エラーの握りつぶし」と呼ばれ、問題が発生しても何も通知されず、原因調査が極めて困難になります。最低限、ログに出力するようにしましょう。
  2. 具体的な例外クラスをキャッチするcatch (Exception e)のように、すべての例外の親であるExceptionクラスで捕捉するのは避けるべきです。予期せぬRuntimeExceptionまで捕捉してしまい、本来修正すべき問題を見逃す原因になります。捕捉したい例外クラスを具体的に指定しましょう。
  3. 例外情報を失わない(例外のラップ)下位の例外を捕捉し、より上位の抽象的な例外(独自の業務例外など)でラップして再スローすることがあります。その際は、必ず元の例外情報を引き継ぐようにします。
// 悪い例: 元の例外情報が失われる
catch (SQLException e) {
    throw new MySystemException("データベースエラー"); 
}
// 良い例: causeとして元の例外を渡す
catch (SQLException e) {
    throw new MySystemException("データベースエラー", e); // 第2引数で元の例外を渡す
} 

元の例外をcauseとして設定することで、根本原因のスタックトレースが残り、デバッグが容易になります。

検査例外と非検査例外の使い分け

理論を学んだところで、次は実践的な使い分けについて考えます。適切な例外設計は、APIの使いやすさやシステムの保守性に直結します。

適切な例外設計の考え方

検査例外と非検査例外を使い分ける際の最も重要な判断基準は、「呼び出し元(APIの利用者)が、そのエラーから回復できるか」です。

検査例外を使うべきケース

  • 呼び出し元がエラーから回復できる、あるいは代替処理を行うことが期待される場合。
  • 例: 「指定されたファイルが存在しない(FileNotFoundException)」→ 呼び出し元は「デフォルト設定で処理を続行する」「ユーザーに別ファイルの選択を促す」などの対応が考えられる。

非検査例外(RuntimeException)を使うべきケース

  • プログラムの事前条件を満たしていないなど、明らかにプログラマのバグが原因の場合。
  • 例: nullが渡されるべきでない引数にnullが渡された(NullPointerException)。これは呼び出し側のコードを修正すべき問題であり、その場で回復しようとするべきではない。

APIを設計する際は、「このエラーが起きたとき、API利用者にどうしてほしいか?」を自問自答することが、適切な例外選択の鍵となります。

業務システム開発における検査例外の実例

業務システムでは、独自の検査例外を定義することがよくあります。

例えば、銀行の振込処理を考えてみましょう。「残高不足」はエラーですが、システム障害ではなく業務ルール上の正常な結果です。このような場合、InsufficientBalanceExceptionという独自の検査例外を定義します。

public void transfer(Account from, Account to, long amount) throws InsufficientBalanceException {
    if (from.getBalance() < amount) {
        // 残高不足は業務上の例外として通知
        throw new InsufficientBalanceException("残高が不足しています。");
    }
    // 振込処理...
}

このメソッドの呼び出し元は、InsufficientBalanceExceptioncatchして、「残高が不足しています」というメッセージをユーザーに表示する処理を実装できます。このように、業務上のルールに関わるエラーは、検査例外で表現するのが効果的です。

検査例外を避けるべきケース

検査例外は強力な仕組みですが、現代のJavaプログラミング、特にラムダ式やストリームAPIと組み合わせる際には、扱いにくさが問題になることがあります。

ラムダ式・ストリームAPIと検査例外の相性問題

Java 8で導入されたストリームAPIやラムダ式は、コードを簡潔に記述できる強力な機能です。しかし、これらの多くが受け入れる関数型インタフェース(例: Function, Predicate)のメソッドシグネチャには、throws宣言が含まれていません。

そのため、ラムダ式の本体から検査例外をスローしようとすると、コンパイルエラーになってしまいます。

List<String> fileNames = List.of("file1.txt", "file2.txt");

// コンパイルエラー!
// ラムダ式内でIOExceptionをスローできない
fileNames.stream()
         .map(name -> new FileReader(name)) // FileReaderのコンストラクタはFileNotFoundExceptionをスローする
         .collect(Collectors.toList());

この「相性の悪さ」は、Javaコミュニティでも長年議論されているテーマです。

ラップしてRuntimeExceptionに変換するパターン

この問題を解決する一般的なパターンが、検査例外をtry-catchで捕捉し、非検査例外でラップして再スローする方法です。

fileNames.stream()
    .map(name -> {
        try {
            return new FileReader(name);
        } catch (FileNotFoundException e) {
            // 検査例外を非検査例外でラップしてスローする
            throw new UncheckedIOException(e); // Java 8で追加された便利な非検査例外
        }
    })
    .collect(Collectors.toList());

この方法を使えば、ラムダ式内で検査例外を扱うことができ、コードの記述がスムーズになります。

ただし、このパターンを乱用すると、検査例外が持つ「回復可能なエラーを呼び出し元に通知する」という本来のメリットが失われてしまいます。あくまで、ラムダ式など構造上やむを得ない場合に限定して利用するのが賢明です。

まとめ

最後に、この記事で解説してきた内容を振り返り、検査例外を正しく理解することの重要性を改めて強調します。

検査例外を正しく理解する重要性

Javaの検査例外は、単なるコンパイラのお節介ではありません。それは、プログラムの外部要因によって発生しうる「回復可能なエラー」を開発者に明示し、事前に対処を促すための重要な設計です。この仕組みを理解し、適切に利用することで、エラーに強い、信頼性の高いアプリケーションを構築できます。

最初は面倒に感じるかもしれませんが、その思想を理解すれば、これほど頼りになる機能はないでしょう。

例外設計がコード品質に与える影響

例外設計は、アプリケーション全体の品質を大きく左右します。

  • 適切な例外処理は、問題の早期発見と迅速な原因特定を助けます。
  • 一貫性のある例外設計は、APIの利用者に正しい使い方をガイドし、コードの可読性と保守性を向上させます。
  • 特にチーム開発においては、共通の例外処理ルールを設けることが、プロジェクト全体の成功に繋がります。
  • この記事を書いた人
  • 最新記事

トム

・SaaS勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

-Java入門