例外

実行時エラーと例外処理

プログラミングでの「エラー」にはおおまかに二種類存在します。
ひとつは「文法上のエラー」で、C#の言語仕様上許されていないコードを書くことで起こります。
文法エラーはコンパイルができませんし、Visual Studioなどの開発環境ならばコードの記述時に間違いの箇所を示してくれるので、修正は容易です。

もうひとつは「実行時エラー」です。
これは文法上の間違いはなくコンパイルもできますが、プログラムの実行時に何らかの意図しない処理が発生し、プログラムが続行不可能になることを言います。
実行時エラーが発生するとその時点でプログラムは強制終了してしまいます。


//意図的に実行時エラーを起こす例

int[] arr = new int[] { 0, 1, 2 };

//ここでエラー発生
Console.WriteLine(arr[arr.Length]);

上記のコードは要素数3の配列に対して3番目の要素を取り出そうとしています。
配列の添え字は「0」から始まるので、最後の要素番号は「配列サイズ - 1」です。
つまりこれは「配列の有効範囲外へのアクセス」という実行時エラーになります。

実行時エラーは例外といい、エラーが起こることを例外が発生する例外がスローされると言います。
例外が発生した時にエラーに適切に対処し、プログラムを停止させないようにすることを例外処理といいます。

例外処理の基礎

try~catch文

例外処理はtry~catch文を用いて行います。


int[] arr = new int[] { 0, 1, 2 };

try
{
   Console.WriteLine(arr[arr.Length]);
   Console.WriteLine("tryブロック終了");
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}
Console.WriteLine("プログラム終了");
インデックスが配列の境界外です。
プログラム終了

tryブロックとcatchブロックはセットで使用します。

tryブロック内には例外が発生する可能性のあるコードを記述します。
実際に例外が発生すると、その時点でコードの処理はcatchブロックに移ります。
残りのtryブロックのコードは実行されません。
(サンプルコードの6行目は実行されない)

catchブロックには引数のようなものが存在します。
Exceptionというのは例外情報を扱う専用のデータ型(クラス)です。
tryブロック中で例外が発生すると、発生したエラーの情報がこの引数に格納されます。
(詳しくは後述)

ExceptionクラスにはMessageプロパティがあります。
これには発生した例外の具体的な内容が文章で格納されています。
配列の有効範囲外へのアクセスならば「インデックスが配列の境界外です。」という文章です。
(インデックスとは配列の添え字のことです)

例外をcatch文で処理をするとプログラムは停止せずに実行が続けられます。
12行目のWriteLine関数は実行されていることがわかります。

tryブロック内で例外が発生しなければcatchブロックは実行されません。


int[] arr = new int[] { 0, 1, 2 };

try
{
   Console.WriteLine(arr[arr.Length - 1]);
   Console.WriteLine("tryブロック終了");
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}
Console.WriteLine("プログラム終了");
2
tryブロック終了
プログラム終了

catchブロック内でExceptionの情報を使用しない場合は省略可能です。


try
{
	//例外が発生する処理
}
catch(Exception e)
{
    Console.WriteLine("例外発生");
}

//「e」を使用しないなら省略可能
//↓

try
{
	//例外が発生する処理
}
catch(Exception)
{
    Console.WriteLine("例外発生");
}

//何も書かないと
//「Exception」と記述するのと同じ意味
//(すべての例外をキャッチする)
//↓

try
{
	//例外が発生する処理
}
catch
{
    Console.WriteLine("例外発生");
}

throw

もう少し複雑な例をみてみます。

配列の範囲外アクセスを回避する程度ならば例外処理ではなく単純に配列のサイズと添え字のチェックをすれば済みます。


int[] arr = new int[] { 0, 1, 2 };
int index = 3;

if(index < 0 || index >= arr.Length)
    Console.WriteLine("配列の範囲外アクセスです。");
else
    Console.WriteLine(arr[index]);

これを踏まえて、「引数で受け取った添え字の要素を返す関数」を考えてみます。


