次の方法で共有


例外コードの記述

Eric Gunnerson
Microsoft Corporation

July 19, 2001

私は、.NET の世界ではエラー処理が "エラー コード" を使用するスタイルから、 例外を使用するスタイルに移行していることに関して最近興味深い議論をしました。 そこで、私は 2か月分のコラムでこのことについてお話したいと思います (コラムの締め切りが明日で、別のトピックについて考えていないという別の理由もありますが)。

今月は、例外の使い方と例外を使う理由についてお話しましょう。 来月は、excetion クラスの記述について詳しくお話するつもりです。

例外を使う理由

エラー コードから例外に移行すると、コードの外観に大きな影響を与えます。 異なる方法でコードを記述することを学ぶには、新しい習慣を身につける必要があります。 例外を使うコードを記述することを学ぶ前に、例外を使うコードを記述する価値を理解することが役に立ちます。

長期間にわたってエラー コードが使用されてきました。 C++ でコードを記述すると、多くの場合次のようになります


HRESULT retval = query.FetchRow();
if (FAILED(retval))
{
   // エラー処理をここに記述します。
}

エラー コードを使用すると、作成したコードがあまり適切でないという大きな問題があります。 理論的には、エラー コードを使用して、 すべてのエラーを正しく処理するコードを記述することは可能です。 しかし、そのようなコードに実際お目にかかることはないでしょう。 従来のプログラミング モデルの脆弱性は、エラー処理が正しく行われないという点にあります。 エラー処理が正しく行われないというよりも、エラー処理を正しく行うコードを記述することが難しいのです。

この問題の重要な点は、エラー コードを使用する手法では、 人間があまり得意としない「常に一貫性のある、完全な状態」にすることを要求されることです。 環境はこれを正しく行う手助けはしてくれません。 例外を使用すると、問題ははるかに簡単になります。

Opt In よりも Opt Out

エラー コードと例外の最も重要な違いは、 「正しい処理を行う」ために要求されることです。 エラー コードは opt in (選択肢を追加していく) モデルを使用します。 コードがエラー コードを使って明確に処理を行わない場合は、エラーは処理されません。

それに対して、例外は opt out (選択肢を除外していく) モデルを使用します。 既定では、ランタイムが例外を必ず配信するようにして、例外を正しく処理できるようにします。

この違いは、コードの記述方法に根本的な影響を与えます。 ランタイムが通常の場合の処理を行うので、 プログラマは通常とは動作の異なる場合の処理だけに集中できます。 そのため、それほど努力する必要なくコードをより強固にできます。

この利点だけでも、例外を使う手法に移行する十分な理由になるでしょう。 しかし、ほかにもう 1 つ重要な利点があります。

詳しい説明

新しくコードを記述するとき、正しく機能しないコードを記述してしまうことがあります。 関数から "0x80001345" のような戻り値が返されると、その戻り値の意味を考える必要が生じます。

最初の作業は、この戻り値をある種のシンボリック コードに変換することです。 もっと優れた方法があるかもしれませんが、 私は通常 ヘッダ ファイルで定数を検索してこの変換を行っています。 その後、MSDN でこのシンボリック コードを検索して、コードの意味を調べる必要があります。 そうすれば、何を変更すればよいかがわかるでしょう。 これは運が良い場合の話で、どこを探しても見つからないエラー コードが返されることもあります。

エラー コードが見つかったとしても、 あまり役に立たないこともあります。 エラー コードが ERROR_BAD_ARGUMENTS であれば、 渡した引数の 1 つが正しくないことはわかります。 しかし、どの引数が正しくないのかはわかりません。 呼び出された関数はどの引数が正しくないのかを把握していますが、 関数はそれを呼び出し側に示す手段がありません。

例外では、発生したエラーの種類を示す名前と詳細情報を示すメッセージの両方を取得できます。 たとえば、正しくないパラメータを渡した場合、 例外はどのパラメータが正しくないかを示します。 この情報によって時間を大幅に節約できます。

