4
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

C#で例外処理実装時に意識するべき3つのポイント

Posted at

C#での例外処理について学んだのでその中でも特に意識しておくべきことを挙げていきます。

1.リソースの使用時にはusingを使う

プログラム中でファイルやデータベースなど、他のアプリケーションと共有するようなリソースを使用する際にはそのリソースを開放する必要があります。
例えば、ファイルに書かれている内容を読み込むStreamReaderを使用した場合はClose()メソッドを呼び出す必要があります。
しかし、下記のような書き方をした場合ある問題が発生します。

Sample.cs
			string filePath = @"c:\sample.txt";

			StreamReader reader = null;
			try {
				reader = new StreamReader(filePath);

				// 読み込んだファイルに対して何かしらの処理を行う

				reader.Close();

			} catch(FileNotFoundException e) {
				Console.WriteLine(e.StackTrace);
			}

上記プログラムの問題点は読み込んだファイルに対して何かしらの処理を行うの部分で例外が発生した場合にreader.Close();が呼ばれないところです。
これを防ぐための1つの手段としてfinallyを使用することがあげられます。

Sample.cs

			string filePath = @"c:\sample.txt";

			StreamReader reader = null;
			try {
				reader = new StreamReader(filePath);

				// 読み込んだファイルに対して何かしらの処理を行う

			} catch(FileNotFoundException e) {
				Console.WriteLine(e.StackTrace);

			} finally {
				reader.Close();
			}

finallyブロックでClose()メソッドを呼び出すことで例外が発生しても確実に呼び出すことができます。
しかし、例外が発生した際にファイルをクローズする前に何かしら処理を行いたいということがなければこの書き方は冗長です。
C#ではusingメソッドを使用して下記のように省略した書き方が可能です。

Sample.cs
			string filePath = @"c:\sample.txt";

			StreamReader reader = null;
			try {
				using (reader = new StreamReader(filePath)) {

					// 読み込んだファイルに対して何かしらの処理を行う
				}
			} catch(FileNotFoundException e) {
				Console.WriteLine(e.StackTrace);
			}

usingの内部で生成されたオブジェクトはusingブロックを抜けるタイミングで自動的に破棄されます。これによって処理の途中で例外が発生しても確実にリソースを開放することができます。

ちなみに、Closeメソッドとusingで自動的にリソースを開放する場合でもどちらもDispose()メソッドを呼び出します。なのでDispose()メソッドを実装していないクラスのリソースをusingで取得しようとするとコンパイルエラーが発生します。
StreamReaderクラスはIDiposesableインターフェイスを実装しているのでusing内で使用することができるわけです。

2.例外フィルターを使ってマルチキャッチを行う

例えば指定されたパスに存在するcsvファイルを読み込み、カンマ区切りで2つ目に記述された文字を出力する処理を書く場合、少なくとも下記の3つの例外を考慮する必要があります。

  • 指定されたパスがnullか空文字("")
  • ディレクトリが存在しない
  • ディレクトリは存在するがファイルが存在しない
  • ファイルを読み込むことはできるが、カンマ区切りの2つ目が存在しない

これらをの例外を全て考慮したプログラムは下記のようになります。
(実際にここまで厳密にcatchするかは別問題です。)

sample.cs
		public void sample(string filePath) {

			StreamReader reader = null;

			try {
				using (reader = new StreamReader(filePath)) {
					string str = reader.ReadLine().Split(',')[1];
					Console.WriteLine(str);
				}
			} catch (ArgumentException ex) {
                // 引数がnullか空文字("")の場合に発生
				Console.WriteLine("ファイルを開けませんでした。");
				Console.WriteLine(ex.StackTrace);
			} catch (DirectoryNotFoundException ex) {
                // ディレクトリが見つからなかった場合に発生
				Console.WriteLine("ファイルを開けませんでした。");
				Console.WriteLine(ex.StackTrace);
			} catch (FileNotFoundException ex) {
                // ファイルが見つからなかった場合に発生
				Console.WriteLine("ファイルを開けませんでした。");
				Console.WriteLine(ex.StackTrace);
			} catch (IndexOutOfRangeException ex) {
                // CSVのフォーマットが不正な場合に発生
				Console.WriteLine("指定されたcsvファイルのフォーマットが不正です。");
				Console.WriteLine(ex.StackTrace);
			}
        }

ここで、ArgumentExceptionとFileNotFoundExceptionとDirectoryNotFoundExceptionは同じ処理をしています。
つまり本来は同じ処理を何度も書きたくないのに例外をキャッチするために仕方なく同じ処理を複数回書いている状態です。
これを回避する1つの方法としてExceptionをcatchで指定する方法があります。

sample.cs

		public void sample(string filePath) {

		    StreamReader reader = null;

			try {
				using (reader = new StreamReader(filePath)) {
					string str = reader.ReadLine().Split(',')[1];
					Console.WriteLine(str);
				}
			} catch(IndexOutOfRangeException ex) {
				Console.WriteLine("指定されたcsvファイルのフォーマットが不正です。");
				Console.WriteLine(ex.StackTrace);
			} catch(Exception ex) {
				Console.WriteLine("ファイルを開けませんでした。");
				Console.WriteLine(ex.StackTrace);
			}
        }

Exceptionをキャッチすることで同じ処理を2度書く必要はなくなりました。
しかし、こう書いてしまうと新たな問題が発生します。
それは例外処理の対処があいまいになってしまったり不適切な例外処理をしてしまうからです。
原則として例外をキャッチする際にはExceptionクラスではなくてExceptionクラスから派生した詳細な例外クラスを指定すべきです。

