.NETとJavaの例外処理の違い

さて、ここまで .NET における例外処理の基本的な考え方として、次のようなポイントを解説してきました。

  • .NET では、「業務フローチャートからはみ出た場合」 を表現する方法として、例外を使う。
  • 業務フローチャート上想定されている業務エラーを、例外として表現してはいけない。
  • 業務エラーは、メソッドの戻り値として enum 型や構造体クラスなどを使うことで表現する。

これらの考え方は非常に重要なのですが、実はこのような例外の取り扱い方は、Java における例外の取扱い方とは大きく異なります。これは、Java には検査例外と実行時例外と呼ばれる 2 種類の例外が存在しており、言語仕様として業務エラーを例外(検査例外)として取り扱える仕組みを持っているからです。Java の開発者の方でも意外に知らない方が多く、Java 系のサンプルコードでも例外を適切に取り扱えていないコードを非常によく見かけますし、また .NET 開発者であっても、Java の持つ例外の仕組み(検査例外と実行時例外の特徴と違い)を理解することで、なぜ私が .NET の例外を業務エラーの表現目的で使うべきではないと主張しているのかの理由もご理解いただけるのではないかと思います。そこで今回のエントリでは、.NET と Java の例外処理の違いについて詳細に解説しておきたいと思います。

※ なお、私の手元には Java のソースコードのコンパイル環境がないため、コンパイルが通らないコードになってしまっている & 間違っているかもしれません。概念レベルの説明としては合っていると思うのですが、コードがミスってた場合にはご容赦を....;。

[Java における 2 種類の例外]

まず、Java には検査例外(Checked Exception)実行時例外(Runtime Exception) と呼ばれる 2 種類の例外が存在しています。

image

この 2 種類の例外はそれぞれ親クラスが異なっており、代表的な例外としては以下のようなものが存在します。

  • 検査例外の代表例
    SQLException, RemoteException, ParseException, IOException など
  • 実行時例外の代表例
    IllegalArgumentException, IndexOutOfBoundsException, NullPointerException, ClassCastException, BufferOverflowException, MissingResourceException など

検査例外と実行時例外という名称は、実はあまりよい訳語ではありません。もともとの英語名はそれぞれ Checked Exception, Runtime Exception なのですが、それぞれの例外は以下のような意味を持ちます。

  • 検査例外(Checked Exception)
    言語構文上、例外の発生と捕捉が必ずチェックされる例外。簡単にいえば、投げる側は throws を書かなければならず、呼び出す側は try-catch を書かなければならないこれに違反すると、コンパイルエラーとなる
  • 実行時例外(Runtime Exception)
    その名の通り、実行ランタイム(=JVM)で発生した例外。通常は発生してはいけないような実行ランタイムの異常状態を表現するための例外。通常は発生しないはずなので、投げる側は throws を書く必要はなく、また呼び出す側も try-catch を書かなくてもよい

この二つの例外の大きな違いは、言語構文上のコンパイルチェックがかかるか否か、です。この仕組みがあるために、検査例外は業務エラーを表現する目的で使うことができるようになっています。これについて、以下に詳細に解説していきます。

[Java の検査例外の利用例]

この 2 種類の例外のうち、まずは検査例外(thorws と try-catch を書かなきゃいけない例外)から解説していきましょう。検査例外の使い方を分かりやすく説明するために、Part.1 のエントリで示した、新規顧客塘路業務を例にとって考えてみることにします。

image

おさらいのために、まずこれを C# で記述すると、以下のようになります。

C# 版ビジネスロジッククラス

 public class CustomerBizLogic {
  
   public RegistCustomerResult ResistCustomer(string id, string name, string mail, 
                         DateTime birthday) {
     // ...
   }
  
   public enum RegistCustomerResult {
     Success,
     DuplicateCustomerIDError
   }
 }

C# 版ユーザインタフェース

 CustomerBizLogic biz = new CustomerBizLogic();
 CustomerBizLogic.RegistCustomerResult result = biz.ResistCustomer(tbxId.Text, tbxName.Text, tbxMail.Text, DateTime.Parse(tbxBirthday.Text));
 switch (result) {
     case CustomerBizLogic.RegistCustomerResult.Success:
         lblResult.Text = "正しく顧客登録を行いました。";
         break;
     case CustomerBizLogic.RegistCustomerResult.DuplicateCustomerIDError:
         lblResult.Text = "指定された ID はすでに利用されています。";
         break;
 }