さらに良いことには、 例外メッセージは問題を解決するためのヒントを提供します。 今年の初めに、私は Beta 1 のコードを Beta 2 に移植しました。 アプリケーションを実行すると、例外がスローされ、次のようなメッセージが表示されました。


"ペイント操作はこのスレッドで実行できません。代わりに Control.Invoke() を使用してください。"

アップデートされた Beta 2 コードは不適切な使用法を確認します。 Beta 2 コードは不適切な使用法を検出すると、 例外をスローし、プログラマに問題を解決する方法を "正確に" 示します。 ですから、この問題を解決するのはとても簡単でした。

例外処理とパフォーマンス

C++ に例外処理が追加されたとき、コードが遅くなるという悪い評判がありました。 例外処理に対応したコードは、例外処理を含まないコードよりも処理速度が少し遅くなるかもしれません。 これらの評判には、当然だと思われる点もあります。 しかし、例外処理を使用しないコードは多くのエラーを確認しないという点で、 明らかに問題があることを指摘すべきでしょう。 例外処理を使用しなくても多くの場合はコードが機能していたので、 そのようなコードでも十分だったのでしょう。

C++ では長い年月をかけて、例外処理のオーバーヘッドを削減してきました。 ただし、オーバーヘッドがなくなったわけではありません。 例外処理を正しく実装するには、 C++ コンパイラは例外がスローされる可能性のあるあらゆる場所で、 作成するオブジェクトを決定する必要があります (このオブジェクトの破棄も必要になります)。 その後、例外を調べ、例外が検出されたときにオブジェクトをクリーン アップするコードを生成する必要があります。 このコードには、最適化を困難にするという副作用があります。 つまり、ペナルティがあります。 C++ では、常にペナルティを払う必要があります。

.NET では、コンパイラの作成者のためにこの処理を簡単にしています。 ランタイムはコードの実行方法について詳しく知っているので、 .NET コンパイラは例外の発生を検出することに関与する必要がありません。 さらに重要なことは、オブジェクトはガベージ コレクタによって収集されるので、 異なるオブジェクトの状態を分析する必要がありません。 ガベージ コレクタは、例外の発生時に適切な方法でオブジェクトをクリーン アップします。 つまり、例外が発生しなかった場合、実際にはオーバーヘッドが追加されません。

例外処理の使用

例外処理が適切な考え方であることを納得してもらえたと思うので、 次は例外処理を扱うコードの記述方法について考えましょう。

Throw

例外状態が発生すると、throw ステートメントを使用して例外を通知できます。 たとえば、あるルーチンがパラメータとして null 以外の文字列を必要とする場合、 以下のようなコードを使います。


public void Process(string location)
{
   if (location == null)
      throw new ArgumentNullException("null value", "location");
}

この例では、特定のメッセージとパラメータ名を指定して、 ArgumentNullException の新しいインスタンスを作成し、 throw ステートメントが使って、このインスタンスをスローします。

Try-Catch

エラー処理の記述で使用される最も基本的な構成は try-catch です。 以下のコードについて考えてみましょう。


   try
   {
      Process(currentLocation);
      Console.WriteLine("処理が完了しました");
   }
   catch (ArgumentNullException e)
   {
      // ここで例外を処理します。
   }

この例では、try ブロック (この場合、Process() 関数) で ArgumentNullException がコードによってスローされると、 Console.WriteLine() の呼び出しを実行せずに、 制御は直ちに catch ブロックに移行します。

一般的な例外のキャッチ

上記の例では、catch 句は ArgumentNullException をキャッチしました。 これは、まさに Process() がスローした例外に一致します。

必須条件ではありませんが、 catch 句は指定した例外やそのクラスから派生した任意の例外をキャッチできます。 たとえば、以下に例を示します。


   try
   {
      Process(currentLocation);
      Console.WriteLine("処理が完了しました");
   }
   catch (ArgumentException e)
   {
      // ここで例外を処理します
   }