では結局のところ詳細に例外クラスを指定するために最初の例のように同じ処理でも複数回記述する必要があるのでしょうか?
これに対応するためにC#6以降では例外フィルターというものが使用できるようになり、catch文に例外の種類に加えて条件を指定できるようになっています。
これを使用して下記のようにマルチキャッチを行うことで上記の問題は解決できます。

sample.cs

		public void sample(string filePath) {

			StreamReader reader = null;

			try {
				using (reader = new StreamReader(filePath)) {
					string str = reader.ReadLine().Split(',')[1];
					Console.WriteLine(str);
				}
			} catch (IndexOutOfRangeException ex) {
				Console.WriteLine("指定されたcsvファイルのフォーマットが不正です。");
				Console.WriteLine(ex.StackTrace);
			} catch (Exception ex) when (ex is ArgumentException || ex is DirectoryNotFoundException || ex is FileNotFoundException) {
				Console.WriteLine("ファイルを開けませんでした。");
				Console.WriteLine(ex.StackTrace);
			}
		}

catch(Exception ex) の後ろにwhenで条件を指定することでその条件に一致した時のみ例外処理を行うことができます。
このように1つのcatch文で複数の例外を処理することをマルチキャッチと言います。
これによって例外処理の対象を明確にした上で同じ処理を複数回記述する必要がなくなります。

3.例外処理のオーバーヘッドに注意する

基本的には意識する程ではないのですが、例外処理はオーバーヘッドが大きく、レスポンスに影響を及ぼす可能性がある処理です。
レスポンスに問題がある場合、まず見直すのはデータベースやネットワーク関係ですが、それでも解決できない場合はプログラムの処理を見直す必要が出てきます。
その際には例外処理が頻発していないかをチェックすべき1つのポイントになります。
ここでは、例外処理を頻発させないための実装パターンを2つ紹介します。

1.Tester-doerパターン

これは実行できるかどうかテストをしてから実行するパターンです。
例えば、Dictionaryクラスでは存在しないキーを指定するとKeyNotFoundExceptionが発生します。

sample.cs
		public void sample() {

			var myDictionary = new Dictionary<string, string>() {
				["リンゴ"] = "Apple",
			    ["バナナ"] = "Banana",
				["ミカン"] = "Orange"
			};
			try {
				Console.WriteLine(myDictionary["パイナップル"]);
			} catch(KeyNotFoundException ex) {
				Console.WriteLine("指定されたキーに対応する値は存在しません。");
				Console.WriteLine(ex.StackTrace);
			}
		}

上記の例だとConsole.WriteLine(myDictionary["パイナップル"]);で例外が発生してcatchブロックへ飛びます。
これが頻発すると処理の遅延が発生する可能性があるので事前に「パイナップル」というキーが存在するかチェックしてから処理を行うのがTester-doerパターンです。
今回例で使用しているDictionaryクラスの場合は下記のように書き換えることができます。

sample.cs
		public void sample() {

			var myDictionary = new Dictionary<string, string>() {
				["リンゴ"] = "Apple",
				["バナナ"] = "Banana",
				["ミカン"] = "Orange"
			};
			string value;
			if (myDictionary.TryGetValue("パイナップル", out value)) {
				Console.WriteLine(value);
			} else {
				Console.WriteLine("指定されたキーに対応する値は存在しません。");
			}
		}

TryGetValue()メソッドは第一引数で指定したキーが存在するかをtrue/falseで判定するメソッドです。キーが存在する場合は第2引数で指定した変数に対応する値を代入してくれます。
ContainsKey()メソッドを使用してチェックもできますが、TryGetValueを使うほうが効率がいいそうです。こちらのサイト様で詳しく検証されていました。

このように処理を実行しても例外が発生しないか事前に確認し、例外が発生する場合はelseで処理
することでtry-catch構文にする必要がなくなり、処理効率が上がります。

Try-parseパターン

このパターンはParseメソッドを呼び出して変換を行う際に使われるパターンです。
例えば、下記の例は文字列クラス⇒日付クラスに変換する処理です。
時間に0~24以外の数値を指定しているので例外が発生します。

		public void sample() {

			var date = "2019/1/5 25:06:30";
			try {
				Console.WriteLine(DateTime.Parse(date));
			} catch (FormatException ex) {
				Console.WriteLine("フォーマットが不正です");
				Console.WriteLine(ex.StackTrace);
			}
		}

これを実行前に事前に判定しようとしても判定しようとした時点で例外が発生するので事前のチェックができません。
よって、処理の失敗は避けられないのですが、処理に失敗しても例外を返さないメソッドを使用することで例外による遅延を防ぐことができます。
今回の例だとTryParse()メソッドを使用すれば変換できない際に例外ではなくfalseを返すのでif-elseで処理することができます。

sample.cs
		public void sample() {

			var date = "2019/1/5 25:06:30";
				DateTime dt;
			if (DateTime.TryParse(date, out dt)) {
				Console.WriteLine(dt);
			}else{
				Console.WriteLine("フォーマットが不正です");
			}
		}

このように、安全のためにとにかくtry-catchで囲むのではなく、そもそも例外が発生しないように設計することでレスポンスを向上させることができるかもしれません。

#まとめ

  • ファイルなどの共有リソースを開放する際の注意点
    • 開放前にやりたい処理があるならfainallyでClose()を呼び出して開放する
    • そういうものがなければusingを使用して自動で開放する
  • catchによる例外処理の注意点
    • Exceptionをcatchすると例外処理の対象があいまいになるので原則使用しない
    • 複数の例外で同じ処理を行いたい場合は例外フィルターを使用してマルチキャッチを行う
  • そもそも例外はなるべく発生させないようにする
    • 事前にチェック可能な場合はTester-doerパターンを使用する
    • 事前のチェックが不可能な場合はTry-parseパターンを使用する
4
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?