C# の場合には、業務エラーを戻り値の一部として表現していることに注目してください。ではこれらを Java で書く場合にはどうなるのかというと、以下のようなコードになります。(※ 話を分かりやすくするため、UI 部は簡素化して書きます。throws 定義と、try-catch の部分をよーく見てください。)

Java 版ビジネスロジック

 public class CustomerBizLogic {
   public void ResistCustomer(string id, string name, string mail, Date birthday) throws DupilicateCustomerIDException 
   {
     // ...
   }
 }
  
 public class DuplicateCustomerIDException extends Exception
 {
 }

Java 版ユーザインタフェース

 CustomerBizLogic biz = new CustomerBizLogic();
 try
 {
     biz.ResistCustomer(tbxId.Text, tbxName.Text, tbxMail.Text, DateTime.Parse(tbxBirthday.Text));
 }
 catch (DuplicateCustomerIDException e)
 {
     lblResult.Text = "指定された ID はすでに利用されています。";
     return;
 }
 lblResult.Text = "正しく顧客登録を行いました。";

Java の場合には、業務エラーを検査例外(Exception クラスの派生クラス)として表現していることに着目してください。Java の場合には、戻り値ではなく、検査例外を使うことによって業務エラーを表現することができます

そしてここで重要なのは、Java の場合には、検査例外に関して以下のようなコードを書くとコンパイルエラーになる、という点です。

  • CustomerBizLogic クラス側の RegistCustomer メソッドに、throws 定義(throws DuplicateCustomerIDException)を書かないと、コンパイルエラーになる。
  • UI 側の RegistCustomer() メソッド呼び出し時に、try-catch による DuplicateCustomerIDException 例外の捕捉コードを書かないと、コンパイルエラーになる。

[Java における検査例外の意味]

ここで、上記のような検査例外の特徴が、どういう意味を持つのかを考えてみましょう。

  • メソッドシグネチャとして throws 句を書かないとダメ。
  • 呼び出し側で try-catch を書かないとダメ。

この 2 つの特徴は、要するにこの検査例外が、「必ず処理ルートとして考慮しなくちゃいけないケースである」ということを意味しており、この特徴はそのまま業務エラーに当てはまります。つまり、業務エラーとはそもそもどのようなものだったのかというと、

  • メソッド側(上の例でいうと BC 側)では、インタフェース仕様(メソッド仕様)の一部として定義しなければならないもの。
  • 呼び出し側(上の例でいうと UI 側)では、必ず後処理してメッセージなどを表示しなければならないもの。