ArgumentNullException は ArgumentoException から派生されているので、 この catch 句はどちらの例外もキャッチします。 また、この catch 句はほかの派生した例外 ArgumentOutOfRangeException、 InvalidEnumArgumentException、および DuplicateWaitObjectException もキャッチします。

すべての例外は Exception クラスから派生することになるので、 Exception をキャッチするとすべての例外がキャッチされます。 しかし、すべての例外とは言い切れません。 C++ ではユーザーが Exception 以外から派生したクラスをスローすることを許可しているので、 C# ではすべての例外をキャッチする構文が用意されています。


   catch
   {
      // ここで例外を処理します
   }

上記の構文は例外処理を完全なものにするために用意されていますが、 あまり実用的ではありません。 多くの C++ プログラマは Exception から派生した例外をスローすることを選択するでしょう。 そうでない場合も、この catch 構文では何がスローされたのかわかりません。

Catch の順序

指定した try 句に対して、複数の catch 句を持つことができ、 各 catch 句で異なる種類の例外をキャッチできます。 上記の例では、ArgumentException 用に特別な処理を用意し、 その他すべての例外用に別の処理を用意した方がわかりやすかったかもしれません。 最も固有な (最も派生した) catch 句が選択されます。

このような場合、上記の例は下記のようになります。


   try
   {
      Process(currentLocation);
      Console.WriteLine("処理が完了しました");
   }
   catch (ArgumentException e)
   {
      // ここで例外を処理します
   }
   catch (Exception e)
   {
      // ここで例外を処理します
   }

複数の catch 句を使用する場合、 派生型を基本型よりも前にリストする必要があります (または、より固有なものから順にリストします)。 これは読みやすくするためです。 より前の catch 句を調べると、ランタイムがどのように動作するかを判断できます。

Catch の操作

例外をキャッチできるようになったので、 次はこの例外を使用して何か役に立つことを行ってみましょう。 まず行いたいことは、特別な状況情報で例外をラップすることです。

これは以下の方法で行えます。


   try
   {
      Process(currentLocation);
      Console.WriteLine("処理が完了しました");
   }
   catch (ArgumentException e)
   {
      throw new ArgumentException("処理中にエラーが発生しました", e);
   }

この例では、ArgumentException のコンストラクタを使用しています。 このコンストラクタは、メッセージと別の例外を受け取ります。 コンストラクタは渡された例外を新しい例外でラップし、ラップした例外をスローします。

このプロセスは、開発者に非常にすばらしい利益をもたらします。 発生した事象に関する情報を個別に取得する代わりに、 例外をラップすることによりスタック トレースのようなものを提供できます。


例外が発生しました: 
System.Exception: Test1 で例外が発生
---> System.Exception: Test2 で例外が発生
---> System.DivideByZeroException: 0 で除算しようとしました。

このような出力のおかげで、デバッグがとても間単になります。 /debug スイッチを使用してコンパイルすると、各レベルでファイル名および行番号も取得できます。

デバッグ用の付加情報を提供する場合、ラップすることが役に立ちます。 ラップが役に立つもう 1 つの状況は、 例外に基づいて操作を行う必要がある場合です。 ファイルに出力を送信するコードは、次のようになります。


   try
   {
FileStream f = new FileStream(filename, FileMode.Create);
StreamWriter s = new StreamWriter(f);
        
s.WriteLine("{0} {1}", "test", 55);
s.Close();
f.Close();      
   }
   catch (IOException e)
   {
      Console.WriteLine("ファイル {0} を開くときのエラー", filename);
      Console.WriteLine(e);
   }

ファイルが開けない場合、例外がスローされ、catch が呼び出されエラーが表示されますが、 引き続きプログラムを実行できます。 多くの場合、これで問題ありません。

ただし、この場合にも問題はあります。 ファイルが開かれてから例外が発生すると、ファイルを閉じることができません。これは良くないですね。

ここで必要なことは、例外が発生した後でもファイルを閉じることを可能にする方法です。