static int Func(int index)
{
    int[] arr = new int[] { 1, 2, 3 };

    if (index < 0 || index >= arr.Length)
        return -1;

    return arr[index];
}

static void Main(string[] args)
{
    Console.WriteLine(Func(3));
    Console.ReadLine();
}
-1

引数の値が有効範囲外の場合は「-1」を返すことにしています。
しかし、この決まりはこのコードを書いた本人が勝手に決めたことですし、何よりも「-1」が返ってきたときに、関数内でエラーが発生したのか、それとも正常終了した上で配列の要素である「-1」が返ってきたのか、判断がつきません。

Array.IndexOfメソッドなどはその原理上、正常終了時に「0未満」の値が返ってくることはあり得ないので、マイナスの戻り値はエラーを意味する、という風に使用することは可能です。

これを解決するには、戻り値はエラーの有無を示すために使用し、関数の処理結果は引数の参照渡しを利用して呼び出し元に渡す、という方法があります。
文字列を数値に変換するTryParseメソッドなどがこの方法を採用しています。

もうひとつの解決方法は、例外処理で対応することです。


//例外を返す可能性がある関数
static int Func(int index)
{
    int[] arr = new int[] { 1, 2, 3 };

    try
    {
        return arr[index];
    }
    catch
    {
        throw;
    }
}