でした。CLR 系言語(C# や VB)では、言語仕様としてこのような業務エラーを体系的に取り扱える仕組みがないため、やむなく enum 値や構造体クラスなどを使って業務エラーを表現していたのですが、Java の場合には、検査例外を使えば言語仕様として業務エラーを体系的に取り扱える、ということになります。

[Java の検査例外のメリット]

さて、Java の検査例外の仕組みは個人的には非常に優れていると思っていて、C# や VB と比べた場合に Java が言語的に優れている点の一つだと思います。(ただし、それをきちんと使いこなせる人が多ければ、という前提条件がつくのですが....orz) その理由は、構造化された業務エラー情報を取り扱ったり返したりする場合には、検査例外を使った方がきれいなコードが書けるから、です。

各業務エラーが発生したときに、「業務エラーが発生したことだけでなく、それに関連する付帯情報も返さなければならない」場合を考えてみることにします。例えば、上記の新規顧客登録業務において、顧客 ID 重複が見つかった場合には、BC 側から、重ならないおすすめ顧客 ID を返すようにするケースを考えてみます。この場合、C# と Java のコードを比較すると、次のようになります。

C# の場合(ビジネスロジッククラス部)

 public class CustomerBizLogic {
  
   public RegistCustomerResultInfo ResistCustomer(string id, string name, string mail, DateTime birthday) {
     // ...
   }
  
   public class ResistCustomerResultInfo {
     public RegistCustomerResult Result;   // 正常/業務エラーのいずれであるかを表現
     public string RecommendedCustomerID;  // ID 重複業務エラーだった場合に、付帯情報を返すためのフィールド
   }
  
   public enum RegistCustomerResult {
     Success,
     DuplicateCustomerIDError
   }
  
 }

Java の場合(ビジネスロジッククラス部)

 public class CustomerBizLogic {
   public void ResistCustomer(string id, string name, string mail, Date birthday) throws DupilicateCustomerIDException 
   {
     // ...
   }
 }
  
 public class DuplicateCustomerIDException extends Exception
 {
   public string RecommendedCustomerID;
 }

このコードを見ると分かるように、検査例外を利用すると、正常ルートのときの結果情報と、業務エラールートのときの結果情報とを、きれいに分離して定義することができるのです。C# のコードでは、ResistCustomerResultInfo クラスを見ればわかるように、正常ルートのときの結果情報と業務エラールートのときの結果情報とが一つのクラスにまとまってしまっており、美しいコードとは言えません。上記の例は業務エラーがひとつだけであるためまだマシですが、実際の業務アプリでは、一つのメソッドで起こる業務エラーのパターンが複数通りになることもあり、こうなってくると Java の検査例外の良さが出てくるわけです。

……と、ここまで書くと「つまり Java の検査例外の仕組みは素晴らしいんだ!」ということになるように思えますが、話は残念ながらここでは終わりません;。検査例外は実はメリットばかりではなく、大きな危険性をはらんでいます。それが、実行時例外と検査例外の正しい使い分けの問題です。

[Java の実行時例外の利用例]

そもそも実行時例外(実行ランタイム例外)がどのようなものであるのかを説明するために、double 型変数 2 つを受け取って、割り算をして結果を返すメソッド Divide() を作ってみます。

 public class MathUtil
 {
   public static double Divide(double a, double b)
   {
     double result = a / b;
     return result;
   }
 }

さて、ここでこのメソッドは適切な入力値チェックを行った上で呼び出されるものだと仮定します。すると、b の値として 0 が入ってくることはないはずです。つまり、b の値がゼロだった場合には、アプリケーションを速やかに停止させるべき(=自爆すべき) ということになりますので、アプリケーションコードは次のように書けます。

C# の場合のコード

 public class MathUtil
 {
   public static double Divide(double a, double b)
   {
     if (b == 0) throw new ArgumentException("b");
     double result = a / b;
     return result;
   }
 }

これに対応する Java のコードは、以下の通りとなります。

Java の場合のコード

 public class MathUtil
 {
   public static double Divide(double a, double b)
   {
     if (b == 0) throw new IllegalArgumentException("b");
     double result = a / b;
     return result;
   }
 }

ここで注目していただきたいのは、Java のコードに throws 定義がないという点です。ここの throw 文で利用している IllegalArgumentException 例外は、RuntimeException の派生クラスであり、実行時例外に分類される例外です。Java では、RuntimeException を派生した例外クラスの使い方に関して、以下のようなルールがあります。

  • メソッドを定義する際に、throws 句を書かなくてもよい。 (※ 書いてもエラーにはなりませんが)
  • そのメソッドを使うときに、try-catch を書かなくてもよい。

これは CLR の例外と全く同じ特徴を持った例外です。try-catch を書かなくてもよい、ということは、その例外はどんどん上位に自動通知されていき、最終的には Java のランタイム(JVM)が拾う、ということを意味します。つまり、Java の実行時例外は、CLR の例外に対応するものであり、業務フローチャートからはみ出た異常事態(アプリケーションエラー/システムエラー)を表現するためのものである、ということになるわけです。

[C# と Java における例外の対応関係]

以上の話をわかりやすく示すために、.NET(C# や VB)の場合と、Java の場合の、業務エラーとアプリケーション/システムエラーの取り扱い方の違いをまとめると、以下のようになります。

image

image

C# の場合

image

Java の場合

image

このように、Java と C# では例外の取り扱い方に違いがあります。特に Java では、以下のポイントを正しく理解しておかないと、適切なコードが書けません。

  • 例外には、検査例外と実行時例外という 2 種類の例外がある。
  • 検査例外は、必ず後処理する。(必ず try-catch を書く)
  • 実行時例外は、基本的には後処理しない。(try-catch は基本的に書かない)

そしてさらに厄介なのが、Java と C# のライブラリでは、何を業務エラーとみなすのか、何をアプリケーション/システムエラーとみなすのかに関して、大きな違いがある、という点です。これについて解説しましょう。

[業務エラーとアプリケーション/システムエラー]

さて、最初に取り上げた、新規顧客登録業務の例を振り返ってみましょう。新規顧客登録業務では、UI 部から指示された顧客 ID が重複しており、その結果として INSERT 処理がうまくいかず、SqlException 例外が発生する場合があります。

image

この例外は、BC 部で適切な業務エラーに変換して取り扱わなければなりませんが、実はデータアクセス処理で発生する SqlException 例外は、C# と Java とで大きく異なります。

  • C# の場合

    SqlException 例外は、「例外」。つまり、アプリケーション/システムエラーに相当する。

  • Java の場合

    SQLException 例外は、「検査例外」。つまり、業務エラーに相当する。

この違いは、C# と Java のクラスライブラリの設計思想の違いによるもので、どちらが正しいというものでもありません。しかしこの違いを正しく意識しておかないと、適切なアプリケーションコードが書けないのも事実です。

ここでは話を簡単にするため、Part 2. で解説した以下のコードを取り上げて、正しい SqlException 例外の取扱い方を示すことにしたいと思います。

 class Program
 {
     static void Main(string[] args)
     {
         AuthorDataAccess objDAC = new AuthorDataAccess();
         bool result = objDAC.InsertAuthor("172-32-1176", "White", "Johnson", "408 496-7223", true);
         if (result == true)
         {
             Console.WriteLine("データを挿入しました。");
         }
         else
         {
             Console.WriteLine("データが重複しており、挿入できませんでした。");
         }
     }
 }
  
 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
         SqlCommand sqlcmd = new SqlCommand("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (@au_id, @au_lname, @au_fname, @phone, @contract)", sqlcon);
         sqlcmd.Parameters.AddWithValue("@au_id", au_id);
         sqlcmd.Parameters.AddWithValue("@au_lname", au_lname);
         sqlcmd.Parameters.AddWithValue("@au_fname", au_fname);
         sqlcmd.Parameters.AddWithValue("@phone", phone);
         sqlcmd.Parameters.AddWithValue("@contract", contract);
  
         sqlcon.Open();
         int affectedRows = sqlcmd.ExecuteNonQuery();
         sqlcon.Close();
         return true;
     }
 }

まずは C# の場合から見ていきましょう。

[C# における、業務エラーとアプリケーション/システムエラーの変換の例]

上記のコードでは、以下の対処コードが書かれていません。

  • PK 衝突(番号が 2627 の SqlException 例外)が発生した場合に対する対処コード
  • コネクションリークに対する対処コード
  • 更新結果行数が 1 行ではなかった場合の自爆コード

これらの 3 つを対処すると、上記のコードは以下のようになります。(詳細は前回の Part.2 のエントリを見てください。)

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
         SqlCommand sqlcmd = new SqlCommand("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (@au_id, @au_lname, @au_fname, @phone, @contract)", sqlcon);
         sqlcmd.Parameters.AddWithValue("@au_id", au_id);
         sqlcmd.Parameters.AddWithValue("@au_lname", au_lname);
         sqlcmd.Parameters.AddWithValue("@au_fname", au_fname);
         sqlcmd.Parameters.AddWithValue("@phone", phone);
         sqlcmd.Parameters.AddWithValue("@contract", contract);
  
         try
         {
             sqlcon.Open();
             try
             {
                 int affectedRows = sqlcmd.ExecuteNonQuery();
                 if (affectedRows != 1) throw new ApplicationException("INSERT 処理で異常事態が発生しました。" + affectedRows.ToString());
             }
             catch (SqlException sqle)
             {
                 if (sqle.Number == 2627)
                 {
                     return false;
                 }
                 else
                 {
                     throw;
                 }
             }
         }
         finally
         {
             sqlcon.Close();
         }
  
         return true;
     }
 }

