Java : 例外 vs. 戻り値でエラーチェック
メソッドの処理が失敗した場合
- 例外を投げる
- 戻り値で成功/失敗、もしくはエラーコードを返す
どちらにするか迷ったりしないでしょうか?
結論としては例外をおすすめするのですが、例外のおさらいもしつつ、そのあたりを解説していきたいと思います。
例外のおさらい
Javaの例外は大きく分けて3つの種類があります。
- チェック例外
- 非チェック例外
- エラー
補足
- エラーも厳密には非チェック例外ですが、本記事では別扱いとしています。
クラス関係は下図のようになります。
チェック例外
Exception (Java SE 17 & JDK 17)
Exceptionとそのサブクラス(RuntimeExceptionを除く)をチェック例外といいます。
代表的なチェック例外は、IOException、ExecutionExceptionなどです。
チェック例外はプログラムに問題がなくても発生しうるときに使うべき例外です。
つまり、プログラム以外の外的要因によって発生する、ということです。
例えばIOExceptionであれば、読み込み対象のファイルをユーザが手動で勝手に削除してしまって、読み込み失敗…ということはありえます。
チェック例外は明示的にcatchまたはthrowsを指定しないとコンパイルエラーとなります。
// コンパイルエラー
public void printFile() {
final var bytes = Files.readAllBytes(Path.of("sample.data"));
System.out.println(Arrays.toString(bytes));
}
// catch (コンパイルOK)
public void printFile() {
try {
final var bytes = Files.readAllBytes(Path.of("sample.data"));
System.out.println(Arrays.toString(bytes));
} catch (IOException e) {
System.out.println("例外発生!");
}
}
// throwsを指定 (コンパイルOK)
// printFileメソッドを呼び出す側にIOExceptionのcatch処理を任せます。
public void printFile() throws IOException {
final var bytes = Files.readAllBytes(Path.of("sample.data"));
System.out.println(Arrays.toString(bytes));
}
これは非常に重要な特徴です。
チェック例外は、どこかでcatchしないとコンパイルエラーとなります。
つまり、例えばFiles.readAllBytesではIOExceptionが発生するということが強制的にわかります。
(API仕様を読まなくても)
Javaの言語仕様の例外から引用です。
ざっくりと意訳すると…
メソッドの戻り値として-1などのエラー値を返すと、経験的に、それに気づかなかったり無視されたりすることがよくあります。
代わりに例外という仕組みを提供し、堅牢なエラー処理を実現します。
という感じでしょうか。
でも、チェック例外はいちいちthrowsを書かないといけないから面倒くさい。
すごくわかります。
public void func1() {
try {
func2();
} catch (Exception e) {
System.out.println("例外発生!");
}
}
public void func2() throws Exception {
func3();
}
public void func3() throws Exception {
func4();
}
public void func4() throws Exception {
if (...) {
// 成功
} else {
// 失敗
throw new Exception();
}
}
このように、処理の奥の方で例外が発生する場合、その途中のメソッドすべてにthrowsが必要となります。
しかし、だからといって安易に非チェック例外を使うことはおすすめしません。
チェック例外のメリットはcatchを忘れたらコンパイルエラーで知らせてくれることだからです。
記述の面倒くささはIDEを使えばある程度は改善するので(自動で必要な箇所すべてにthrowsを追加してくれたりします)、そちらをおすすめします。
非チェック例外
RuntimeException (Java SE 17 & JDK 17)
RuntimeExceptionとそのサブクラスを非チェック例外といいます。
代表的な非チェック例外は、NullPointerException、IndexOutOfBoundsExceptionなどです。
NullPointerExceptionは誰でも一度は経験したことがあるのではないでしょうか。
非チェック例外は、プログラムに問題がなければ発生しないときに使うべき例外です。
つまり、プログラム内部に問題があるときのみに発生します。
例えばNullPointerExceptionであれば、それはコードが間違っているので修正が必要、ということになります。
チェック例外との違いは、catchやthrowsが不要なことです。
基本的に発生したらダメなので、catchすることも少ないでしょう。
エラー
Errorとそのサブクラスをエラーといいます。
代表的なエラーは、OutOfMemoryError、AssertionErrorなどです。
エラーはプログラム以外の外的要因でも発生しえますが、通常のアプリケーションではcatchしてはいけません。
OutOfMemoryErrorはキャッチするべきだと思うかもしれません。
例えば、Files.readAllBytesで巨大なファイルを読み込むとOutOfMemoryErrorが発生します。
しかし、それはアプローチを変えてみたほうがよいことが多いでしょう。
(一度に読み込まずにInputStreamを使う、事前にファイルサイズを確認する、などなど)
例外 vs. 戻り値でエラーコード
例外がおすすめ
基本的にはチェック例外を使うことをおすすめします。
チェック例外は、例外をcatchすることを強制します。
つまり、エラー処理を忘れるということがありません。(コンパイルエラーになるため)
一方、戻り値で成功/失敗やエラーコードを返す場合だと、エラー処理を忘れてもコンパイルエラーとなりません。
public void main() {
// コンパイルエラーにならない!
func();
}
public boolean func() {
...
if (...) {
// 成功
return true;
} else {
// 失敗
return false;
}
}
public void main() {
// 本当はこうする必要があった。
if (!func()) {
// ユーザにエラーメッセージ表示
...
}
}
この手の不具合は、非常に見つかりにくいです。
処理が失敗したときにだけ不具合は発生します。
そのため、テスト環境だと処理が失敗するケースがなく不具合が発覚せず…
↓
本番環境で使用人数が増え処理が失敗するケースも出て、不具合発覚なんてことも。
外部サイトで英語ですが、例外と戻り値についての議論があるので、こちらも参考にしてみてください。
language agnostic - Which, and why, do you prefer Exceptions or Return codes? - Stack Overflow
例外を使わないケース
とはいえ、すべて例外で実装すると、try/catch, throwsだらけになって少し嫌ですよね。
ここでは例外とは違うアプローチの仕方を紹介します。
例
- ユーザ登録画面で、ユーザの名前を入力するUIがあるとします。
- ユーザの名前には記号文字は使えない、という制限があります。
ユーザが記号文字を入力したときのエラー処理はどのようにするのがよいか、一緒に考えていきましょう。
まずチェック例外を使ってみます。
// ユーザ名が不正なときに発生する例外
public class InvalidUserNameException extends Exception {
}
public void onAddUserAction() {
final String userName = ...; // ユーザが入力した名前
try {
addUser(userName);
} catch (InvalidUserNameException e) {
// ユーザに"名前には記号は使えません"とメッセージを表示
...
}
}
public void addUser(String userName) throws InvalidUserNameException {
// userNameに記号が含まれていないかチェック
if (...) {
throw new IllegalUserNameException();
}
...
}
ユーザが入力した名前は外的要因となるので、チェック例外を使うのは間違っていません。
悪くはないと思います。
ただ、"例外"というわりには少し簡単に起きすぎる気もします。
そこで、addUserで例外を返すのではなく、ユーザ名が正常かどうかチェックするメソッドを作ってみましょう。
public void onAddUserAction() {
final String userName = ...; // ユーザが入力した名前
if (!isValidUserName(userName)) {
// ユーザに"名前には記号は使えません"とメッセージを表示
...
return;
}
addUser(userName);
}
public void addUser(String userName) {
if (!isValidUserName(userName)) {
throw new IllegalArgumentException();
}
...
}
public boolean isValidUserName(String userName) {
if (...) {
// 記号なし
return true;
} else {
// 記号あり
return false;
}
}
isValidUserNameで事前にユーザ名をチェックするようにしてみました。
addUserメソッドでは、userNameが不正であれば非チェック例外であるIllgealArgumentExceptionを投げるように変更しています。
つまり、addUserメソッドを呼び出す前に、必ずユーザ名がチェックされていることを前提とします。
契約プログラミングの事前条件とも呼ばれる手法です。
このアプローチもありかなと思います。
もちろん、isValidUserNameで事前にチェックするのを忘れるかもしれません。(コンパイルエラーも発生しない)
その場合はaddUserメソッドを呼ぶと例外が発生します。そこで間違いに気づけます。
このあたりは、ある程度プログラミングの経験を積んでいても難しいかもしれません。
どこまでを例外にするか…バランスだと思います。
迷ったら、より堅牢である例外を使っておくのが無難かと思います。
非チェック例外は標準APIのものを使おう
非チェック例外は、標準APIに汎用的に使えるものがいくつかあります。
独自の非チェック例外クラスを作る前に、標準APIに使えるものがないか探してみるのをおすすめします。
汎用的に使えそうな例外をいくつか紹介します。
- IllegalArgumentException (Java SE 17 & JDK 17)
- IllegalStateException (Java SE 17 & JDK 17)
- UnsupportedOperationException (Java SE 17 & JDK 17)
まとめ
本記事では、戻り値でエラーをチェックするより例外を使おう、という趣旨となりました。
エラーコードを返すことを完全に否定するわけではありません。
場合によってはそのほうが良いケースもあるかと思います。
不安になったら熟練の先輩などにコードレビューを依頼してみるのもよいかもです。
関連記事
- API 使用例
- Error (エラー)
- Exception (チェック例外)
- RuntimeException (非チェック例外)
- ArithmeticException (算術例外)
- ArrayIndexOutOfBoundsException
- ArrayStoreException
- CancellationException
- ClassCastException (キャスト例外)
- ConcurrentModificationException (並列処理例外)
- DateTimeException (日付・時刻の例外)
- DateTimeParseException (日付・時刻の解析例外)
- IllegalArgumentException
- IllegalStateException
- IndexOutOfBoundsException
- NoSuchElementException
- NullPointerException
- PatternSyntaxException
- StringIndexOutOfBoundsException
- UnsupportedOperationException
- Throwable