November 2015
Volume 30 Number 12
Essential .NET ー- C# の例外処理
Mark Michaelis | 年 11 月 2015
ようこそ、「Essential .NET」コラムの第 1 回です。このコラムでは、Microsoft .NET Framework の世界で起きているさまざまなことを追跡し、C# vNext (現 C# 7.0) の進化点、.NET 内部の改善点、(オープン ソースに移行中の MSBuild など) Roslyn や .NET Core の最前線で起きていることなどを取り上げる予定です。
2000 年に .NET がリリースされて以来、.NET を使ったコーディングや開発を行ってきました。このコラムでは、新しい情報だけでなく、ベスト プラクティスを目指してこのテクノロジを活用する方法について数多く紹介していく予定です。
私は、ワシントン州スポケーンに住み、IntelliTect (IntelliTect.com、英語) という最高レベルのコンサルティング企業で「コンピューター専門家集団のチーフ」を務めています。IntelliTect は、優れた「複雑な製品」の開発を専門とする企業です。私は 20 年間 Microsoft MVP (現在は C#) に認定されていて、そのうち 8 年間は Microsoft Regional Director を務めています。それでは、最新の例外処理ガイドラインを紹介するところから、コラムを始めます。
C# 6.0 には、2 つの新しい例外処理機能があります。1 つは、例外条件のサポートです。スタックをアンワインドする前に、catch ブロックに入る例外をフィルターで除外する式を指定できます。もう 1 つは、catch ブロック内での非同期サポートです。C# 5.0 で言語に非同期が追加された時点ではこれが不可能でした。また、現在までの 5 つのバージョンの C# と対応する .NET Framework には多数の変更が加えられています。こうした変更点の中には、C# のコーディング ガイドラインを編集する必要があるほど大きな変更点もあります。この連載コラムでは、これら多くの変更点を確認し、例外処理 (例外のキャッチ) に関連する場合は、それに応じて更新したコーディング ガイドラインを提示します。
例外のキャッチ: 復習
特定の例外型をスローすると、キャッチした側がその例外の型自体を使用して問題を特定できることはよく知られています。つまり、例外をキャッチし、例外メッセージに対して switch ステートメントを使用して、その例外を受けたときに取る動作を決める必要はありません。C# では、特定の例外型をそれぞれターゲットにする複数の catch ブロックが許可されます (図 1 参照)。
図 1 さまざまな例外型のキャッチ
using System;
public sealed class Program
{
public static void Main(string[] args)
try
{
// ...
throw new InvalidOperationException(
"Arbitrary exception");
// ...
}
catch(System.Web.HttpException exception)
when(exception.GetHttpCode() == 400)
{
// Handle System.Web.HttpException where
// exception.GetHttpCode() is 400.
}
catch (InvalidOperationException exception)
{
bool exceptionHandled=false;
// Handle InvalidOperationException
// ...
if(!exceptionHandled)
// In C# 6.0, replace this with an exception condition
{
throw;
}
}
finally
{
// Handle any cleanup code here as it runs
// regardless of whether there is an exception
}
}
}
例外が発生すると、その例外を処理できる最初の catch ブロックに実行がジャンプします。try ブロックに関連付けられた catch ブロックが複数ある場合、一致度が最も高いブロックが継承チェーンによって決定され (C# 6.0 の例外条件を使用しない場合)、一致度が最も高い最初のブロックが例外を処理します。たとえば、スローされた例外が System.Exception 型であっても、System.InvalidOperationException は結局のところ System.Exception から派生されているため、継承により関係が生じます。スローされた例外との一致度が最も高い例外は InvalidOperationException になるため、catch(Exception...) ブロックではなく catch(InvalidOperationException...) がこの例外をキャッチします (一致度が最も高いのが 1 つの場合)。
複数の catch ブロックは、コンパイル時エラーを避けるため、最も具体的なブロックから最も汎用のブロックという順序で配置しなければなりません (ここでも、C# 6.0 例外条件を使用しないことが前提です)。たとえば、catch(Exception...) ブロックを他のすべての例外の前に配置すると、他の例外はすべて継承チェーンのある時点で System.Exception から派生されるため、結果的にコンパイル エラーが発生します。また、catch ブロックには名前付きパラメーターが必要ない点にも注意します。実際には、パラメーター型のない最後の catch は、残念ながら、汎用の catch ブロックとして許可されます。汎用の catch ブロックについては後ほど説明します。
場合によっては、例外をキャッチした後、その例外を適切に処理できるかどうかを実際に判断する場合があります。このシナリオには、2 つのオプションがあります。最初のオプションは、別の例外を再スローすることです。このオプションが適している状況には、3 つのシナリオがあります。
シナリオ 1: 受け取った例外では、その原因となる問題を十分に特定できない場合。たとえば、有効な URL を指定して System.Net.WebClient.DownloadString を呼び出すと、ネットワークに接続されていない場合も、存在していない URL を指定した場合も、ランタイムは同じ System.Net.WebException をスローする可能性があります。
シナリオ 2: 受け取った例外が、呼び出しチェーンの上位方向に公開すべきではないプライベート データを含む場合。たとえば、ごく初期のバージョンの CLR v1 (アルファ以前) には、「セキュリティ例外: c:\temp\foo.txt のパスを決定する権限がありません」というような例外がありました。
シナリオ 3: 例外型が呼び出し側が処理するには具体的過ぎる場合。たとえば、ZIP コードを検索する Web サービスを呼び出すと、サーバー上で System.IO 例外 (UnauthorizedAccessException IOException FileNotFoundException DirectoryNotFoundException PathTooLongException、NotSupportedException、SecurityException ArgumentException など) が発生します。
別の例外を再スローすると、元の例外が失われる可能性がある点には注意が必要です (ただし、シナリオ 2 ではおそらく意図的に破棄します)。これを防ぐには、ラップしている側の例外の InnerException プロパティにキャッチした例外を設定します。これは通常コンストラクターで割り当てることができます。ただし、この設定を行うことで呼び出しチェーンの上位方向に公開すべきではないプライベート データが公開される場合は除きます。これを行っても、元のスタック トレースはそのまま使用できます。
内側の例外を設定しないで、throw ステートメント (例外のスロー) の後に例外インスタンスを指定すると、スタック トレースの場所がその例外インスタンスに設定されます。以前にキャッチした例外を再スローしたとしても、その例外のスタック トレースが既に設定されていても、リセットされます。
例外をキャッチしたときの 2 つ目のオプションは、その例外を適切に処理できないことを決定することです。このシナリオでは、まったく同じ例外を再スローして、呼び出しチェーンの上位にある次のハンドラーに例外を送信します。図 1 の InvalidOperationException の catch ブロックがこの例を示しています。例外のインスタンス (例外) が再スローできる catch ブロックのスコープ内にある場合でも、スローする例外を指定しない throw ステートメント (単独のスロー) を指定しています。具体的な例外をスローすると、新しくスローする場所に一致するように、すべてのスタック情報が更新されます。結果的には、例外が本来発生した呼び出しサイトを示すスタック情報がすべて失われ、問題の診断が非常に難しくなります。catch ブロックでは例外を適切に処理できないと決めた場合は、空の throw ステートメントを使用して例外を再スローします。
同じ例外を再スローするか、例外をラップするかにかかわらず、全般的なガイドラインとしては、呼び出しスタックの下位方向に例外をレポートまたはログ記録しないようにします。つまり、例外をキャッチし、再スローするときは、毎回例外をログに記録しません。記録を行うと、そのたびに同じ内容が記録されるため、特に付加価値もなく、ログ ファイルに不要な情報が記録されます。さらに、例外には、その例外がスローされた時点のスタック トレース データが含まれているため、毎回記録する必要はありません。もちろん、例外を処理するときは必ずその例外をログに記録し、例外を処理する予定がない場合は、プロセスをシャットダウンする前にその例外をログに記録します。
スタック情報を置き換えない既存例外のスロー
C# 5.0 では、以前にスローされた例外を、その元の例外のスタック トレース情報を失うことなくスローできるメカニズムが追加されました。このメカニズムにより、たとえば、catch ブロックの外からでも例外を再スローできるため、空の throw を使う必要がありません。catch ブロックの外側で再スローしなければならない状況はめったにありませんが、場合によっては、プログラムの実行が catch ブロックの外側に移動するまで、例外がラップまたは保存されることがあります。たとえば、マルチスレッド コードは AggregateException を使って例外をラップすることがあります。.NET Framework 4.5 は、静的 Capture メソッドとインスタンスの Throw メソッドを使用して上記のシナリオを具体的に処理する、System.Runtime.ExceptionServices.ExceptionDispatchInfo クラスを提供します。図 2 は、スタック トレース情報をリセットせず、空の throw ステートメントを使用しない例外の再スローを示しています。
図 2 ExceptionDispatchInfo を使用した例外の再スロー
using System
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
Task task = WriteWebRequestSizeAsync(url);
try
{
while (!task.Wait(100))
{
Console.Write(".");
}
}
catch(AggregateException exception)
{
exception = exception.Flatten();
ExceptionDispatchInfo.Capture(
exception.InnerException).Throw();
}
ExeptionDispatchInfo.Throw メソッドにより、コンパイラは通常の throw ステートメントと同じように例外を return ステートメントとして扱いません。たとえば、メソッド シグネチャでは値を返すことになっていても、ExceptionDispatchInfo.Throw ではコード パスから値が返されない場合は、コンパイラは値が返されないことを示すエラーを報告します。場合によっては、実行時に return ステートメントが実行されない場合 (代わりに例外がスローされることになります) でも、開発者は ExceptionDispatchInfo.Throw の後に return ステートメントを用意しなければならないこともあります。
C# 6.0 での例外のキャッチ
例外処理の全般的なガイドラインとしては、完全に処理できない例外はキャッチしないようにします。ただし、C# 6.0 より前の catch 式は例外型でしかフィルター選択できないため、catch ブロックでスタックがアンワインドされる前に例外データとコンテキストをチェックできるようにするには、例外をテストする前にその catch ブロックをその例外のハンドラーにする必要があります。残念なことに、例外を処理しないように決めた場合、同じコンテキスト内の別の catch ブロックがその例外の処理できるようにコーディングするのは手間がかかります。さらに、同じ例外を再スローすると、結果的に 2 パスの例外プロセスを再度呼び出す必要があります。最初のプロセスでは、まず例外を処理できるブロックが見つかるまで呼び出しチェーンに例外を渡します。その後、次のプロセスでは、例外とキャッチの場所との間でフレームごとに呼び出しスタックをアンワインドします。
例外がスローされてから、catch ブロックでコール スタックをアンワインドする場合、例外のその後のテストで例外を適切に処理できないことが明らかになったら、結局は例外を再スローすることになります。そのため、catch ブロックでコール スタックをアンワインドするよりも、最初の場所で例外をキャッチしない方が明らかに望ましいと考えられます。C# 6.0 からは、catch ブロックに追加の条件式を使用できるようになります。C# 6.0 には、catch ブロックが例外型に一致するかどうかを基準にしたチェックだけでなく、条件句のサポートが含まれています。when 句では、条件が真のときだけ例外を処理するように catch ブロックをフィルタリングするブール式を指定できます。図 1 の System.Web.HttpException ブロックは、等値比較演算子を使用する例を示しています。
興味深いことに、例外条件を指定すると、コンパイラは catch ブロックを継承チェーンの順に配置するよう求めなくなります。たとえば、例外条件を指定した System.ArgumentException 型の catch ブロックを、より具体的な System.ArgumentNullException 型の前に配置できるようになります。これは両者に派生関係がある場合でも可能です。これにより、汎用の例外型の後に具体的な例外型 (例外条件の有無は問いません) を組み合わせる特定の例外条件を作成できるようになります。実行時の動作は以前のバージョンの C# と同じで、最初に一致する catch ブロックが例外をキャッチします。一致する catch ブロックは型と例外条件の組み合わせによって決まり、コンパイラは例外条件が指定されていない catch ブロックの順序のみを強制します。複雑さが増したのはこの点のみです。たとえば、例外条件を指定した catch(System.Exception) は、例外条件の有無にかかわらず、catch(System.ArgumentException) の前に配置できます。ただし、例外条件を指定しない例外型の catch ブロックを配置したら、それよりも具体的な例外の catch ブロック (catch(System.ArgumentNullException) など) は例外条件の有無にかかわらず配置することはありません。これにより、プログラマには順不同になる可能性のある例外条件をコーディングする「柔軟性」が提供されます。ただし、順不同になると、前にある例外条件が、後ろに配置した例外条件に一致する例外をキャッチしてしまい、その例外が意図したブロックに到達できない状態になる可能性はあります。最終的に、catch ブロックの順序は、if-else ステートメントの順序に似てきます。条件に一致したら、他のすべての catch ブロックは無視されます。ただし、if-else ステートメントの条件とは異なり、すべての catch ブロックは例外型のチェックを含まなければなりません。
更新された例外処理ガイドライン
図 1 の比較演算子の例は単純ですが、例外条件を単純にしなければならないという制限はありません。たとえば、条件の評価をメソッド呼び出しにしてもかまいません。唯一の要件は、式を述語にすることです。つまり、ブール値を返すことです。言い換えれば、例外をキャッチする呼び出しチェーン内から事実上任意のコードを実行できます。これにより、同じ例外を再度キャッチして再スローする可能性がなくなります。原則的に、十分にコンテキスト絞り込んでから、キャッチが有効な場合にのみ特定の例外だけをキャッチできるようになります。そのため、十分に処理できない例外のキャッチを回避するガイドラインが現実味を帯びてきます。実際、空の throw ステートメントを囲む条件付きチェックを調べて、回避できる可能性があります。失われやすい状態をプロセスが終了する前に保存する場合を除いて、空の throw ステートメントを使用するよりも効果がある例外条件を追加することを検討します。
とは言うものの、開発者はコンテキストチェックするためだけに条件句を使用することは制限する必要があります。条件式自体が例外をスローすると、新しい例外が無視され、その条件が偽として扱われるため、条件句の制限が重要になります。そのため、例外条件式での例外のスローは避けるようにします。
汎用 catch ブロック
C# では、コードをスローするオブジェクトはすべて、System.Exception から派生する必要があります。ただし、この要件はすべての言語に共通というわけではありません。たとえば C/C++ では、System.Exception から派生していないマネージ例外や、int や string などのプリミティブ型を含め、任意のオブジェクト型をスローできます。C# 2.0 以降、すべての例外は、System.Exception から派生しているかどうかにかかわらず、System.Exception から派生しているものとして C# アセンブリに伝達されます。そのため、System.Exception の catch ブロックは、その前のブロックでキャッチされなかった "合理的に処理される" すべての例外をキャッチします。しかし、C# 1.0 以前は、System.Exception から派生していない例外が (C# で記述されていないアセンブリに存在する) メソッド呼び出しからスローされた場合、catch(System.Exception) ではキャッチされません。このため、C# は、型や変数名が存在しない場合を除いて catch(System.Exception 例外) ブロックとまったく同じように動作する汎用 catch ブロック (catch{ }) もサポートします。このようなブロックのデメリットは、アクセスする例外インスタンスが存在しないため、適切な行動指針を把握する方法がない点です。さらに、例外を記録したり、例外に問題がないというまれな状況を認識することもできません。
実際には、プロセスをシャットダウンする前に例外をログに記録することによって、名目上例外を「処理する」ことを除けば、catch(System.Exception) ブロックと汎用 catch ブロック (このコラムで System.Exception の catch ブロックとして全体的に参照しているブロック) は、どちらも回避されます。処理できる例外だけをキャッチするという一般原則に従えば、プログラマが宣言のためだけにコードを記述するのは不自然です。この catch ブロックは、スローされる可能性のある例外をすべて処理することになります。まず、ごく単純なプログラムを除けば、すべての例外を分類するのは膨大な作業になります (実行するコード量が最も多く、コンテキストが最も少なくなる Main の本文では特に膨大になります)。さらに、予期しない例外をスローする可能性のあるホストも存在します。
C# 4.0 以前は、プログラムがほぼ修復不能な壊滅的な状態の例外の 3 番目のセットがありました。ただし、System.Exception (または汎用 catch ブロック) のキャッチではこのような例外が実際にはキャッチされないため、このセットは、C# 4.0 のリリース当初はあまり注目されませんでした (技術的には、System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions でメソッドを修飾して、このような例外をキャッチできますが、そのような例外に十分に対処できる見込みは非常に難しいものです。詳細については、bit.ly/1FgeCU6 を参照してください)。
壊滅的状態の例外に関する技術的な注意点の 1 つとして、この例外が実行時にスローされたとき、System.Exception の catch ブロックに渡されるだけです。System.StackOverflowException や他の System.SystemException など、壊滅的状態の例外の明示的なスローはキャッチはされますが、このようなスローは大きな誤解を招く恐れがあり、現実的には、旧バージョンとの互換性のためだけにサポートされています。現在のガイドラインでは、壊滅的状態の例外 (System.StackOverflowException, System.SystemException、System.OutOfMemoryException, System.Runtime.InteropServices.COMException、System.Runtime.InteropServices.SEHException、System.ExecutionEngineException など) をスローしないことになっています。
要約すると、なんらかのクリーンアップ コードで例外を処理し、例外を記録した後に再スローしてアプリケーションを適切に終了する場合を除いて、System.Exception の catch ブロックは使用しないようにします。たとえば、この catch ブロックがアプリケーションをシャットダウンしたり例外を再スローする前に、失われる可能性のあるデータ (必ずしも失われると考える必要はなく、データが破損している可能性もあります) を正しく保存する場合などが挙げられます。実行を続けることが安全ではなく、アプリケーションを終了する必要があるシナリオでは、コードから System.Environment.FailFast メソッドを呼び出します。アプリケーションのシャットダウン前に例外を適切にログに記録する場合を除いて、System.Exception や汎用の catch ブロックは使用しないようにします。
まとめ
今回は、例外のキャッチ、現在までの複数のバージョンに行われた C# と .NET Framework の改善による更新に関する例外処理ガイドラインの更新を取り上げました。新しいガイドラインもいくつかありましたが、多くのガイドラインは以前と同様に今でも安定しています。以下に、例外のキャッチに関するガイドラインをまとめておきます。
- 完全に処理できない例外のキャッチは避ける。
- 完全に処理しない例外を隠ぺい (破棄) することは避ける。
- 例外を再スローする throw を使用する。catch ブロックの内部では、throw <例外オブジェクト> は使用しない。
- プライベート データが公開される場合を除いて、例外をラップする側の InnerException プロパティにキャッチした例外を設定する。
- 処理できない例外を受け取った後に例外を再スローするよりも効果がある例外条件を検討する。
- 例外条件式からの例外のスローは避ける。
- 別の例外を再スローするときは注意する。
- System.Exception と汎用の catch ブロックはあまり使用しない (アプリケーションのシャットダウン前に例外をログに記録する場合を除く)。
- コール スタックの下位方向に例外を報告または記録するのは避ける。
各ガイドラインの詳細については、itl.tc/ExceptionGuidelinesForCSharp (英語) を参照してください。次回のコラムでは、例外のスローに関するガイドラインに注目する予定です。あえて言うなら、例外のスローのテーマは、例外が対象とする受け手は、プログラムのエンド ユーザーではなくプログラマだということです。
このコラムで紹介する要素の多くは、筆者の『Essential C# 6.0 (5th Edition)』 (Addison-Wesley、2015 年) の新版から引用しています。この書籍は、itl.tc/EssentialCSharp (英語) から購入できます。
Mark Michaelisは、IntelliTect の創設者で、チーフ テクニカル アーキテクトとトレーナーを務めています。彼は約 20 年間 Microsoft MVP に認定され、2007 年から Microsoft Regional Director を務めています。Michaelis は、C#、Microsoft Azure、SharePoint、Visual Studio ALM など、マイクロソフト ソフトウェアの設計レビュー チームにも所属しています。彼は開発者カンファレンスで講演し、最近の『Essential C# 6.0 (5th Edition)』を含め、多くの書籍を執筆しています。連絡先の Facebook は facebook.com/Mark.Michaelis (英語)、ブログは IntelliTect.com/Mark (英語)、Twitter は @markmichaelis (英語)、メールは mark@IntelliTect.com (英語のみ) です。
この記事のレビューに協力してくれた技術スタッフの Kevin Bost、Jason Peterson、および Mads Torgerson に心より感謝いたします。