では、同じことを Java で行う場合にはどのようになるのかを考えてみます。

[Java における、業務エラーとアプリケーション/システムエラーの変換の例]

まず Java の場合には、そもそも SQLException 例外が検査例外(業務エラー)として定義されています。つまり、最初のコードは SQLException に対する try-catch が書かれていないため、コンパイルすら通りません。このため、各処理について SQLException 検査例外を try-catch するコードが必要になります。(最近 Java のコード書いてないので間違ってるかも....間違ってたら指摘してください;)

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
         } catch (SQLException sqle0)
             // 後処理
         }
         try {
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
         } catch (SQLException sqle1)
             // 後処理
         }
         try {
             cmd.setString(1, au_id);
         } catch (SQLException sqle2)
             // 後処理
         }
         try {
             cmd.setString(2, au_lname);
         } catch (SQLException sqle3)
             // 後処理
         }
         try {
             cmd.setString(3, au_fname);
         } catch (SQLException sqle4)
             // 後処理
         }
         try {
             cmd.setString(4, phone);
         } catch (SQLException sqle5)
             // 後処理
         }
         try {
             cmd.setBoolean(5, contract);
         } catch (SQLException sqle6)
             // 後処理
         }
         try {
             int affectedRows = cmd.executeUpdate();
         } catch (SQLException sqle7)
             // 後処理
         }
         try {
             conn.Close();
         } catch (SQLException sqle8)
             // 後処理
         }
         return true;
     }
 }

