初出 JAVA PRESS Vol.32 (2003.09)
例外との正しいつきあい方
一歩進んだプログラムを目指して
例外ってなに?
プログラムを書いていると、try ... catch を書くのがめんどうになったことはありませんか。
たとえば、リスト 1 はファイルを読み込んで単に標準出力に出力を行うプログラムです。これを javac でコンパイルすると、図 1 のようにコンパイルエラーが起きて、コンパイルできません。そこで、「しょうがない、try ... catch を書くか」といって、リスト 2 のように書いたとします。「これでコンパイルはできたから OK」と済ませていませんか。
これでは例外から得ることのできるさまざまな有用な情報を捨ててしまっています。それだけでなく、想定していた動作を行うことができないかもしれません。
逆にいえば、例外を有効に使いこなせるようになれば、プログラムの堅牢性を高めることができ、また保守性も向上させることが可能です。そんな例外の基本から例外を使いこなすための Tips まで紹介していきましょう。
import java.io.BufferedReader; import java.io.FileReader; public class ReaderSample { public ReaderSample(String filename) { BufferedReader reader = new BufferedReader(new FileReader(filename)); while (true) { String str = reader.readLine(); if (str == null) { break; } System.out.println(str); } reader.close(); } public static void main(String[] args) { new ReaderSample(args[0]); } } |
リスト 1 例外処理をしていないプログラム |
---|
図 1 ReaderSample のコンパイル結果 |
---|
import java.io.BufferedReader; import java.io.FileReader; public class ReaderSample { public ReaderSample(String filename) { try { BufferedReader reader = new BufferedReader(new FileReader(filename)); while (true) { String str = reader.readLine(); if (str == null) { break; } System.out.println(str); } reader.close(); } catch (Exception ex) {} } public static void main(String[] args) { new ReaderSample(args[0]); } } |
リスト 2 最低限の例外処理しかしていないプログラム |
---|
例外のメカニズム
C 言語のように、例外処理が言語仕様として取り入れられていないプログラムでは、障害を表わすためにメソッドの戻り値や障害を示すためのフラグがよく使用されています。たとえば、C 言語でファイルを fopen を使用して、オープンすることを考えてみましょう。
ファイルが存在しなかったときなどファイルがオープンできないと、fopen の戻り値は NULL になります。ところが、リスト 3 では戻り値のチェックを行っていません。このため、ファイルがオープンできなかった場合、getc メソッドに NULL が渡されてしまいます。この結果、プログラムは異常終了します。
正しくは if 文を使用して fopen メソッドの戻り値をチェックすることなのですが、問題なのは戻り値のチェックをプログラマに強制させることができないことにあります。
Java では、例外を使用することでこのような問題に対応しています。プログラムの中で何らかの障害があった場合、例外がスローされるので、それに対応した例外処理を行う必要があります。
例外処理
例外処理を行うには try、catch、finally という予約語が使用されます。それぞれブロックを構成し、その中には次のような記述を行います。
try { 例外の発生する可能性のあるコードを記述する。 } catch (発生する可能性ある例外) { 発生する可能性のある例外を処理するためのコードを記述する。 } finally { 例外の発生の有無にかかわらず、最後に処理されるコードを記述する。 }
複数の例外が発生する場合は、catch ブロックを続けて記入することができます。また、finally ブロックは省略することが可能です。例外が起きないのであれば try ... finally と書くこともできます。
try ブロックの中で例外が発生すると、即座に catch ブロックに処理が移ります。finally ブロックが存在した場合は、例外の有無に関わらず必ず最後に finally ブロックが処理されます。
finally ブロックは常に実行されるということを注意しなくてはなりません。たとえば、リスト 4 を実行すると結果はどのようになるでしょうか。図 2 の実行結果を見て、意外に思われた方も多いと思います。
このプログラムは 8 行目で bar メソッドをコールすると、 3 行目の bar メソッドの中で必ず例外が発生するようになっています。例外がスローされるので、処理は 10 行目の catch ブロックに飛び、11 行目の return が実行されます。ところが、return を実行した後に、12 行からの finally ブロックが実行されます。そして、なんともう一度 return が実行されてしまうのです。そのため、図 2 に示したように finally を出力するのです。
return が try ブロックと finally ブロックにあるようなプログラムを書くことはないと思いますし、このプログラムを javac コンパイルすると finally ブロックが正常に完了しないことを警告されるので気づくことができると思います。しかし、詳しくは後述しますが、finally ブロックで例外をスローするときに同じ問題に遭遇します。
例外をスローする
何らかの障害をプログラム中で検知したときは新たに例外を生成して、スローすることができます。例外をスローするときには throw を使用します。
throw new XXXXException();
メソッドの中で例外がスローされる可能性のある場合、メソッドの定義に throws を使用して例外の種類を列記します。
public void foo() throws XXXXException { ... // 何らかの原因で障害発生 throw new XXXXException(); }
ただし、実行時例外 (後述) の場合は thrwos で表記する必要はありません。
図 2 FinallyTest の実行結果 |
---|
#include <stdio.h> int main(int argc, char** argv) { FILE* fp; int c; fp = fopen("sample.txt", "r"); c = getc(fp); printf("%c", c); fclose(fp); } |
リスト 3 C 言語でのファイルオープン |
---|
1: public class FinallyTest { 2: private static void bar() throws Exception { 3: throw new Exception(); 4: } 5: 6: private static String foo() { 7: try { 8: bar(); 9: return "normal"; 10: } catch (Exception ex) { 11: return "abnormal"; 12: } finally { 13: return "finally"; 14: } 15: } 16: 17: public static void main(String[] args) { 18: System.out.println("Return value of foo: " + foo()); 19: } 20: } |
リスト 4 finally の例 |
---|
例外の種類
さて、一番はじめに提示したプログラム (リスト 1、リスト 2) をもう一度見てみましょう。例外処理を行わないとコンパイルエラーが出ることは図 1 に示したとおりです。
コンパイル結果は FileNotFoundException と IOException の例外処理が行われていないことを示しています。コンパイルエラーが出てから例外処理を考えるのでもいいのですが、それよりはアプリケーションの設計時から例外処理を考慮すべきです。設計時から考慮することで、例外の取り扱いをアプリケーションで一貫させることができ、それに則って例外処理を行うことが可能だからです。
設計時から考えるということは、事前にどこでどのような例外が発生するかなどを知っておくにこしたことはありません。
JavaDoc を見ると、メソッドでスローされる可能性のある例外が分かります。たとえば、FileReader クラスのコンストラクタは次のようになっており、ファイルが見つからない場合に FileNotFoundException がスローされることが分かります。
FileReaderpublic FileReader(String fileName) throws FileNotFoundException
|
JavaDoc では例外を @throws もしくは @exception タグで記述できます。プログラムを自作するときにも、なるべく JavaDoc を記述することをお勧めします。
このように Java では様々な例外の種類がありますが、次の章では多々ある例外を分類して、その特徴を調べてみましょう。
4 種類の例外
Java の例外は次の 4 種類に分類することができます。
- チェックすべき例外 (Checked Excepion)
- 実行時例外 (Runtime Exception)
- エラー (Error)
- アサーションエラー (Assertion Error)
チェックすべき例外
チェックすべき例外は一般の例外を示しており、try ... catch を使用して例外処理を行うことが義務付けられています。try ... catch を行っていないプログラムを javac でコンパイルをすると、「例外 XXX は報告されません」とコンパイルエラーになります。チェックすべき例外には、前述の FileNotFoundException、IOException 以外にも InterruptedException や MalformedURLException などがあります。
実行時例外
実行時例外とエラーは、例外処理を行うことは義務付けられていません。一般にはこれらの例外が発生した場合、回復は不可能なことが多いです。
実行時例外はプログラムのエラーにより引き起こされるものです。細心の注意を払ってコーディングされていれば、通常は発生することはありません。たとえば、ArrayIndexOutOfBoundsException は配列のサイズ以上のインデックスを使用して配列にアクセスしたときに発生しますが、次のようにインデックスの値をチェックするだけで例外が起こることを抑止することができます。
if (index >= 0 && index < a.length) { // インデックスのチェック Object b = a[index]; }
実行時例外は try ... catch で処理をすることもできますが、ここで示したようになるべく実行時例外がおきないようなコーディングをすることのほうが重要です。
実行時例外には ArrayIndexOutOfBoundsException 以外に、UnsupportedEncodingException、NumberFormatException、UnsupportedOperationException などがあります。
エラー
エラーは何らかの理由で、Java VM が回復不能状態になったときに発生します。このため、try ... catch で例外処理を行うことはできません。エラーには、StackOverflowError や OutOfMemoryError などがあります。
最後のアサーションエラーは前の 3 つの例外とはかなり趣きが違います。アサーションは J2SE 1.4 から使用できるようになった機構で、assert 文を使用して記述します。assert 文は
assert [expression1]; assert [expression1] : [expression2];
のように記述します。expression1 が true だと何もおきませんが、false になったときに AssertionError がスローされます。しかし、AssertionError は catch してはいけません。
AssertionError がスローされるとコンソールにスタックトレースが表示されます。このとき expression2 が記述してあれば、一緒に表示されます。たとえば、次のように記述してあったとします。
assert i > 10 : "i が小さすぎます" + i;
i が 10 以下だと AssertionError が発生し、i が 2 だとすると、「i が小さすぎます i =2」 と一緒に表示されます。
詳しく使い方は参考文献の 6, 7 をご覧ください。
チェックすべき例外などの例外は障害が起こったとしても、それに対応することでアプリケーションを正常に戻すために使用されます。これはアプリケーションの頑健性を向上させるということができます。
それに対してアサーションはアプリケーションが正しく動いているかをチェックするために使用されます。すなわち、アプリケーションの妥当性を向上させるために使用するのです。
オブジェクト指向の世界では、Design by Contract (DbC) という概念がありますが [参考文献 5]、アサーションは DbC を実現するための強力な武器となるのです。
例外のクラス構成
Java では例外もクラスとして表わされています。図 3 に例外のクラス図を示しました。すべての例外の基底となるクラスは Throwable クラスです。チェックすべき例外は Exception クラスの派生クラス、実行時例外は RuntimeException クラスの派生クラス、エラーは Error クラスの派生クラスとして記述されます。アサーションエラーは Error クラスの派生クラスです。
例外を自作することもできますが、直接 Throwable の派生クラスとすべきではなく、Exception クラスもしくは RuntimeException クラスの派生クラスとすべきです。ただし、詳しくは後述しますが、なるべくライブラリで定義された例外を使用する方が望ましいです。
Error クラスは前述したように Java VM に起因する例外なので、アプリケーション作成者がエラーをスローすることはありませんし、Error クラスを派生させたクラスを自作することもありません。
図 3 例外のクラス構成 |
---|
Throwable クラス
すべての例外の基底となる Throwable クラスを少し詳しく見ていきましょう。
Throwable クラスのコンストラクタは 4 種類あります (表 1)。引数に用いられる文字列の message には例外の原因に関する何らかのメッセージを記述することができます。
Throwable クラスのインスタンス cause は、例外が発生する原因が他の例外にあったときなどに指定するようにします。たとえば、ある低い抽象度の例外が発生したときに、それを高い抽象度の例外として再スローするときなどに使われます。
try { // 何らかの処理 } catch (LowLevelAbstractionException event) { // 低い抽象度の例外を高い抽象度の例外として再スロー throw new HighLevelAbstractionException(event); }
このような用途として汎用的に使われる例外に、RMI で使用される RemoteException や Servlet で使用される ServletException などがあります。
次に Throwable クラスの主なメソッドも一通り眺めてみましょう (表 2)。
まず、getMessage メソッドです。このメソッドを使うことで、コンストラクタで設定したメッセージを得ることができます。
getCause メソッドは同様に例外の原因となる例外を得ることができます。これもコンストラクタで設定しますが、cause を引数にとるコンストラクタを持たない例外も多くあります (たとえば、IOException など)。このような例外クラスでは、 initCause メソッドを使用して原因を設定することができます。
ただし、原因を設定できるのは 1 度だけです。コンストラクタで原因を設定してある場合や、すでに initCause メソッドをコールした後に initCause メソッドをコールすると IllegalStateException が発生します。
後の 4 つのメソッドはすべてスタックトレースに関するものです。スタックトレースに関しては章をあらためて説明しましょう。
表 1 Throwable クラスのコンストラクタ
種類 | 説明 |
---|---|
Throwable() | メッセージ、原因とも設定せずに、オブジェクトを生成する |
Throwable(String message) | メッセージを設定して、オブジェクトを生成する |
Throwable(Throwable cause) | 原因を設定して、オブジェクトを生成する |
Throwable(String message, Throwable cause) | メッセージと原因の両方を設定して、オブジェクトを生成する |
表 2 Throwable クラスの主なメソッド
メソッド | 説明 |
---|---|
String getMessage() | コンストラクタで指定したメッセージを返す |
Throwable getCause() | 原因となる Throwable オブジェクトを返す |
Throwable initCause(Throwable cause) | 原因となる Throwable オブジェクトを設定する |
StackTraceElement[] getStackTrace() | スタックトレースを返す |
void printStackTrace() | 標準エラー出力にスタックトレースを出力する |
void printStackTrace(PrintStream s) | 指定されたストリームにスタックトレースを出力する |
void printStackTrace(PrintWriter w) | 指定されたライタにスタックトレースを出力する |
スタックトレース
リスト 5 は単にファイルをオープンするプログラムですが、実際に存在しないファイルをオープンしてみたらどうなるでしょうか。図 4 にその結果を示しました。なにやら、クラス名やらメソッド名らしきものが表示されています。これがスタックトレースです。
プログラムを実行するには、どこのメソッドからどのメソッドを呼びだしたかという情報を記録する必要があります。Java ではこのようなメソッドの呼びだしの記録をスタックフレームといいます。例外が発生すると、例外の発生箇所からスタックフレームをさかのぼっていきます。これがスタックトレースです。
スタックフレームには次のような情報が保持されています。
- クラス名
- メソッド名
- 行番号
- ファイル名
- メソッドがネイティブメソッドかどうか
図 4 のスタックトレースの表示中に、上記の情報がどこに対応するかを示しておきました。<init> というメソッド名はコンストラクタを示しています。
このスタックトレースは
- java.io.FileInputStream クラスのネイティブメソッドの open の中で FileNotFoundException が発生。
- open をコールしたのは、java.io.FileInputStream クラスのコンストラクタの中で呼び出している。その場所は FileInputStream.java の 103 行目である。
- FileInputStream のコンストラクタは、java.io.FileInputStream クラスのコンストラクタの 66 行目から呼び出されている。
- FileInputStream は FileReader クラスのコンストラクタの中で生成されており、その場所は FileReader.java の 41 行目である。
- FileReader を生成しているは、FileOpenSample クラスの main メソッドで、FileOpenSample.java の 8 行目である。
ということを示しています。これをシーケンス図で表すと図 5 のようになります。
スタックトレースはライブラリに依存しているので、使用している Java のバージョンによって結果は異なります。また、ここで示した例ではネイティブメソッド以外はファイル名と行番号が表示されていますが、行番号の情報がない場合は Unknown Source と表示されます。
スタックトレースからは、例外が発生した場所や、どこから呼び出されているか知ることができるので、デバッグには非常に重要な情報になります。
スタックトレースを出力するには Throwable クラスの printStackTrace メソッドを使用します。printStackTrace は標準エラー出力、ストリーム、ライタの 3 種類の出力先を選ぶことができます。
また、J2SE 1.4 からはこのスタックトレースをプログラムの中で操作することができるようになりました。そのためのメソッドが getStackTrace メソッドです。getStackTrace メソッドの戻り値は StackTraceElement クラスの配列になります。StackTraceElement クラスはスタックトレースの 1 行分の情報、すなわちスタックフレームが保持されています。配列の 0 番目の要素がスタックトレースの先頭になります。
StackTraceElement クラスの主なメソッドを表 3 に示します。StackTraceElement クラスは不変クラスで、保持している情報を取得することだけが可能です。StackTraceElement クラスを使用することで、例外の解析などが行いやすくなります。また、ログなどにスタックトレースをカスタマイズして出力することも容易です。たとえば、J2SE 1.4 の Logging API では java.util.logging.Formatter クラス、Apache の Log4J の場合 org.apache.log4j.Layout クラスを派生させてログ出力のカスタマイズを行いますが、これらのクラスの中で StackTraceElement クラスを使えばスタックトレースのカスタマイズができます。
表 3 StackTraceElement クラスの主なメソッド
メソッド | 説明 |
---|---|
String getClassName() | スタックフレームの実行ポイントを含むクラス名を返す。 |
String getMethodName() | スタックフレームの実行ポイントを含むメソッド名を返す。 |
int getLineNumber() | スタックフレームの実行ポイントを含むソースファイルの行番号を返す。行番号の情報がない場合、負の値。 |
String getFileName() | スタックフレームの実行ポイントを含むソースファイル名を返す。ファイルの情報がない場合、null。 |
boolean isNativeMethod() | スタックフレームの実行ポイントを含むメソッドがネイティブメソッドである場合、true を返す。 |
String toString() | スタックフレームを文字列に変換する。書式は Throwable#printStackTrace と同様。 |
import java.io.FileReader; import java.io.FileNotFoundException; import java.io.IOException; public class FileOpenSample { public static void main(String[] args) { try { FileReader reader = new FileReader(args[0]); reader.close(); } catch (FileNotFoundException ex) { ex.printStackTrace(); } catch (IOException ex) { ex.printStackTrace(); } } } |
リスト 5 ファイルのオープン |
---|
図 4 スタックトレース |
---|
図 5 例外のシーケンス |
---|
例外の鉄則
例外を無視しない
リスト 2 は例外をキャッチしていますが、例外処理をまったく行っていない例です。例外処理を行わないということは、例外を無視しているのと同じです。
例外は何らかの障害がプログラムに起きているのですから、それを無視するのは非常に危険です。例外を無視すると一見正常に動作しているように見えますが、後の部分で無視した例外が原因でアプリケーションが落ちてしまうかもしれません。
例外が発生した場合、最低限やらなくてはならないことは、例外が発生したことをユーザなり開発者なりに伝えることです。ダイアログを出す、コンソールに表示する、ログに記録するなどいろいろと方法はあります。最低限行ってほしいのがスタックトレースを出力することです。
ログに書き出す場合、J2SE 1.4 の Logging API であれば、java.util.logging.Logger クラスの log/logp/logrb メソッドの Throwable オブジェクトを引数にとるもの、もしくは throwing メソッドを使用すればスタックトレースをログに出力できます。
同様に Apache の Log4J でも、org.apache.log4j.performance.Logger クラスのThrowable オブジェクトを引数にとる log、fatla、debug メソッドなどを使用すればスタックトレースを出力できます。
独自のロガーを使用している場合、ログに書き出すのが文字列だけということがよくあります。このような場合、スタックトレースを文字列に変換しなくてはなりません。
J2SE 1.4 以前の場合、スタックトレースを文字列に変換するには StringWriter クラスを使用します (リスト 6)。リスト 6 では StringWriter オブジェクトをクローズしていませんが、StringWriter クラスは特別なライタでクローズする必要がないためです。
J2SE 1.4 以降では、前述した StackTraceElement クラスを使用することができます。単に文字列に変換すだけであれば StackTraceElement#toString メソッドを使用します。
ただし、これは最低限の例外処理です。スタックトレースを出力したからといって、障害が復旧するわけではありません。スタックトレースを出力するのは、単にどこでどのような例外が発生したことを知らせているのにすぎないのです。
try { // 何らかの処理 } catch (SomeException ex) { StringWriter writer = new StringWriter(); // ライタにスタックトレースを出力 ex.printStackTrace(writer); // ライタから文字列を取得 String stackTrace = writer.toString(); System.out.println(stackTrace); } |
リスト 6 スタックトレースを文字列に変換 (J2SE 1.4 以前の場合) |
---|
try { // 何らかの処理 } catch (SomeException ex) { StackTraceElement[] stackFrames = ex.getStackTrace(); for (int i = 0 ; i < stackFrames.length ; i++) { // スタックフレームを文字列に変換 String stackFrame = stackFrames[i].toString(); System.out.println(stackFrame); } } |
リスト 7 スタックトレースを文字列に変換 (J2SE 1.4の場合) |
---|
例外処理の責任を明確にする
例外を処理するには次の 3 種類の方法が考えられます。
- 例外をキャッチして、その場で例外処理を行い、呼び出されたメソッドには例外を伝えない。
- 例外をキャッチして、新たな例外を生成して呼び出したメソッドに再スロー。
- 例外をキャッチせずに、そのまま呼び出したメソッドに伝える。もしくはキャッチした例外を、そのまま呼び出したメソッドに再スローする。
前述した 3 種類の方法のうち、1 はその場で例外処理を行い、2 と 3 は例外が発生したポイントを含むメソッドを呼び出したメソッドに処理を委譲するものです。
これらの方法のどれを選ぶかということは、誰が例外処理の責任を持つかを考えることと同じです。例外処理を行っている途中で、手におえないからといって投げだされてしまったらどうしようもありません。責任とは最後まで処理を完遂させることなのです。
原則は、例外が発生したなるべくそばで例外を処理することです。しかし、それではカバーできない例外処理もあります。
たとえば、火事が起こったときに、被害を最小限にするにはその場で初期消化を行うことが重要だと思います。しかし、手におえないと判断したら、なるべく速やかに消防署に連絡をしなくてはいけません。これは消火という処理の責任を消防署に委譲したことを意味します。
例外でも同じです。例外が発生した個所で例外処理を行うことが困難であれば、それを呼び出したメソッドに例外処理を委譲します。
その場で例外処理が行えない例には、ユーザが入力した値によって例外が発生する場合などがあります。たとえば、GUI のテキストフィールドでファイル名を入力する場合を考えてみましょう。
リスト 8 に示したように、ユーザが入力した値を JTextField#getText メソッドで取り出し、それをファイルをオープンするメソッドの引数にし、そこで FileReader クラスを使用して読み込みむとします。
しかし、入力されたファイル名が実際に存在しなかった場合、FileReader オブジェクトを生成するときに FileNotFoundException が発生してしまいます。
例外処理を行うとしたら (1) か (2) のどちらかになります。まず、(2) でどのような例外処理が行えるか考えてみましょう。スタックトレースを出力しても意味ははありません。
この場合は (1) でユーザに対してファイル名が異なっていたことを (ダイアログなどで) 示し、再入力を促すようにするのがいいのではないでしょうか。
それには、(2) ではそのまま例外をスローして、(1) に例外処理を委譲します (リスト 9)。
GoF のデザインパターンには Chain of Responsibility というパターンが紹介されています[参考文献 8]。try ... catch を使用した例外処理はこのパターンとは同一ではありません。しかし、例外処理と通常の処理という違いはありますが、処理の責任を明確にし、責任がとれるオブジェクトまで処理を委譲するということは同じなのです。
// 決定ボタンがクリックされた場合のイベント処理 public void actionPerformed(ActionEvent event) { // テキストフィールドからファイル名を取得 String fileName = textField.getText(); reader = fileOpen(filename); <---- (1) } private Reader fileOpen(String fileName) { BufferedReader reader = new BufferedReader(new FileReader(fileName); <---- (2) return reader; } |
リスト 8 どこで例外処理を行うか |
---|
// 決定ボタンがクリックされた場合のイベント処理 public void actionPerformed(ActionEvent event) { // テキストフィールドからファイル名を取得 String fileName = textField.getText(); try { reader = fileOpen(filename); } catch (FileNotFoundException ex) { // ダイアログでユーザに例外を通知 JOptionPane.showMessageDialog(frame, "ファイル [" + fileName + "] が存在しません\n再入力をしてください", "入力エラー", JOptionPane.ERROR_MESSAGE); } } private Reader fileOpen(String fileName) throws FileNotFoundException { BufferedReader reader = new BufferedReader(new FileReader(fileName); return reader; } |
リスト 9 例外を委譲した例 |
---|
例外がおきる前の状態に戻す
旅行のオンラインチケットを発行することを考えて見ましょう。飛行機のチケットとホテルの予約を行うシステムであれば、処理はこんな感じで行われるのではないでしょうか。
- ユーザが希望の場所、日時などを入力
- 入力した日時から飛行機の座席を仮予約
- ホテルの予約を行う
- ホテルの予約が完了できたら、飛行機のチケットも購入
- ホテルの予約ができなかったら、飛行機のチケットをキャンセルし、ユーザに再入力を促す
1, 2 と処理が進んで、ホテルの予約を行っている最中に何らかの障害が発生してしまったらどうしましょう。単に復旧しただけでは、もしかしたら飛行機のチケットが仮予約されたままになってしまっているかもしれません。本来は仮予約もキャンセルして、一番前の状態まで戻す必要があります。
これはトランザクション処理の基本ともいえることだと思います。
例外処理でも同じです。障害が発生しても、例外処理を行うことでプログラムを正常な状態に戻すことが重要です。特にチェックすべき例外ではこれが望まれます。とはいっても、言うは易し行なうは難しです。Effective Java の中には現状回復のために 3 つの方法が紹介されています [参考文献 2]。
- 普遍オブジェクトを使用する
- 操作が始まる前の時点までオブジェクトの状態を戻す回復コードを記述する
- オブジェクトを一時的にコピーして、コピーに対して操作を行い、完了したらオリジナルをコピーに置き換える
普遍オブジェクト
普遍オブジェクトは String オブジェクトのように状態の変化しないオブジェクトです。状態が変化しないので、回復させる必要はありません。
回復コード
回復コードは、たとえば前述のオンラインチケットのシステムでは飛行機の座席予約をキャンセルするという処理になります。同じように、データベースではロールバックを行うことが回復コードになります。
コピー
コピーを行うことも有効な手段です。Effective Java には Collections#sort の例が示されていますが、コレクションに関する操作でよく使用されています。
ここでは、例外をキャッチした場合に関して説明しましたが、例外をスローする時にも同じことが言えます。例外をスローする前に、なるべく問題が起こらないようにしてからスローするようにします。
たとえば、リスト 10 は配列に要素を代入していくルーチンですが、引数の要素をそのまま使うのではなく、そこからある情報を取り出して配列に代入しています。ただし、情報を取り出すときに例外が発生することがあります。
もし、getInfo メソッドで SomeException が発生したら、配列に代入はされていないにもかかわらず index だけは増加してしまいます。
この場合、修正するのは簡単で、例外が発生する前にオブジェクトの状態を変更しないようにすればいいだけです。具体的には index のインクリメントを、例外が発生する可能性のある getInfo の後に行うようにします (リスト 11)。
ここで示した例は非常に単純な例ですが、実際にはもっと複雑で難しい場合も多いと思います。しかし、正常な状態に戻らなかった場合、後々それが原因でまた障害がおきてしまうかもしれません。アプリケーションの頑健性を高めるには避けて通れない道なのです。
public void addInfo(Something obj) throws SomeException { index++; if (index < size) { SomethingInformation info = getInfo(obj); array[index] = info; } } private SomethingInformation getInfo(Something obj) throws SomeException { // 何らかの処理 // SomeException をスローする可能性あり return info; } |
リスト 10 配列に代入する例 |
---|
public void addInfo(Something obj) throws SomeException { if (index - 1 < size) { SomethingInformation info = getInfo(obj); // 要素が確保されてから、index を増加 index++; array[index] = info; } } private SomethingInformation getInfo(Something obj) throws SomeException { // 何らかの処理 // SomeException をスローする可能性あり return info; } |
リスト 11 配列に代入する例 (修正版) |
---|
適切な例外を選ぶこと
例外をスローするときに、どのように例外を選びますか。選んだ例外の名前はその障害を適切にあらわしているものでなくてはなりません。例外の名前は、その障害をあらわすためにつけられたタイトルなのです。
例外が発生したときに、もっともはじめに分かる情報は例外の名前です。それが、障害の内容にそぐわないと、例外処理やデバッグが的を射たものにならなくなってしまいます。
例外の種類を選ぶときに、とるべき手段はいくつかあります。
- コアライブラリで定義されている標準的な例外から選択する
- コアライブラリの標準的な例外を派生させた例外クラスを作成して、使用する
- まったく新たに例外クラスを作成して、使用する
アプリケーションの保守を行う人と、アプリケーション作成者が異なることもあるということを考慮しなくてはなりません。自作の例外クラスの場合、その例外がどのようなうな障害をあらわしているのか理解する必要があります。ちょっとした差かもしれませんが、このような差がアプリケーションの保守性に大きくかかわってくるのです。
したがって、なるべく標準的な例外を選択することが望ましいのです。標準的な例外を使用すれば、その例外が表している障害を理解するのは容易です。また、このことによりソースの可読性も向上するのです。
汎用的に使用できる例外は java.lang パッケージに定義されています。たとえば、NullPointerException や IllegalAccessException、InterruptedException、ArrayIndexOutOfBoundsException などがあります。
入出力に関する例外であれば java.io パッケージで定義されています。java.io パッケージで定義されている例外はほとんど IOException の派生クラスになっています。また、通信に関連する例外は java.net パッケージで定義されていますが、これらの例外もほとんど IOException の派生クラスです。
アプリケーションを作成する場合、まずこれらの標準的な例外の中から、障害を適切に表している例外を選択します。もし、障害の内容を表している例外がない場合に限って、例外を自作することを考えるべきです。その場合でも、たとえば入出力や通信に関連するものであれば IOException の派生クラスにするなど、標準例外の派生クラスをまず考えます。
もしそれでも障害を表すのにしっくりした例外がない場合、最終手段として新たな例外クラスの自作を考えるべきです。
キャッチした例外を、再スローする場合はどうでしょうか。あるメソッドの中でキャッチした例外が、メソッドの障害内容とあわない場合があります。このような場合、例外を解釈しなおす必要があります。抽象度が低い例外を、抽象度の高い例外に移し変えるのです。
リスト 12 は ArrayList クラスの親クラスである AbstractList クラスの一部です。AbstractList クラスでは List#iterator メソッドで返すための Iterator インタフェースの実装クラスを内部クラスとして定義していますが、リスト 12 はその next メソッドです。
checkForComodification メソッドはここでは関係がないので、気にしないでおきましょう。メインの処理は cursor をインデックスに使用して、List オブジェクトの要素を順々に返す部分です。
最後の要素まで返してしまった後に next メソッドをコールすると、cursor が要素数より大きくなってしまうので、IndexOutOfBoundsException が発生します。しかし、それをそのまま再スローするには問題があります。next メソッドは Iterator オブジェクトの次の要素を返すメソッドであり、そこにはインデックスは登場しません。それなのに IndexOutOfBoundsException がスローされるのは処理内容にそぐわなくなってしまいます。
そこで、もう要素がないことを示す NoSuchElementException をスローしています。
このライブラリは J2SE 1.4 以前に書かれたものなので、原因を引き継ぐことを行っていません。これだと例外のスタックトレースが切れてしまうので、できれば例外生成時に原因をセットするようにします。前述したように例外の原因はコンストラクタで指定するか initCause メソッドを使用します。こうすることで高い抽象度の例外が、低い抽象度の例外によって発生したことが分かるのです。
public Object next() { checkForComodification(); try { Object next = get(cursor); lastRet = cursor++; return next; } catch(IndexOutOfBoundsException e) { checkForComodification(); throw new NoSuchElementException(); } } |
リスト 12 例外の変更 |
---|
例外の種類に応じた例外処理を心がける
前節で例外の種類を選ぶことが重要であることを示しました。ところが、せっかく適切に選択された例外でも、例外処理を行う時に無視されてしまったのでは目も当てられません。複数の例外が発生する可能性がある場合でも、次のように Exception だけで一くくりにされてしまったらどうでしょうか。
try { ... } catch (Exception ex) { ... }
FileReader クラスのコンストラクタで FileNotFoundException が発生するのはファイルが存在しないから、Iteration インタフェースの next で NoSuchElementException が発生するのは要素の最後まで走査したから、という理由があって例外が発生しています。理由が違うのですから、理由に応じた例外処理があるはずです。ですから、FileNotFoundException と NoSuchElementException では例外処理が異なるのが当然です。
それぞれの理由があって例外が発生しているのですから、その理由に応じた例外処理を心がけましょう。
finally に気をつける - 例外を隠さない
finaly ブロックはは正常な場合は try ブロックの後、例外が発生した時は catch ブロックの最後に実行されます。
たとえば、ストリームは使用後にはクローズしますが、たとえ例外があってもクローズしなくてはいけません。こんなときに finally ブロックは便利です。finnaly ブロックにクローズ処理を記述すれば、例外に関係なくクローズ処理を行うことができます。
使い方によっては非常に有効な finally ですが、使い方を間違えると危険です。try ブロックと finally ブロックの両方に return がある場合を前述しましたが、もっと気をつけなくてはならないのが finally ブロックの中の例外です。
リスト 13 はかなり意図的に書いてあるので、こんなプログラムを書く人はいないと思いますが、実行するとどうなるでしょう(注)。結果は図 6 です。return の例があるので、予想がついたかもしれません。
図 6 のスタックトレースを見ると、8 行目で例外が発生しています。ソースを見てみると 8 行目は finally ブロックの中で CException をスローしている部分です。 BException をスローしているはずなのですが、それはどこにいってしまったのでしょう。
finally ブロックは catch ブロックで BException をスローした後に実行されます。そこで、CException をスローしてしまうと、BException をスローした痕跡もなくなってしまうのです。もちろん、スタックトレースにも表れません。
リスト 13 のようなプログラムは書かなくとも、リスト 14 のようなコードは書いてしまうかもしれません。
リスト 14 は FileReader の生成過程で FileNotFoundException をスローする可能性があります。FileNotFoundException が発生した場合、reader は null のまま 6 行目の catch ブロックに処理は移行します。catch ブロックで FileNotFoundException をスローした後に 8 行目以降の finally ブロックが実行されますが、reader が null のままなので 10 行目の reader.close() で NullPointerException がスローされてしまいます。
すると、7 行目の catch ブロックでスローされた FileNotFoundException が隠されてしまいます。そのため、try ブロックがあたかも正常に終了したかのように見えてしまい、NullPointerException の原因が非常に分かりにくくなってしまいます。
このように finally ブロックでは try ブロックや catch ブロックでの処理を打ち消してしまう可能性を持っているので、使用には注意が必要です。
(注) J2SE, SDK 1.4.2 を使用してコンパイルをすると、finally ブロックで警告されます。しかし、J2SE, SDK 1.4.2 以前の javac では警告はされないようです。
1: public class FinallyExceptionTest { 2: public static void foo() throws BException, CException { 3: try { 4: throw new AException(); 5: } catch(AException e) { 6: throw new BException(); 7: } finally { 8: throw new CException(); 9: } 10: } 11: 12: public static void main(String[] args) { 13: try { 14: foo(); 15: } catch (Exception ex) { 16: ex.printStackTrace(); 17: } 18: } 19: } 20: 21: class AException extends Exception {} 22: class BException extends Exception {} 21: class CException extends Exception {} |
リスト 13 finally ブロックでの例外 |
---|
1: public openFile(String filename) throws FileNotFoundException { 2: FileReader reader = null; 3: try { 4: reader = new FileReader(filename); 5: // 何らかの処理 6: } catch (FileNotFoundException ex) { 7: throw ex; 8: } finally { 9: try { 10: reader.close(); 11: } catch (IOException ex) { 12: System.out.println("ファイルのクローズを失敗しました (" + filename + ")"); 13: ex.printStackTrace(); 14: } 15: } 16: } |
リスト 14 finally ブロックで例外が発生する可能性のあるコード |
---|
図 6 FinallyExceptionTest の実行結果 |
---|
正常なロジックとして例外を使用しない
Java で goto を使用できることを知らない人は結構多いのではないでしょうか。そんな goto を知らなかった筆者の知り合いが goto を使わないで、多重のループから一気に抜け出せる方法を発見しました (注)。
リスト 15 ではストリームの終端に達したら Exception をスローしてループを抜け出られます。
しかし、このようなコードは結局 goto の焼き直しに過ぎず、処理の流れが分かりにくくなってしまいます。このような誤った例外の使い方を発見するより、多重ループをリファクタリングして見やすくするべきです。
例外は何らかの障害がプログラムにあったことを知らせるための道具です。それを通常のフローに使用することは避けなければなりません。
(注) 誰もが一度は発見するらしいです。
try { for (int i = 0; i < sizeI; i++) { for (int j = 0; j < sizeJ ; j++) { int b = stream.read(); if (b == -1) { throw new Excption(); } doProcess(b, i, j); } } } catch (Exceptionex) {} |
リスト 15 例外を正常なロジックに使った例 |
---|
コンストラクタでの例外、static ブロックでの例外
FileReader のコンストラクタの例外について見てきたように、コンストラクタも通常のメソッドと同様に例外をスローすることができます。ただし、コンストラクタで例外をスローするクラスをプロパティに使うときには注意が必要です。
コンストラクタで例外をスローするクラスをプロパティにする場合次のような初期化はできません。
public class A { private FileReader reader = new FileReader("a.txt");
そこで、コンストラクタで初期化するようにします。
public class A { private FileReader reader; public A() { try { reader = new FileReader("a.txt"); } catch (FileNotFoundException ex) { ... } }
しかし、static なプロパティの場合が問題です。static なプロパティはオブジェクトで同じプロパティを共通して持つので、コンストラクタで初期化すると無駄が生じてしまいます。
そこで static ブロックを使用します。static ブロックはクラスがロードされたときに 1 度だけ実行されるブロックで、例外を扱うことも可能です。たとえば、デザインパターンの Singleton パターン [参考文献 8] を使用するときには、Singleton の初期化をリスト 16 のように書くことができます。
ただし、気をつけなくてはならないのが、static ブロックの中から例外をスローすることは禁じられているということです。例外をスローするにもキャッチする相手がいないのですから、これは当然ですね。
public class SingletonX { private static SingletonX instance; static { instance = null; try { // SingletonX の初期化 instance = new SingletonX(); } catch (XXXException ex) { // 何らかの例外処理 } } private SingletonX() throws XXXException { ... } public SingletonX getInstance() { return instance; } ... } |
リスト 16 static ブロックを使った static 変数の初期化 |
---|
キャッチされなかった例外をキャッチする
マルチスレッドのアプリケーションでいつのまにかスレッドが死んでいることを経験されたことがある方は多いのではないでしょうか。多くの場合、その原因はスレッドで発生したエラーか実行時例外によるものです。
エラーと実行時例外はキャッチする義務はないので、スレッドでこれらが発生すると誰もキャッチしてくれない可能性があります。例外がキャッチされないとそのスレッドは停止してしまいます。
リスト 17 はキャッチされない例外でスレッドが停止してしまう例です。これを実行すると、コンソールにはスタックトレースが表示されるので、スレッドが停止したことが分かります。しかし、たとえばサーバ系のアプリケーションではコンソールを持たないものも多く、スレッドが停止したことに気づかないことも多々あります。
ましてや、負荷バランスのためにスレッドプールを使用している場合などでは、複数のスレッドが独立して同じような処理を行っている場合も多く、スレッドが停止したとしても、パフォーマンスが若干低くなるだけで見過ごされてしまうこともあります。
このようにキャッチできなかった例外をキャッチするには ThreadGroup クラスの uncaughtException メソッドを使用します。
スレッドは必ずスレッドグループに属しています。Thread クラスのコンストラクタで属するスレッドグループを指定できます。何も指定しない場合は、デフォルトのスレッドグループに属すことになります。
ThreadGroup クラスの uncaughtException メソッドは、親スレッドグループがあれば親の uncaughtException メソッドをコール、親がなければ例外が ThreadDeath 以外の場合は標準エラー出力にスタックトレースを出力します。リスト 17 で、コンソールにスタックトレースが出力されたのはこのためです。
キャッチされなかった例外に対して例外処理を行うには、ThreadGroup クラスを派生させたクラスを作成し、uncaughtException メソッドをオーバライドします。リスト 18 では uncaughtException メソッドをオーバライドして、ログに例外を出力するようにしてみました。
スレッドが停止したことを知ることができれば、新たにスレッドを立ち上げなおすなど何らかの対処を行うことが可能になります。マルチスレッドを扱うのであれば、ぜひスレッドグループと共に使うことを検討してみましょう。
注) J2SE 5.0 では UncaughExceptionHandler インタフェースが導入されました。このため、ThreadGroup を派生させる必要がなくなりました。
public class UncaughtExceptionTest { public UncaughtExceptionTest() { Thread thread = new Thread() { public void run() { throw new RuntimeException(); } }; thread.start(); } public static void main(String[] args) { new UncaughtExceptionTest(); } } |
リスト 17 キャッチされない例外でスレッドが停止する例 |
---|
import java.util.logging.Level; import java.util.logging.Logger; public class UncaughtExceptionTest { private Logger logger = Logger.getLogger(this.getClass().getName()); public UncaughtExceptionTest() { ThreadGroup group = new ThreadGroup("Test Group") { public void uncaughtException(Thread thread, Throwable throwable) { logger.log(Level.SEVERE, "Thread Death", throwable); } }; Thread thread = new Thread(group, "Thread1") { public void run() { throw new RuntimeException(); } }; thread.start(); } public static void main(String[] args) { new UncaughtExceptionTest(); } } |
リスト 18 uncaughtException メソッドを使用したログ出力 |
---|
おわりに
例外を扱う上での注意点などをいろいろと説明してきましたが、例外を上手に扱えばプログラムの堅牢性や保守性を向上させることが可能です。
コーディングで例外処理を行うのはもちろんなのですが、ぜひプログラムの分析、設計を行うときから例外を考慮すべきだと筆者は考えています。たとえば、UML のユースケース記述では例外を書くことができます。この時点で例外を考慮することによって、アプリケーションで例外処理に対する統一したポリシーを決めることができ、見通しのいい設計が可能になるはずです。ぜひ、例外を上手に使いこなして、頑丈なアプリケーションを構築してください。
今回はスペースの関係もあり、アサーションや DbC についてほとんど触れることができませんでしたが、参考文献の 5, 6, 7 などに解説されていますので、興味のある方は参考になさってください。
参考文献
- James Gosling, Bill Joy, Guy Steele, Gilad Brache, 「Java 言語仕様」 ピアソン・エデュケーション ISBN 4-89471-306-3
まずは言語仕様のチェック。11 章に例外について記述されています。英語版は下記 URL でダウンロード可能。
http://java.sun.com/docs/books/jls/ - Joshua Block 「Effective Java プログラミング言語ガイド」 ピアソン・エデュケーション ISBN 4-89471-436-1
Java プログラマなら一度は目をとおすべき本。いろいろと示唆に富んでいます。例外は 8 章。 - Peter Haggar 「Java の鉄則」 ピアソン・エデュケーション ISBN 4-89471-258-X
少し古い本ですが、内容は今でも通ずるものが多くあります。例外は 3 章にあります。 - IBM DeveloperWorks Java Technology Zone http://www-6.ibm.com/jp/developerworks/java/
Java に関する記事が多数あります。例外に関するものだと「Java コードの診断」のシリーズでバグパターンについて取り上げられています。
もうすこし記事を探しやすくしてくれるともっといいのですが。 - Bertrand Meyer 「オブジェクト指向入門」 アスキ ISBN 4-7561-0050-3
DbC のオリジナル。Java ではなくて Effel という言語について書かれた本ですが、オブジェクト指向に関する部分だけでも読みごたえあり。 - 相馬 純平 「JDK 1.4ではじめるアサーション」, JAVA PRESS, vol.22, 2002
- William Paul Rogers, "J2SE 1.4 premieres Java's assertion capabilities" JavaWorld, Nov. 2001, Dec. 2001
http://www.javaworld.com/javaworld/jw-11-2001/jw-1109-assert.html
http://www.javaworld.com/javaworld/jw-12-2001/jw-1214-assert.html
翻訳は日本語版 JavaWorld 2002年6月
後半 (Part 2) のロバストネスとコレクトネスに関する議論や、DbC とアサーションに関する議論は一見の価値あり。 - Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides 「オブジェクト指向における再利用のためのデザインパターン」 ソフトバンクパブリッシング ISBN 4-7973-1112-6
パターンといったら、やっぱりこの本。