前回の記事では、Javaの例外の基本的な使い方を学んだよね。
今回は、さらに一歩進んで、モダンなJava開発における例外設計のベストプラクティスについて掘り下げていこう。巷には色々な情報があるけれど、これさえ押さえておけば、君のコードはグッと読みやすく、そして堅牢になるはずだ。
例外って、何のために使うんだっけ?
まず、例外設計を考える前に、例外が何のために存在するかを改めて考えてみよう。例外は、**「プログラムの正常な実行フローでは起こりえない、予期せぬ問題が発生したこと」**を知らせるためのものだ。例えば、ファイルが見つからない、ネットワークに接続できない、数値のフォーマットが間違っている、なんて場合だね。
よくある間違いとして、「ビジネスロジックの分岐」に例外を使ってしまうケースがある。例えば、「ユーザーが見つからなかった場合に例外をスローする」なんてやり方だ。でも、ユーザーが見つからないことは、そのアプリケーションにおいては「あり得る」状態かもしれないよね?こういう場合は、Optional型を返したり、特定の値を返したりして、正常系のフローで処理するのが一般的だ。
Checked例外とUnchecked例外、どっちを使うべき?
Javaには大きく分けてChecked例外とUnchecked例外の2種類がある。
- Checked例外:
IOExceptionやSQLExceptionのように、コンパイル時に捕捉(または宣言)が義務付けられている例外だ。これは、「このメソッドを呼び出すなら、この例外が起こるかもしれないから、ちゃんと対処してね!」とコンパイラが教えてくれるわけだ。 - Unchecked例外:
NullPointerExceptionやIllegalArgumentExceptionのように、コンパイル時には捕捉が義務付けられていない例外だ。これらは主に、プログラマーのミス(バグ)や、回復不能な問題を示すことが多い。
昔は「Checked例外を積極的に使って、あらゆるエラーを明示的に処理すべき!」という考え方が主流だった。でも、現代のJava開発では、**「基本的にはUnchecked例外(特にRuntimeExceptionのサブクラス)を使うべき」**という考え方が主流になっている。
なぜかって?Checked例外は、呼び出し元に例外処理を強制するから、場合によってはコードが複雑になり、可読性が落ちてしまうんだ。例えば、複数のChecked例外を扱う場合、catchブロックがずらっと並んで、本来の処理が見えにくくなるなんてことも。
もちろん、Checked例外が全く不要というわけじゃない。例えば、外部システムとの連携や、回復可能なエラー(ユーザーに入力し直してもらうなど)を明示的に伝える必要がある場合には、Checked例外も有効な選択肢になる。でも、**「本当にその例外を呼び出し元で回復できるのか?」「回復できないのであれば、わざわざChecked例外にする意味があるのか?」**という視点で考えることが重要だ。
カスタム例外を作るときのポイント
標準の例外クラスだけでは表現しきれない、独自のビジネスルールに基づいたエラーがある場合、カスタム例外を作成することになる。カスタム例外を作る際のポイントをいくつか紹介するよ。
1. RuntimeExceptionを継承する
多くの場合、カスタム例外はRuntimeExceptionを継承してUnchecked例外として定義するべきだ。これにより、呼び出し元に強制的な例外処理を押し付けることなく、必要な箇所で適切に捕捉できる。
Java
// 例えば、商品の在庫が足りない場合
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String message) {
super(message);
}
public InsufficientStockException(String message, Throwable cause) {
super(message, cause);
}
}
2. 例外の情報を豊富にする
単に「エラーが発生しました」だけでは、何が起きたのか分かりにくいよね。例外には、何が、なぜ、どのように発生したのかを伝えるための情報を含めるようにしよう。例えば、エラーコードや、具体的な引数の値などだ。
Java
public class InvalidInputException extends RuntimeException {
private final String fieldName;
private final String inputValue;
public InvalidInputException(String message, String fieldName, String inputValue) {
super(message);
this.fieldName = fieldName;
this.inputValue = inputValue;
}
public String getFieldName() {
return fieldName;
}
public String getInputValue() {
return inputValue;
}
}
// 使い方
// throw new InvalidInputException("ユーザー名が不正です", "username", "bad_user%");
3. 例外チェーンを意識する
ある例外が発生したことが原因で、別の例外が発生することはよくある。このような場合、元の例外(原因となる例外)を新しい例外に含めることで、問題の原因を追跡しやすくなる。これを例外チェーンと呼ぶよ。
Java
try {
// データベースアクセス処理
// SQLExceptionが発生する可能性がある
} catch (SQLException e) {
// SQLExceptionを原因として、カスタム例外をスロー
throw new DataAccessException("データの読み込みに失敗しました", e);
}
DataAccessExceptionのようなカスタム例外を作成し、元のSQLExceptionをコンストラクタで渡すことで、スタックトレースに元の例外の情報も含まれるようになるんだ。
例外をキャッチするときのコツ
例外をキャッチするときも、ただcatch (Exception e)とするだけじゃなくて、いくつかのポイントがあるんだ。
1. 具体的な例外をキャッチする
「Don’t Catch ‘Em All!(全部キャッチするな!)」という言葉があるように、Exceptionのような汎用的な例外をまとめてキャッチするのは避けるべきだ。それでは、どんな種類の例外が起きたのかが分かりにくくなり、適切なエラーハンドリングができない可能性があるからね。
できる限り、具体的な例外クラスを指定してキャッチしよう。
Java
try {
// ファイル読み込み処理
} catch (FileNotFoundException e) {
// ファイルが見つからなかった場合の処理
System.err.println("ファイルが見つかりません: " + e.getMessage());
} catch (IOException e) {
// その他のIOエラーの場合の処理
System.err.println("IOエラーが発生しました: " + e.getMessage());
}
2. 例外を再スロー(Re-throwing)する
キャッチした例外を適切に処理できない場合や、より上位のレイヤーで処理すべき場合は、例外を再スローすることも検討しよう。ただし、再スローする際は、元の例外の情報を失わないように注意が必要だ。
Java
public void readFile(String filePath) throws CustomFileProcessingException {
try {
// ファイル読み込み処理
} catch (IOException e) {
// 例外をログに出力し、より上位の例外に変換して再スロー
System.err.println("ファイル読み込み中にエラーが発生しました: " + e.getMessage());
throw new CustomFileProcessingException("ファイル処理中にエラーが発生しました", e);
}
}
ここではCustomFileProcessingExceptionというChecked例外を定義しているけど、アプリケーションの設計によってはRuntimeExceptionを継承したものを利用することも検討しよう。
3. finallyブロックを使いこなす
finallyブロックは、例外が発生したかどうかにかかわらず、必ず実行される処理を書く場所だ。ファイルのクローズ処理やデータベースコネクションの解放など、リソースの解放処理はfinallyブロックに書くのが基本だね。
Java
FileInputStream fis = null;
try {
fis = new FileInputStream("file.txt");
// ファイル読み込み処理
} catch (IOException e) {
System.err.println("エラー: " + e.getMessage());
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
System.err.println("ファイルのクローズに失敗しました: " + e.getMessage());
}
}
}
Java 7以降では、Try-with-resources文を使うことで、finallyブロックで明示的にリソースを閉じる手間を省けるようになった。これはぜひ活用したい機能だ。
Java
try (FileInputStream fis = new FileInputStream("file.txt")) {
// ファイル読み込み処理
} catch (IOException e) {
System.err.println("エラー: " + e.getMessage());
}
これを使えば、FileInputStreamがAutoCloseableインターフェースを実装していれば、tryブロックを抜けるときに自動的にclose()メソッドが呼ばれるんだ。
ロギングとの連携
例外が発生した場合は、その情報をログに出力することが非常に重要だ。ログを見ることで、何が、いつ、どこで発生したのかを把握し、問題解決の手がかりを得られるからね。
Javaでは、java.util.loggingや、より高機能なロギングフレームワークであるLog4jやSLF4j + Logbackなどがよく使われる。特に、プロダクション環境ではこれらのロギングフレームワークを使うことがほとんどだ。
Java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);
public void processData() {
try {
// データ処理
} catch (Exception e) {
// エラーログとして例外情報を出力
logger.error("データ処理中に予期せぬエラーが発生しました", e);
// 必要に応じて例外を再スロー
throw new RuntimeException("データ処理に失敗しました", e);
}
}
}
logger.error("メッセージ", e)のように、第2引数に例外オブジェクトを渡すことで、スタックトレースを含めた詳細な例外情報がログに出力されるようになる。これはデバッグの際に非常に役立つ情報だよ。
例外設計の原則まとめ
ここまで色々な話をしてきたけれど、現代的なJavaの例外設計における主要な原則をまとめると、以下のようになる。
- 例外は「予期せぬ問題」のために使う: ビジネスロジックの分岐には使わない。
- 基本はUnchecked例外: Checked例外は、呼び出し元で回復可能な場合や、外部との明確な契約が必要な場合に限定する。
- カスタム例外は
RuntimeExceptionを継承: 豊富な情報を含め、例外チェーンを意識する。 - 具体的な例外をキャッチ: 汎用的な
Exceptionをまとめてキャッチするのは避ける。 - リソース解放は確実に: Try-with-resourcesを積極的に活用する。
- ロギングは必須: 例外情報を詳細にログに出力する。
まとめ
今回の記事では、Javaの最新の例外設計について、Checked例外とUnchecked例外の使い分け、カスタム例外の作成方法、そして例外をキャッチする際のベストプラクティスについて詳しく見てきた。
例外設計は、アプリケーションの堅牢性と保守性を大きく左右する重要な要素だ。今回学んだ内容を実践することで、君のJavaコードはより高品質なものになるはずだ。
もしもっと深く学びたいなら、以下のリンクも参考にしてみてね!


コメント