上記のサンプルコードでは、まだ SQLException 検査例外を catch した後の処理が書かれていませんが、ここには何をするコードを書けばよいかを考えてみます。SQLException 検査例外は、以下のような理由により発生するはずです。

  • データベースに接続できなかった。(→ これはアプリケーション/システムエラー)
  • PK 制約違反が発生した。(→ これは業務エラー)
  • データベースでディスク障害が発生した。(→ これはアプリケーション/システムエラー)

このうち、アプリケーション/システムエラーに相当するケースでは、自爆処理が必要になります。このため、上記のコードは次のようになります。

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
         } catch (SQLException sqle0)
             throw new RuntimeException("データベース接続がうまくできませんでした。", sqle0);
         }
         try {
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
         } catch (SQLException sqle1)
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle1);
         }
         try {
             cmd.setString(1, au_id);
         } catch (SQLException sqle2)
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle2);
         }
         try {
             cmd.setString(2, au_lname);
         } catch (SQLException sqle3)
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle3);
         }
         try {
             cmd.setString(3, au_fname);
         } catch (SQLException sqle4)
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle4);
         }
         try {
             cmd.setString(4, phone);
         } catch (SQLException sqle5)
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle5);
         }
         try {
             cmd.setBoolean(5, contract);
             throw new RuntimeException("SQL 文の準備がうまくできませんでした。", sqle6);
             // 後処理
         }
         try {
             int affectedRows = cmd.executeUpdate();
         } catch (SQLException sqle7)
             throw new RuntimeException("SQL 文の実行がうまくできませんでした。", sqle7); // ※ ここは PK 制約違反の可能性があるため対処が必要、後述
         }
         try {
             conn.Close();
         } catch (SQLException sqle8)
             throw new RuntimeException("接続のクローズがうまくできませんでした。", sqle8);
         }
         return true;
     }
 }

しかし、このコードはどう見ても記述効率が悪いです。このため、上記のコードは try ブロックをまとめてしまい、以下のように書くことが多いです。

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
             cmd.setString(1, au_id);
             cmd.setString(2, au_lname);
             cmd.setString(3, au_fname);
             cmd.setString(4, phone);
             cmd.setBoolean(5, contract);
             int affectedRows = cmd.executeUpdate();
             conn.Close();
         } catch (SQLException sqle) {
             throw new RuntimeException("データベース処理がうまくできませんでした。", sqle);
         }
         return true;
     }
 }

この try ブロックは、検査例外を実行時例外に変換する(=自爆する)目的で使っていることに注意してください。では、ここに以下の処理を行うコードを追加します。

  • PK 衝突(番号が 2627 の SQLException 例外)が発生した場合に対する対処コード
  • コネクションリークに対する対処コード
  • 更新結果行数が 1 行ではなかった場合の自爆コード

まず、ひとつ目について考えてみます。PK 衝突は、cmd.executeUpdate() 文のみで発生しうるものです。このため、他の行で発生した SQLException を誤って捕捉しないようにしなければなりません。よって、コードは次のようになります。

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
             cmd.setString(1, au_id);
             cmd.setString(2, au_lname);
             cmd.setString(3, au_fname);
             cmd.setString(4, phone);
             cmd.setBoolean(5, contract);
             try
             {
                 int affectedRows = cmd.executeUpdate();
             }
             catch (SQLException sqle0)
             {
                 if (sqle0.getErrorCode() == 2627)
                 {
                     return false;
                 }
                 else
                 {
                     throw new RuntimeException("SQL 処理がうまくいきませんでした。", sqle0);
                 }
             }
             conn.Close();
         }
         catch (SQLException sqle)
         {
             throw new RuntimeException("データベース処理がうまくできませんでした。", sqle);
         }
         return true;
     }
 }

※ ここで、誤って捕捉してしまった際に RuntimeException を throw していますが、これは Java には throw (catch しなかったことにする命令)が存在しないためです。throw sqle0; と書いてしまうと、スタックトレース情報が失われてしまうため、他の実行時例外にラップしなおして throw することをおすすめします。