static void Main(string[] args)
{
    //関数Funcは例外が発生する可能性があるので
    //try文を使用する
    try
    {
        Console.WriteLine(Func(3));
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
    Console.WriteLine("プログラム終了");
    Console.ReadLine();
}
インデックスが配列の境界外です。
プログラム終了

自作関数Func内では、範囲外アクセスが発生した場合の処理にthrowというキーワードを指定しています。
これは「例外が発生した時、その例外をそのまま呼び出し元に渡す」という機能があります。
関数内でthrowが実行されると、その時点で関数の処理は終了します。

ちなみにthrow文を実行することを「例外をスローする(例外を投げる)」といいます。
スローされた例外をcatch文で処理することを「例外をハンドルする」「例外をキャッチする」「例外を捕捉する」といいます。

関数Funcの呼び出し元であるMain関数側では、try文の中で関数Funcを実行します。
そうすると関数Funcの実行中に例外が発生した時、プログラムを停止させずに処理を続行することができます。
関数呼び出し側からは、関数の実行の成否は例外が発生するか否かで判断できます。

try~finally文

catchブロックはtryブロック内で例外が発生した時に実行されますが、finallyは例外発生の有無にかかわらず必ず実行されます。
finallyはcatchと共に使用することもできますし、単独でも使用できます。


try
{
    //例外が発生する可能性のあるコード
}
finally
{
	//例外発生の有無に関わらず実行されるコード
}

try
{
    //例外が発生する可能性のあるコード
}
catch
{
    //例外発生時に実行されるコード
}
finally
{
	//例外発生の有無に関わらず実行されるコード
}

finally文はリソースの破棄に使用されることが多いです。
例えば「ファイルを開き、読み書きして、ファイルを閉じる」という処理です。

ファイル処理についての詳細はFileStreamクラスを参照してください。


//よくない例
static void Test()
{
    string path = "test.txt";
    string text = null;
    
    System.IO.StreamReader sr = null;
    try
    {
        sr = System.IO.File.OpenText(path);
        text = sr.ReadToEnd();
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
        return;
    }
    //これより下のコードは
    //実行されないかもしれない
    
    if (sr != null)
        sr.Close();
    
    Console.WriteLine(text);
}

File.OpenTextメソッドで開かれたファイルは、File.Closeメソッドで閉じる必要があります。
上記のコードの場合、ファイルの読み取り中に何らかの例外が発生すると処理はcatchブロックに移ることになりますが、ここでreturn文などによって処理が別の箇所に飛ばされると、File.Closeメソッドが呼ばれないままになってしまいます。

finally句を用いると、例外が発生しようがしまいが必ず最後にfinallyブロック内の処理が実行されるので、ファイルの閉じ忘れがなくなります。


static void Test()
{
    string path = "test.txt";
    string text = null;
    
    System.IO.StreamReader sr = null;
    try
    {
        sr = System.IO.File.OpenText(path);
        text = sr.ReadToEnd();
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
        return;
    }
    finally
    {
        if (sr != null)
            sr.Close();
    }
    Console.WriteLine(text);
}

usingステートメント

IDisposableなクラスのDisposeメソッドの呼び出しをtry~finally文で確実に実行する、という場合はusingステートメントの使用が推奨されます。


static void Test()
{
    string path = "test.txt";
    string text = null;

    using (var sr = System.IO.File.OpenText(path))
    {
        text = sr.ReadToEnd();
    }
    Console.WriteLine(text);
}

これは以下と同じ意味になります。


static void Test()
{
    string path = "test.txt";
    string text = null;

    var sr = System.IO.File.OpenText(path);
    try
    {
        text = sr.ReadToEnd();
    }
    finally
    {
        if (sr != null)
            sr.Close();
    }

    Console.WriteLine(text);
}

usingステートメントではcatch句は生成されないので、例外を捕捉するにはusingブロック内でtry~catch文を使用します。


static void Test()
{
    string path = "test.txt";
    string text = null;
    
    using (var sr = System.IO.File.OpenText(path))
    {
        try
        {
            text = sr.ReadToEnd();
        }
        catch(Exception e)
        {
            Console.WriteLine(e.Message);
        }
    }

    Console.WriteLine(text);
}

例外の伝播(伝搬)

ある関数内で例外がスローされたとき、その時点でその関数の実行は停止し、呼び出し元に処理が移ります。
呼び出し元の関数ではさらに上位の呼び出し元関数に例外をスローすることができます。


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApplication1
{
    class Program
    {
        static string Func1()
        {
            object obj = new int[] { 0, 1, 2 };
            string str;

            try
            {
                //キャストできない(例外発生)
                str = (string)obj;
            }
            catch
            {
                throw;
            }
            return str;
        }

        static string Func2()
        {
            try
            {
                return Func1();
            }
            catch
            {
                //Func1で例外がスローされた場合
                //そのまま再スローする
                throw;
            }
        }

        static void Main(string[] args)
        {
            try
            {
                Console.WriteLine(Func2());
            }
            catch (Exception e)
            {
                //最終的にここで処理される
                Console.WriteLine(e);
            }
            Console.WriteLine("プログラム終了");
            Console.ReadLine();
        }
    }
}
System.InvalidCastException: 型'System.Int32[]'のオブジェクトを型'System.String'にキャストできません。
 場所 ConsoleApplication1.Program.Func1() 場所 C:¥xxx¥ConsoleApplication1¥Program.cs:行23
 場所 ConsoleApplication1.Program.Func2() 場所 C:¥xxx¥ConsoleApplication1¥Program.cs:行38
 場所 ConsoleApplication1.Program.Main(String[] args) 場所 C:¥xxx¥ConsoleApplication1¥Program.cs:行46
プログラム終了

これを例外の伝播(伝搬)例外の再スローリスロー再送出、などといいます。
スローされた例外はどこかでキャッチされるまで上位に再スローされ続けます。
どこにもキャッチされなければプログラムは停止します。

なお、今回はExceptionに含まれる情報をMessageプロパティを使用せず直接Console.WriteLineメソッドに渡しています。
表示メッセージを見ると、例外が発生した行が順番に表示されていることがわかります。

このように、どこでどのようなエラーが発生したのかを順に記録されている情報をスタックトレースといいます。
この情報を頼りにしてエラーの原因を修正していきます。

例外を自作する

今まではC#がスローする例外に対する処理をしていましたが、例外は自分で作ることができます。


static int Func(int index)
{
    int[] arr = new int[] { 1, 2, 3 };

    //引数の値をチェックし、範囲外の場合に例外を発生させる
    if (index < 0 || index >= arr.Length)
        throw new Exception("引数の値が不正です。");

    return arr[index];
}

static void Main(string[] args)
{
    try
    {
        Console.WriteLine(Func(3));
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
    Console.WriteLine("プログラム終了");
    Console.ReadLine();
}
引数の値が不正です。
プログラム終了

throw new Exception("メッセージ")と記述することで、任意のメッセージ情報を持つ例外を発生させることができます。
上記コードでは、引数の値が想定する範囲外であった場合に例外を発生させています。

例外の種類

例外が発生する理由はいろいろありますが、今まではすべてExceptionクラスを使用して例外処理をしてきました。
Exceptionクラスはすべての例外クラスの大元となるクラスで、これを使用すればすべての例外を捕捉することができます。

しかし、例外が発生する理由が異なるということは適切な対処方法も異なるということです。
Exceptionクラスで捕捉してしまうとエラーの種類によって処理を分けることができないので、エラーによって使用するクラスを変える必要があります。


static string ReadTextFile(string path)
{
    string text = null;
    if(string.IsNullOrEmpty(path))
        return string.Empty;

    try
    {
        text = System.IO.File.ReadAllText(path);
    }
    catch (ArgumentException)
    {
        //引数の値が不正
        Console.WriteLine("'" + path + "'は無効なファイルパスです。");
        return string.Empty;
    }
    catch (System.IO.PathTooLongException)
    {
        //引数の文字列が長すぎる
        Console.WriteLine("パスが長すぎます。");
        return string.Empty;
    }
    catch (System.IO.FileNotFoundException)
    {
        //ファイルが存在しない
        Console.WriteLine("'" + path + "'は存在しません。");
        return string.Empty;
    }
    catch (Exception)
    {
        //その他のエラー
        //予期しないエラーは
        //上位の処理にまかせる
        throw;
    }

    return text;
}

static void Main(string[] args)
{
    string text = null;
    while (true)
    {
        Console.Write("開くテキストファイル名を入力: ");
        string path = Console.ReadLine();
        
        try
        {
            text = ReadTextFile(path);
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
            //予期しないエラーはそのまま続行せず
            //プログラムを終了させる
            Console.WriteLine("プログラムを終了します。");
            Console.ReadLine();
            Environment.Exit(0);
        }

        if(string.IsNullOrEmpty(text))
        {
            Console.WriteLine("正しいファイル名を入力してください。\n");
            continue;
        }
        break;
    }
    Console.WriteLine(text);
    Console.ReadLine();
}

9行目のSystem.IO.File.ReadAllTextメソッドは引数に指定されたテキストファイル読み取りをstring型で返すメソッドです。
詳しくはテキストファイルの読み書きを参照してください。

ファイルというのは他のプログラムからも読み書き可能なため、ファイル操作は比較的エラーが起きやすい処理です。
エラーの原因もいくつか存在し、スローされる例外クラスもひとつではありません。

catch句では引数に捕捉したい種類の例外クラスを記述することで、特定の例外のみをキャッチすることができます。
言い換えれば、捕捉したくない例外は無視することができます。
(今まではExceptionクラスを使用していたため、すべての例外をキャッチしていた)

catch句は複数記述することができ、発生した例外ごとに個別に処理を分けることができます。
tryブロック内で例外が発生した時、catch句はif文のように上から順に指定した例外に合致するか否かを判定し、最初に合致したcatchブロック内の処理が実行されます。
つまり、すべての例外を捕捉するExceptionクラスは一番最後に書かないと、以降のcatch句は無意味になってしまいます。


try
{
    //例外が発生する可能性のある処理
}
catch (Exception)
{
    //すべての例外がここで捕捉される
}
catch (ArgumentException)
{
    //このcatchブロックが実行されることはない
}

「捕捉したくない例外は無視できる」と書きましたが、例外は例外処理をしないとプログラムが停止します。
できる限りプログラムは停止しないようにすべきですが、起こり得るすべての例外に対応することは現実的ではありません。
(サンプルコードのファイルの読み取りも、ここまで例外処理をしなくても大概は大丈夫)
そのため、例外が発生する「可能性が高い」処理に対して、発生が予想される例外の処理をしておき、それ以外の例外が発生した場合はプログラムを停止させてしまった方が良いでしょう。

例外処理の本来の目的は「プログラムを止めない」のではなく「エラーを修正する」ことです。
「プログラムを止めない」のはエラー修正による副次的な効果です

Exceptionクラスは深刻なエラーであっても捕捉してプログラムを続行させることができます。
しかしそれでエラーが修正されるわけではないので、そのままプログラムを続行すると例えば間違った計算結果を(間違いとは気づかない形で)出力してしまうとか、ファイルを壊してしまうとか、より深刻なバグが発生する可能性があります。
しかもプログラム自体は停止せずにそのまま動くのだからバグの発見が困難になります。
それよりはプログラムが落ちてくれた方が幾分マシです。
(このような適当な例外処理を「例外を握りつぶす」などといいます)

そのため、Exceptionクラスは使用しないか、使用する場合はエラーログを書き出す程度の処理に留め、すぐにプログラムを停止させるのが望ましいです。
プログラムを停止させるには、Main関数内でreturn文を実行するほか、Main関数以外から即座に終了させたいならばEnvironment.Exitメソッドを実行しても良いです。
Environment.Exitメソッドの引数はOS(ウィンドウズなど)に渡す終了コード(エラーコード)ですが、通常は「0」で構いません。

よく使用される例外一覧

ここではよく使用される(発生する)例外をいくつか紹介しておきます。

例外クラスはExceptionクラスを元とした派生クラスとなっており、そこからさらに派生して作られているクラスもあります。
最上位であるExceptionクラスがすべての例外に対応するのと同じように、「上位クラスはその下位クラスの例外を捕捉する」と考えてください。
一段右にずらして記述されているクラスは派生クラスであることを意味します。

クラスの派生などについては継承を参照してください。

クラス名 説明
System.ArgumentException 不正な引数が渡された場合にスローされる例外
System.ArgumentNullException 引数にnullを受け付けないメソッドにnullが渡された場合にスローされる例外
System.ArgumentOutOfRangeException 引数に渡された値が有効な範囲外の場合にスローされる例外
System.ArithmeticException 算術演算、キャスト演算、または変換演算におけるエラーが原因でスローされる例外
System.DivideByZeroException 0で除算しようとした場合にスローされる例外
System.NotFiniteNumberException 小数値が無限大、またはNaN(Not a Number: 非数)の場合にスローされる例外
System.OverflowException checked文中でオーバーフローが発生した場合にスローされる例外
System.FormatException 引数の形式が無効な場合にスローされる例外
System.IndexOutOfRangeException 配列またはコレクションの有効範囲外にアクセスしようとした場合にスローされる例外
System.InvalidCastException キャストに失敗した場合にスローされる例外
System.InvalidOperationException 無効なメソッドの呼び出しが行われた場合にスローされる例外
System.NotImplementedException メソッドが未実装の場合にスローされる例外
System.NotSupportedException 呼び出されたメソッドがサポートされていない場合、または呼び出された機能を備えていないストリームに対して読み取り、シーク、書き込みが試行された場合にスローされる例外
System.NullReferenceException nullオブジェクトを逆参照しようとした場合にスローされる例外
要するに中身がnullの変数を操作しようとした場合
System.ObjectDisposedException すでに破棄(Disposed)されたオブジェクトを操作しようとした場合にスローされる例外
System.PlatformNotSupportedException 特定のプラットフォームで機能が実行できない場合にスローされる例外
System.TimeoutException 時間が掛かる処理で時間切れ(タイムアウト)した場合にスローされる例外
System.IO.IOException I/Oエラーが発生した時にスローされる例外
I/OとはInput/Outputの略で、ファイルなどのデータの入出力の操作をいう
System.IO.DirectoryNotFoundException ファイルまたはディレクトリの一部が見つからない場合にスローされる例外
System.IO.EndOfStreamException ストリームの末尾を越えて読み取ろうとした場合にスローされる例外
System.IO.FileNotFoundException 存在しないファイルにアクセスしようとした場合にスローされる例外
System.IO.PathTooLongException パス名の長さがシステムの最大文字数を超えている場合にスローされる例外
パスとはディスク上のファイルの場所を示す文字列(例えば「C:\aaa\bbb\ccc.txt」など)
System.Collections.Generic.KeyNotFoundException コレクションに存在しないキー名を指定した場合にスローされる例外

Visual Studioでの例外クラスの確認方法

上に掲載した以外にも例外クラスはたくさん存在します。
あるメソッドがどのような例外を返すかはVisual Studioならば簡単に確認することができます。

まず、例外を確認したいメソッドを記述します。
メソッドがスローする例外の確認1

この時点でメソッド名にマウスポインタを乗せると、メソッドの説明パネルが表示されます。
ここにスローされる例外情報も載っています。
メソッドがスローする例外の確認2

さらにメソッド名を右クリックし、「定義に移動」を選択します。
もしくはメソッド名をクリックして「F12」キーを押下します。
メソッドがスローする例外の確認3

するとタブが切り替わり、そのメソッドが定義されているコードに移動します。
(このコードは読み取り専用で、編集できません)
メソッドがスローする例外の確認4
目的のメソッドが選択状態になっているので、その左端にある「+」記号をクリックします。

すると折りたたまれていたコメント行が展開されます。
コメントにはメソッドに関する情報が書かれており、そこにスローされる例外情報が示されています。
メソッドがスローする例外の確認5

ただしコメント行が書かれていないメソッドもあります。
その場合はマイクロソフトのヘルプなどで確認する必要があります。

ちなみにVisual Studioでは例外処理をせずに例外を発生させると以下のように表示されます。
メソッドがスローする例外の確認6
ここにも発生した例外の種類が記されているほか、対処方法も提示してくれます。

catch句をまとめる

catch句を複数使用することで複数の例外を捕捉することができますが、catchブロック内の処理は結局同じ、ということがあります。
その場合はコードをコピー&ペーストしても良いのですが、同じコードを何度も記述するのは無駄です。


try
{
    //例外が発生する可能性のあるコード
}
catch (System.IO.DirectoryNotFoundException e)
{
    Console.WriteLine(e.Message);
    return;
}
catch (System.IO.FileNotFoundException e)
{
    Console.WriteLine(e.Message);
    return;
}
//↑どっちも同じ処理

このような場合はwhenを使用するとcatchブロックをひとつにまとめることができます。


try
{
    //例外が発生する可能性のあるコード
}
catch (System.IO.IOException e) when(e is System.IO.DirectoryNotFoundException || e is System.IO.FileNotFoundException)
{
    Console.WriteLine(e.Message);
    return;
}

System.IO.IOExceptionSystem.IO.DirectoryNotFoundExceptionSystem.IO.FileNotFoundExceptionの派生元クラス(親クラス)ですから、どちらの例外が発生した場合でも捕捉することができます。
その上で、実際に捕捉した例外をwhen句とis演算子を使用してフィルターします。
System.IO.IOExceptionにはこの二つ以外にも例外クラスが派生していますが、それらの例外がスローされた場合は捕捉されません。

通常は同じ例外クラスをcatch句に書くことはできませんが、when句を使用する場合はwhen句内の例外クラスが重複しない限りは同じ例外クラスを記述できます。


try
{
    //例外が発生する可能性のあるコード
}
catch (System.IO.IOException e) when(e is System.IO.DirectoryNotFoundException || e is System.IO.FileNotFoundException)
{
    //
}
catch (System.IO.IOException e) when(e is System.IO.EndOfStreamException)
{
    //
}
//「System.IO.IOException」は重複しているが
//when句内の例外クラスは重複していないのでOK