Try-Finally

finally 構造を使用して、例外が発生した場合でも常に実行されるコードを指定できます。 一般的に finally は、例外イベントでのクリーン アップ処理に使用されます。 上記の例を修正すると、下記のようになります。


   try
   {
FileStream f = new FileStream(filename, FileMode.Create);
StreamWriter s = new StreamWriter(f);
        
s.WriteLine("{0} {1}", "test", 55);
s.Close();
f.Close();      
   }
   catch (IOException e)
   {
      Console.WriteLine("ファイル {0} を開くときのエラー", filename);
      Console.WriteLine(e);
   }
   finally
   {
      if (f != null)
         f.Close();
   }

修正したコードでは、例外が発生した場合でも finally 句が実行されます。

Using ステートメントと Lock ステートメント

上記の形式はとても一般的なものです。 C# では、プログラマが適切な処理を行うことを奨励するための特別なサポートが用意されています。 using ステートメントを使用して、以下のような手法で try-finally コードを作成できます。


   using (FileStream f = new FileStream(filename, FileMode.Create))
   {
StreamWriter s = new StreamWriter(f);
        
s.WriteLine("{0} {1}", "test", 55);
s.Close();
f.Close();      
   }
   catch (IOException e)
   {
      Console.WriteLine("ファイル {0} を開くときのエラー", filename);
      Console.WriteLine(e);
   }

このコードは上記の例と同じ処理を行います。

lock ステートメントは、例外が発生した場合でもロックが解除されるように、 System.Threading.Monitor クラスを同じように隠ぺいします。

デザイン ガイドライン

ここでは、いくつかの例外処理のデザイン ガイドラインを説明します。

最も固有な例外をキャッチする

コードが一部の例外から復旧する必要がある場合、 対象の例外だけをキャッチするようにします。 一般的な例外をキャッチする場合、 見落としたくくない例外を誤って見落としてします可能性が高くなります。

確実な場合だけエラーを処理しない

これは上記のガイドラインの必然的な結果です。 例外を処理しない場合は、 ユーザーがその例外が発生する可能性がある状況をすべて理解していて、 その例外が発生した場合はユーザーが記述した復旧コードで処理することを意味します。

適用可能ならば、lock か using ステートメントを使用する

lock ステートメントまたは using ステートメントを使用できる場合は、 これらのステートメントを使用します。 これらのステートメントを使用すると、コードが読みやすくなり、 適切な処理を行う可能性が高くなります。

適用可能ならば、例外をラップする

例外に付加情報を追加できる場合は、是非そうしてください。 別の関数にパラメータを渡す場合、 パラメータに関する付加情報を追加すると役に立つことがあります。


try
{
    o.Process(v1, v2, v3);
}
catch (ArgumentException e)
{
    throw new ArgumentException("連絡先情報の更新中にエラーが発生しました", e);
}

catch 句はキャッチした例外を同じ種類でラップして、 ラップした例外をスローします。 この処理によって、ユーザーには 2 つのレベルの情報が提供されます。

ただし、この場合注意することがあります。 この例では、catch 句は ArgumentException または ArgumentException から派生した任意の型 (ArgumentNullException など) をすべてキャッチできます。 明示的に ArgumentException をスローすることにより、 呼び出し側がこれらの違いにまったく注意する必要がなくなります。

最悪な例は、Exception をキャッチして Exception でラップするような場合です。 呼び出し側が例外の実際の種類を解釈する必要がある場合、 以下のようなコードを記述する必要が生じます。


try
{
   o.BadFunction();
}
catch (Exception e)
{
   if (e.Inner.GetType() == typeof(ArgumentException))
   {

   }
   else
      throw;
}

このコードはラップされた例外の型を調べて、 例外を処理するか、または再度例外をスローします。 このようなコードを記述する必要がある場合、コードには重大な問題があると言えます。

来月

今月はここまでです。来月は、独自の Exception クラスの記述についてお話しましょう。