次に、コネクションリークに対する対処コードを追加しましょう。実行時例外などが発生した場合でもコネクションが確実に解放されるようにするためには、以下のようなコードを記述する必要があります。(ちなみに .close() 処理でも SQLException が発生する可能性があるため、ここでも try-catch 記述が必要)

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
             cmd.setString(1, au_id);
             cmd.setString(2, au_lname);
             cmd.setString(3, au_fname);
             cmd.setString(4, phone);
             cmd.setBoolean(5, contract);
             try
             {
                 int affectedRows = cmd.executeUpdate();
             }
             catch (SQLException sqle0)
             {
                 if (sqle0.getErrorCode() == 2627)
                 {
                     return false;
                 }
                 else
                 {
                     throw new RuntimeException("SQL 処理がうまくいきませんでした。", sqle0);
                 }
             }
             conn.close();
         }
         catch (SQLException sqle)
         {
             throw new RuntimeException("データベース処理がうまくできませんでした。", sqle);
         }
         finally
         {
             if (conn != null) 
             {
                 try
                 {
                     conn.close();
                 }
                 catch (SQLException sqle1) // ここはなにもしなくてOK
                 {
                 }
             }
         }
         return true;
     }
 }

最後に、更新結果行数が 1 行ではなかった場合の自爆コードを追加して完成です。(たぶんこれで合ってると思うのですが、ちょっと不安....概念的には合ってるはずなのですが;)

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
             cmd.setString(1, au_id);
             cmd.setString(2, au_lname);
             cmd.setString(3, au_fname);
             cmd.setString(4, phone);
             cmd.setBoolean(5, contract);
             try
             {
                 int affectedRows = cmd.executeUpdate();
                 if (affectedRows != 1) throw new RuntimeException("INSERT 文により正しく 1 行のみが INSERT されませんでした。");
             }
             catch (SQLException sqle0)
             {
                 if (sqle0.getErrorCode() == 2627)
                 {
                     return false;
                 }
                 else
                 {
                     throw new RuntimeException("SQL 処理がうまくいきませんでした。", sqle0);
                 }
             }
             conn.close();
         }
         catch (SQLException sqle)
         {
             throw new RuntimeException("データベース処理がうまくできませんでした。", sqle);
         }
         finally
         {
             if (conn != null) 
             {
                 try
                 {
                     conn.close();
                 }
                 catch (SQLException sqle1) // ここはなにもしなくてOK
                 {
                 }
             }
         }
         return true;
     }
 }

というわけで、正確な例外処理コードを書こうとすると、かなり複雑であることがご理解いただけるのではないでしょうか。ちなみに実際の Java の開発では、上記のような厳密なコードを書くとキリがないため、たいていは以下のように「簡略化したコード」で済ませてしまうことが多いと思います。

 public class AuthorDataAccess
 {
     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
     {
         Driver driver = (java.sql.Driver)Class.forName("...").newInstance();
         Properties props = new Properties();
         props.put(...);
         Connection conn = null;
         PreparedStatement cmd = null;
         try {
             conn = driver.connect("...", props);
             cmd = conn.prepareStatement("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (?, ?, ?, ?, ?)");
             cmd.setString(1, au_id);
             cmd.setString(2, au_lname);
             cmd.setString(3, au_fname);
             cmd.setString(4, phone);
             cmd.setBoolean(5, contract);
             int affectedRows = cmd.executeUpdate();
             if (affectedRows != 1) throw new RuntimeException("INSERT 文により正しく 1 行のみが INSERT されませんでした。");
             conn.close();
         }
         catch (SQLException sqle0)
         {
             if (sqle0.getErrorCode() == 2627) return false;
             throw new RuntimeException("SQL 処理がうまくいきませんでした。", sqle0);
         }
     }
 }

たいていの場合は上記のようなコードでも十分なのですが、このコードには以下のようなリスクもあります。

  • コネクションリークが発生する危険性がある。
  • INSERT 文以外で発生した、No.2627 例外を誤って捕捉してしまう可能性がある。

どちらも大きな問題になることは少ないでしょうが、厳格なコードという観点から見た場合にはあまり望ましいとはいえないのが実際のところ、だと思います。いずれにしても、Java で例外を取り扱う場合には、その例外が業務エラーに相当する検査例外なのか、アプリケーション/システムエラーに相当する実行時例外なのかを考えて、適切に取り扱う必要がある、という点をしっかり理解しておく必要があります。

ちょっとだけつぶやいておくと、要するにこの話って、SQLException は業務エラーなのかアプリ/システムエラーなのか、という問題なんですよね。実態を言えば、SQLException が発生する大半のケースはエンドユーザから見て対処のしようのないアプリ/システムエラーなので、これらは実行時例外として設計されていた方がよいんじゃないかと思うのです。しかし、Java ではこれが検査例外として設計されているために、上記のような厄介なコードが必要になってしまう、と思うんですよね。同様の議論は Java の RemoteException に関しても当てはまり、これも検査例外としてライブラリが設計されているのですが、実際には実行時例外として設計されてたほうがよかったんじゃないか、と。この辺はライブラリを設計した人の思想や考えによるものなのでなんとも言えないのですが、開発者レベルから見るとちょっと取扱いが面倒なところではあります。

