なぜ例外処理が必要なのか?
適切な例外処理を実装することは、アプリケーションの信頼性を高めます。
どういうことかというと、
-
予期されるエラーは適切に対処してクラッシュさせない
例外として適切にを処理することでアプリのクラッシュを防ぎ、継続性を確保する -
予期できないバグやエラーの顕在化
予期されていない例外は無理に動作を続けずに例外でアプリをクラッシュさせて、問題を顕在化させる
ということですね。
公式ドキュメントに学ぼう!
何はともあれ公式ドキュメントが正義です
中身を見てみましょう。
ぴったりのページがあったので例外処理のベストプラクティスを自分なりに噛み砕いて学んでいきます。
try/catch/finallyブロックを使用すべし
予測される例外発生後、そのコード内で回復可能な場合はtry/catch/finallyを使って処理を継続させます。finallyブロックではリソースの解放等の例外の発生有無に関わらず実行したい処理を書きます。
例えば、SQLの処理失敗時にアプリはクラッシュさせずにエラーメッセージを表示させたいケース
public void ExecuteSqlCommand(string connString, string cmdString)
{
var connection = new SqlConnection(connString);
var executeCommand = new SqlCommand(cmdString, connection);
try
{
connection.Open();
executeCommand.ExecuteNonQuery();
}
catch (SqlException e)
{
Console.WriteLine("SQL実行でエラーが起きています");
Console.WriteLine($"{e.ToString}");
}
finally
{
connection.Dispose();
executeCommand.Dispose();
}
}
ちなみにIDisposable
を実装しているクラスであればusing
が使用できるため、以下のように書き換え可能です。
// 従来のusing実装
public void ExecuteSqlCommandUsing(string connString, string cmdString)
{
using (var connection = new SqlConnection(connString))
using (var executeCommand = new SqlCommand(cmdString, connection))
{
try
{
connection.Open();
executeCommand.ExecuteNonQuery();
}
catch (SqlException e)
{
Console.WriteLine("SQL実行でエラーが起きています");
Console.WriteLine($"{e.ToString}");
}
}
// 例外発生有無に関わらずusingブロックの終了タイミングでDisposeが実行される
}
// .NET8.0以降で使用可能なusing宣言
public void ExecuteSqlCommandNewUsing(string connString, string cmdString)
{
using var connection = new SqlConnection(connString);
using var executeCommand = new SqlCommand(cmdString, connection);
try
{
connection.Open();
executeCommand.ExecuteNonQuery();
}
catch (SqlException e)
{
Console.WriteLine("SQL実行でエラーが起きています");
Console.WriteLine($"{e.ToString}");
}
// 例外の発生有無に関係なく変数のスコープが無効になったタイミングでDisposeが実行される
}
例外発生を回避する方法を検討すべし
例外が発生しうるケースで、例外を回避する実装方法がある際は例外が発生しない実装を検討すべきです。
例えば、SqlConnectionのCloseメソッドを呼び出したい際、すでに閉じられている場合はInvalidOperarionException
が発生します。
その場合は、ifを使って"すでに閉じられていない場合"のみ実行する処理に置き換えればtry/catchの実装なしに例外を回避できます。
// bad
try
{
// すでに閉じられている場合はInvalidOperationExceptionが発生
connection.Close();
}
catch (InvalidOperationException e)
{
Console.WriteLine("SQL実行でエラーが起きています");
Console.WriteLine($"{e.ToString}");
}
// good
if (connection.State != ConnectionState.Closed)
{
// すでに閉じられていない場合のみ実行する
connection.Close();
}
例外を回避するクラス設計をすべし
上記と同様ですが、クラス設計に関しても例外のスローを回避する実装方法を検討すべきです。
例えば、System.IO.FileStream
クラスではファイルの末尾まで到達したかどうかを判断するのに役立つメソッドがあります。
using FileStream fs = File.OpenRead("test.txt");
int b;
// ReadByteはファイルの末尾に到達すると-1を返す
// ファイルの末尾まで読み込むためには、ループの条件は、戻り値が-1ではない間となる
while ((b = fs.ReadByte()) != -1)
{
Console.Write(b.ToString());
}
これを利用することによって、ファイルの末尾を越えて読み取ろうとしたときにスローされる例外を回避することができます。
その他にも、例外をスローする代わりにnullを返す実装方法も一般的によく使われています。
例外が起因でメソッドが完了しない場合は状態を復元すべし
複数の処置が実行されるメソッド内で、例外が発生した場合、一部の処理は実行が成功していて、一部の処理は例外によって処理が失敗しているということになるとアプリケーション内で状態の齟齬がおきます。
公式ドキュメントの例をそのまま引用すると、
ある口座から現金を引き出して別の口座に預金することで送金を行うコードがあって、預金の実行中に例外がスローされた場合、引き出しが有効のままにしておきたくはないはずです。
ということですね。
こういうケースの場合は、現金の引き出し処理もまるで成功しなかったような振る舞いを実装する必要があります。
コードもそのまま引用させていただきますが、以下のような感じ
private static void TransferFunds(Account from, Account to, decimal amount)
{
string withdrawalTrxID = from.Withdrawal(amount);
try
{
to.Deposit(amount);
}
catch
{
// 引き出しの処理もロールバックする
from.RollbackTransaction(withdrawalTrxID);
throw;
}
}
例外の再スローはスタックトレースを考慮すべし
例外が発生すると、スタックトレースというものが記録されます。スタックトレースには例外が発生したメソッドからその例外をキャッチしたメソッドまでの呼び出し階層が記録されています
スタックトレースのテンプレートは以下の通り
場所 メソッド名() 場所 ファイルパス:行 行番号
例外発生のデバッグに役立つスタックトレースですが、再スローする場合には注意が必要です
例えば、以下のようにキャッチした例外e
をそのままスローした場合は、再スロー前のスタックトレースの情報は消えてしまいます
public class ExceptionSample
{
/// <summary> コンストラクタ </summary>
public ExceptionSample()
{
try
{
this.ReThrowException();
}
catch (Exception e)
{
// スタックトレースをコンソールへ出力
Console.WriteLine(e.StackTrace);
}
}
private void ReThrowException()
{
try
{
this.ThrowException();
}
catch (Exception e)
{
// スタックトレースこのメソッドからの情報に書き換わるため、
// 大元のThrowException()の情報が含まれない
throw e;
}
}
private void ThrowException()
{
throw new Exception("例外が発生しました");
}
}
本来知りたいはずの例外発生箇所が適切に特定できなくなってしまいますね🤔
ただ、もちろん対策もあります
対策1: throw;ステートメントを使用する
先ほどのReThrowException
を以下のように書き換えます
private void ReThrowException()
{
try
{
this.ThrowException();
}
catch
{
// ここでただthrowするだけ
throw;
}
}
対策2: ExceptionDispathInfoクラスを使用する
System.Runtime.ExceptionServices.ExceptionDispatchInfo
クラスのCapture
メソッドとThrow
メソッドを使用する方法もあります
private void ReThrowException()
{
ExceptionDispatchInfo? edi = null;
try
{
this.ThrowException();
}
catch (Exception e)
{
// Captureメソッドを使用して例外情報を保持する
edi = ExceptionDispatchInfo.Capture(e);
}
// 例外情報が格納されている場合にThrowメソッドで再スローする
if (edi is not null) edi.Throw();
}
これで再スロー前の情報を含めたスタックトレースを上位へ伝播させることができます
To Be Continued...
公式ドキュメントの物量をあまり確認せずに走り出してしまいましたが、
意外と量が多かったので、今回は一旦この辺で絞めようと思います🙇
いつか続きを書きますので乞うご期待…