言語構文的な意味での例外処理の方法は 「例外処理」 で説明しましたが、 実際のところ、どういう場合にどうやって例外を投げて、 どうやって処理すればいいのかは、 慣れるまでなかなか難しかったりします。
ということで、ここでは、例外の使い方について説明したいと思います。
例外の投げ方に関する考え方は意外とシンプルで、 「メソッドの定める結果を達成できないなら例外を投げる」という方針で OK です。
いくつか例を挙げてみましょう。
表1: 規約と例外
| メソッド | 規約 | 例外が起きる場面 |
|---|---|---|
| File.Open | ファイルを開く。 | 開こうとしたファイルが存在しない。 |
| Enumerable.Single | コレクションの中から条件を満たすただ1つの要素を選択する。 | 条件を満たす要素が1つも存在しないか、もしくは、2つ以上存在する。 |
| int.Parse | 文字列を整数に変換する。 | 変換できない文字列が渡された。 |
| Dictionary<T>.Item | キーに対応する値を取得する。 | キーが存在しない。 |
こういう規約違反に対しては、基本的に例外を使います。 後述する Try Parse パターンが適しているような状況以外では、戻り値によって正常・例外の区別をするような方法は好ましくありません。 (利用側に例外処理を強制できず、例外が発生したことに気づかないまま正常なつもりで処理を続けてしまい、後になって困る可能性がある。)
一口に「規約を達成できない状況」と言っても、実はいくつかのパターンがあります。
多くの場合、事前のチェックなどを行うことによって例外が発生するような状況は回避できます。
表2: 例外の発生状況と回避方法
| 例外が発生する状況 | 回避する方法 |
|---|---|
| 引数が null であってはならない。 | どこか適切なレベルで null チェックを行う。 |
| ファイルを Open するまえに Read してはならない。 | ファイルを Open してから Read する。 Close した後には Read しない。 |
| Dictionary では、存在しないキーに対して値の読み取りを行ってはならない。 | キーが存在するかどうかを事前にチェックする。 |
| 読み取り専用コレクションに対して書き込み操作を行ってはならない。 | 事前に IsReadOnly プロパティを確認する。 |
この手の例外に対しては、以下のような方針を取ります。
例えば、File.Open を考えてみましょう。 以下のようなコードは、一応、ファイルの存在の有無を事前チェックしています。
if (!File.Exists(filename)) { text = "デフォルトのテキスト"; } else { // Exists の後、↓を実行するまでの間にファイルが消される可能性はある。 text = File.ReadAllText(filename); }
ところが、ファイルというのはこのプログラム以外からも編集されるものなので、 Exists で確認してから ReadAllText で読み込みを行うわずかな間にファイルが消えてしまう可能性が残ります。
ですが、このコードの場合、ファイルが消えてたら消えてたでデフォルトの値を返すことでプログラムは続行可能なので、 以下のように対処します。
try { if (!File.Exists(filename)) { text = "デフォルトのテキスト"; } else { text = File.ReadAllText(filename); } } catch(FileNotFoundException) { text = "ファイルがなくてもデフォルトのテキストがあれば OK"; }
この手の例外に対しては、以下のような方針を取ります。
.NET Framework 自体がエラーを起こしたり、どうあがいてもプログラムの続行が不可能な状況も、希にあります。
こういう場合、言えることは1つだけで、 下手に例外を握りつぶさず、素直にプログラムを終了させてください。 握りつぶしたがために後から不具合を起こすくらいなら、 例外が起きた瞬間に処理を止める方が幾分かマシです。
前述のとおり、使用法上の例外はそもそも発生しないようにするのが好ましいです。 そのためには、メソッド呼び出し前に、インスタンスの状態や、引数の中身をチェックします。 引数の null チェックのように外で簡単にチェックできる条件もあれば、 特別なチェック用メソッドが必要な場合もあります。
後者、すなわち、 例外の発生しうるメソッド本体(Doer: do するもの)に対して、 事前チェック用のテストメソッド(Tester)を用意する方法のことを Tester-Doer パターンと呼びます。 .NET Framework のクラスで、この Tester-Doer パターンになっているものをいくつか紹介します。
表3: Tester-Doer パターンを実装するクラス
| クラス | Doer | Tester | 説明 |
|---|---|---|---|
| Sytem.Collections.Generic.IDictionary | Item (インデクサー) | ContainsKey | キーが存在しない場合に例外を起こすんで、事前に存在の有無を確認。 (ちなみに、後述の Try Parse パターンを使う TryGetValue というメソッドもある。) |
| System.Collection.Generic.ICollection | Item (インデクサー)の setter | IsReadOnly | .NET では、読み取り専用コレクションと読み書き両用コレクションでインターフェースを分けない方針。 読み取り専用かどうかは IsReadOnly プロパティで確認してから使う。 |
| System.IO.Stream | Read, Write | CanRead, CanWrite | 同上、読み取り専用・書き込み専用・両用でインターフェースを分けない。 |
処理の内容によっては、Tester の処理負荷が高すぎて、Tester-Doer パターンを実装したくない場合があります。
例えば、int.Parse なんかがそうなんですが、 int.Parse に対して Tester を作ろうと思うと、結局のところ int.Parse と同じような処理が必要になります。 同じような処理を2度実行するのも馬鹿げた話なので、別の方法を考えることになります。
こういう場合に使うのが Try Parse パターンです。 「戻り値でエラーを返さない」という方針をあきらめて、bool の戻り値で処理の可否を返します。
// 通常の Parse。変換できない場合は FormatException が発生。 int x = int.Parse(text); // TryParse。例外が発生しない代わりに、見てのとおり書き方がちょっとうっとおしい。 int y; if (!int.TryParse(text, out y)) y = 0;
out 引数も可能な限りは避けたい機能だったりするんで、 実は結構な苦肉の策だったりはします。 とはいえ、変換ができないときに例外を発生させるくらいなら、 多少の不恰好を覚悟でこのパターンを使うのもやむなしな感じです。
ちなみに、Tester-Doer パターンと Try Parse パターンの両方を実装しておくというのも考えられます。 例えば、IDictionary<T> では、Item(インデクサー)と ContainsKey では Tester-Doer パターンに、 TryGetValue では Try Parse パターンに基づいた実装になっています。