※ 余談ですが、私は「検査例外」という仕組み自体は非常に好きで、Java から C# に移ったときにこの仕組みがないことに落胆したタイプの人間です;。で、なぜ C# に検査例外の仕組みがないのか……に関してはいろんな理由があるそうなのですが、一つの大きな理由は、CLR の多言語対応のためだとか。確かに C# や VB などは、言語仕様に検査例外の仕組みを導入することもできたと思うのですが、COBOL などをはじめとする言語では、言語仕様に検査例外の仕組みを導入することが難しいのは確か。そのために結果的に検査例外が導入されなかったそうなのですが、検査例外が導入されなかったがゆえに、かえって C# や VB の方が、例外がシンプルで取り扱いやすくなっている、というのはちょっと皮肉的な気もします。

[まとめ]

というわけで、ここまで .NET と Java の例外処理の違いを見てきましたが、重要なポイントをまとめると以下のようになります。

  • アプリケーションを設計する際に、フローチャートを考えて、業務エラーとアプリケーション/システムエラーを分類して考えることは非常に大切。これは .NET でも Java でも同じ。

  • .NET の場合には...

    業務エラーは戻り値で表現。

    アプリケーション/システムエラーは例外で表現。

  • Java の場合には...

    業務エラーは検査例外で表現。

    アプリケーション/システムエラーは実行時例外で表現。

  • フローチャートを考えて、適宜、業務エラーとアプリケーション/システムエラーを変換する。

  • クラスライブラリが投げてくる例外は、.NET と Java とで設計思想が異なることがある。例えば、SqlException 例外は、.NET ではアプリケーション/システムエラー扱いだが、Java では業務エラー扱いである。

image

image

[ところで、それはさておき。]

さて、ここまで解説してきてまとめまで書いておいてなんなのですが、最後にちゃぶ台をひっくり返すことにします。

例外処理は、絶対にここまでの 3 つのエントリに書いた通りにしなければならないというわけではありません。

あ、いえ別に読んでくださっている方々を混乱させようというわけではなく^^、実はこの手の例外処理に関しては、「業務エラーとして .NET の例外を絶対に使っちゃいけないのか?」という議論が必ずつきものであり、おそらく読者の方々の中にも、そうした疑問を抱いている方が少なからずいらっしゃると思うからです。

この質問・疑問に対する、私の回答は以下の通りです。

  • 業務エラーの表現方法として .NET の例外を絶対に使っちゃいけないというわけではありません。(ここまで「絶対」という表現を使いまくってますが;)
  • ここまでの議論をすべて理解した上で、それでもなお自システムの設計ポリシーとして、業務エラーの表現に .NET の例外を使いたいということであれば、それでも構わないと思います。
  • でも、私のおすすめや意見という意味では、上記のような指針をお勧めします。

これは、アーキテクチャに関する一般的な議論としていえることなのですが、アーキテクチャを決定する上で重要なのは、100 点満点の唯一無二の正解を求めることではなく、80 点の内容でもいいからシステム全体でそのアーキテクチャ(設計ポリシー)を一貫させること、だと私は思います。もし、業務エラーにも .NET の例外を使う、というポリシーがシステム全体で一貫しているのであれば、それならそれでもいいのでは? と私は思うんですね。

ただ、それでもはっきりさせておいていただきたいのは、なぜそのようなアーキテクチャ(設計ポリシー)を選択したのかという理由です。

私が業務エラーの表現方法として .NET の例外を使うべきではない、というスタンスを取っている理由は、

  • 業務フローチャートは、アプリケーションコードと 1:1 に対応しているべき。
  • 業務エラーは、メソッドシグネチャ(インタフェース規約)の一部として表現すべき(=フローチャートに表現されていることは、アプリケーションコードとメソッドシグネチャに明示的に表現されるべき)。
  • Java の検査例外のように、メソッドシグネチャの一部として例外を表現したり取り扱ったりする機能が、.NET にはない。

というものです。(ちなみにこの考えの背後には、(スレッドデータスロットなどを使った)暗黙的データ引き継ぎの話、例外発生時の復帰挙動の話、さらには指針としての汎用性や、開発者のミスのしづらさの話などもあるのですが、今回はここまではちょっと触れられなかったのでまた別の機会に。)

ただ、上記の私の考え方が常に 100% いつでも正しいのかというときっとそういうわけではなく、前提条件や考え方次第ではそもそも上記の理由付け自体が重要ではなかったり不適切であったりすることもあるはずで、そうなれば当然正解も変わってくるはず。なぜ私が上記のような主張をしているのかをご理解いただき、そして自システムにおける前提条件などと照らし合わせた上で、「自分のシステムではこういう理由でこういうふうにしよう!」と決定することが重要、だと思うのですね。

特にアプリケーションアーキテクチャの世界には、 「絶対」というものは本来的に存在しない、と思います。(といいつつ、わかりやすいので私はよくこの表現を使っちゃうのですが;。ごめんなさい;。) 例外処理に関する私の見解や取扱い方は、おそらく多くのシステムでそのまま使っていただけるものだろうとは思いますが、なぜそうなのか、という理由まで含めた上で、きちんと理解していただき、ご自分のシステムでの例外処理と業務エラーの取扱いのポリシーを定めていただければ、と思います。

[そんなわけで。]

.NET 開発者でも Java 開発者でも、例外を正しく取り扱うことは非常に重要です。プログラマ初心者の中には「なんとなく」例外処理を書いてしまっている人も多いと思いますが、業務フローチャートを意識して、正しい処理コードを書くことは非常に重要です。本エントリが、.NET、Java 問わずいろんなプログラマの方々に役立つことを願っています。

# にしても、例外処理に関するエントリを書いているとつい熱が入ります(笑)。

# いやー、こういう例外処理の話って私けっこう好きでして^^。

[さらに追記(2009/01/19)]

おまけでもうちょっとだけ追記します。

ある処理について、どんなケースを業務エラーとみなし、どんなケースをアプリケーション/システムエラーとするのかは、どんなクラスを作るのかによって変わります。

例えば、文字列を int 型に変換する処理を考えてみます。この処理は、

  • Java の場合 → Integer.parseInt(string s) メソッド
  • .NET の場合 → Int32.Parse(string s) メソッド

なのですが、この場合、パースエラー(与えられた文字列が数字ではなかったケース)は業務エラーであるべきか、それともアプリケーション/システムエラーであるべきか、どちらでしょうか?

結論からいえば、このような処理においては、パースエラーの場合にはアプリケーションエラー扱いにする(=.NET なら例外、Java なら実行時例外とする)、というのが適切な設計です。これは次のような理由によります。

  • Integer.parseInt() メソッドは、「どういう条件下で使われるのか?」が規定しづらい。
  • パースエラーが業務エラーになるかアプリケーションエラーになるかは、利用シナリオ次第。

もう少し突っ込んで解説すると、パース処理はアプリケーションの様々なところで行われますが、

  • ① UI 部で使われる場合には、パースエラーは十分ありうる=業務エラー扱いとすべき。
  • ② 逆に構成設定ファイルなどから読み取ったものをパースする場合には、パースエラーは普通ありえない=アプリケーションエラー扱いとすべき。

となり、利用シナリオや利用シーン次第で、パースエラーが業務エラーなのかアプリケーションエラーなのかが変わります。このようなケースにおいて、parseInt() のパースエラーを業務エラー扱いとしてライブラリを設計されてしまうと、②の使い方をする際にいちいち業務エラーハンドリングが必要になるので厄介になってしまいます。よって、Integer クラスのような汎用ライブラリのパースメソッドに関しては、アプリケーションエラー扱いとするのが適切、と私は考えます。(ちなみに実際、パースエラーは .NET でも Java でもアプリケーションエラー扱いとなっていて、.NET の場合には FormatException, Java の場合には NumberFormatException が発生する形になっています。)

さらにつぶやいておくと、このことからわかるように、基本的な考え方として、汎用クラスライブラリの戻り値を設計する際には、

「業務エラーかどうかが状況次第で変わるものについては、かたっぱしからアプリケーションエラーに倒して設計しておくべき。」

なのだと思います。この点に関しては Java のクラスライブラリには問題があって、特に I/O 系の業務エラー(RemoteException や SQLException)が片っ端から検査例外として実装されてしまっているのはかなり困りものなのですよね....。(実装コードが非常に書きにくくなるため。これは Java をいじっているときの不満事項の一つでした。) 検査例外が .NET にないのは悔しいものの、不適切な検査例外の利用は逆にデメリットにもなるので、この辺のトレードオフが悩ましいところです。

[さらにさらに追記(2009/01/22)]

以下の資料も参考になるので、お時間があるときにでも読んでみるとよいと思います。