なぜ、例外の理解が重要なのか、その理由

この記事では、当社の新人エンジニア研修の参考に C# を解説します。

前回は、「カプセル化と情報隠蔽で部品の完成度を高める」について学びました。

今回は例外処理(Exception Handling)について解説します。例外は継承の仕組みを使っており、エラー処理を効率よく行うために重要です。

1. 例外(Exception)とは?

例外とは、プログラムが正常に続行できない状況が起こったときに発生するエラー通知です。たとえば「0で割った」「範囲外の配列アクセス」「null参照」などが挙げられます。これらのエラーを「例外オブジェクト」として発生させ、必要に応じてキャッチ(補足)して処理を行います。

例外の典型的な使い方は「アプリが落ちても、データはちゃんと保存してから終了」や「ユーザーにエラーメッセージを表示」などです。もし例外処理がなければ、エラーが起きた瞬間にアプリが強制終了してしまい、ユーザーデータも残らないなど悲惨なことになるかもしれません。

C#で典型的な例外には以下のようなものがあります:

  • DivideByZeroException
  • IndexOutOfRangeException
  • NullReferenceException
  • InvalidCastException
  • FormatException

いずれも実行時に起こります。

2. trycatchfinally

以下のような構文で例外を捕捉できます。

try
{
// 例外が発生する可能性のある処理
}
catch (例外の型1 変数名1)
{
// 例外の型1を捕捉した場合の処理
}
catch (例外の型2 変数名2)
{
// 例外の型2を捕捉した場合の処理
}
finally
{
// 例外の有無に関わらず必ず実行される処理
}


単純な例

using System;

public class Example01
{
    public static void Main()
    {
        try
        {
            Console.WriteLine(5 / 0);
        }
        catch (DivideByZeroException e)
        {
            Console.WriteLine("例外が発生しました");
            Console.WriteLine(e);
        }
        Console.WriteLine("このプログラムを終了します");
    }
}

<実行結果(例)>

例外が発生しました
System.DivideByZeroException: Attempted to divide by zero.
このプログラムを終了します
  1. try ブロック内で 5/0 が実行され、DivideByZeroException が投げられる
  2. catch (DivideByZeroException e) で捕捉し、「例外が発生しました」と表示。その後プログラムは正常に進行し、最後の行が実行されます。

finally ブロック

finally に書いた処理は例外の発生有無にかかわらず必ず実行されます。リソース解放や必須の後処理を置くのに便利です。

using System;

public class Example02
{
    public static void Main()
    {
        try
        {
            Console.WriteLine(int.Parse("NotAnInt"));
        }
        catch (FormatException e)
        {
            Console.WriteLine(e);
        }
        finally
        {
            Console.WriteLine("例外発生の有無に関わらず必ず実行したい処理です");
        }
        Console.WriteLine("このプログラムを終了します");
    }
}

<実行結果>

System.FormatException: Input string was not in a correct format.
例外発生の有無に関わらず必ず実行したい処理です
このプログラムを終了します

finallyに書いた行はエラーが起きても必ず実行。

3. 複数例外を順にキャッチ

複数の異なる例外が起こり得る場合、それぞれに対応したcatch節を並べます。

using System;

public class Example03
{
    public static void Main()
    {
        try
        {
            int[] numbers = { 1, 2, 3 };
            numbers[3] = 4;         // IndexOutOfRangeException
            Console.WriteLine(5 / 0); // DivideByZeroException
            Console.WriteLine(int.Parse("NotAnInt")); // FormatException
        }
        catch (IndexOutOfRangeException e)
        {
            Console.WriteLine($"Index問題: {e}");
        }
        catch (DivideByZeroException e)
        {
            Console.WriteLine($"割り算問題: {e}");
        }
        catch (FormatException e)
        {
            Console.WriteLine($"変換問題: {e}");
        }
        Console.WriteLine("このプログラムを終了します");
    }
}

最初に IndexOutOfRangeException が起きると、そこですぐ catch(IndexOutOfRangeException ...) にジャンプし、以降の行は実行されません。

4. usingステートメント

usingステートメントはIDisposableインターフェースを実装しているオブジェクトを自動的にDispose()で解放します。

using System;
using System.IO;

public class Example04
{
    public static void Main()
    {
        Console.Write("ファイル名を入力してください: ");
        string filePath = Console.ReadLine();
        try
        {
            using (StreamReader sr = new StreamReader(filePath))
            {
                string content = sr.ReadToEnd();
                Console.WriteLine(content);
            } // ここで自動的に sr.Dispose() が呼ばれる
        }
        catch (FileNotFoundException e)
        {
            Console.WriteLine($"ファイルが見つかりません: {e.FileName}");
        }
        catch (IOException e)
        {
            Console.WriteLine($"IOエラー: {e.Message}");
        }
    }
}


using(...) ブロックを抜けると自動で Dispose()が呼ばれます。

5. 例外クラスの体系

C#では「すべての例外は Exception の派生で、catchは任意」です。

try-catchは強制されません。try-catch を使うタイミングは「必要に応じて」設計者が判断する文化です

System.Exceptionクラス

すべての例外は System.Exceptionを継承しており、そこから派生した System.SystemException があります。具体的には System.DivideByZeroExceptionSystem.FormatException などが存在します。

System.Object
 └─ System.Exception
     └─ System.SystemException
          ├─ DivideByZeroException      (ゼロによる除算時の例外)
          ├─ IndexOutOfRangeException   (配列などで範囲外を参照した場合の例外)
          ├─ NullReferenceException     (null参照を使用した際の例外)
          ├─ InvalidCastException       (無効な型変換を行った場合の例外)
          └─ FormatException            (書式が不正な場合の例外)

throw キーワード

C#では、throwを使って例外を発生させることができます。

public static int ParsePositiveInt(string s)
{
    int num = int.Parse(s);
    if (num < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(s), "正の数を指定してください");
    }
    return num;
}

6. 例外を呼び出し元に投げる

C#では例外が起きたら勝手に上位メソッドへ伝播します。
throwを使うと「現在のスコープを抜けて、呼び出し元のメソッドへ例外を送る」動きになります。
もしキャッチしなければ更に呼び出し元へ伝播し、最終的にはランタイムが受け取ってアプリケーションが終了し、スタックトレースが表示されます。

public class Sample
{
    public Sample()
    {
        throw new Exception("Custom error in constructor");
    }
}

public class Example10
{
    public static void Main()
    {
        Sample s = new Sample(); // => ここで Exceptionが投げられ、キャッチしないとプログラム終了
    }
}

<実行結果(例)>

Unhandled exception. System.Exception: Custom error in constructor
at Sample..ctor() ...
at Example10.Main() ...

スタックトレースを見れば、どこで例外が発生したかが分かります。

7. 例外クラスを自作する

C#ではオリジナル例外を作れます。Exceptionクラスを継承して用途に合ったクラス名・追加情報を持たせることがあります。

public class MyCustomException : Exception
{
    public MyCustomException(string message) : base(message)
    {
    }
}

public class Example11
{
    public static void Main()
    {
        throw new MyCustomException("この例外はユーザー定義です");
    }
}

<実行結果>

Unhandled exception. MyCustomException: この例外はユーザー定義です
at Example11.Main() ...

<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ 例外(Exception)はプログラムの異常事態をオブジェクトとして表現する仕組み

□ try-catchで適切に例外を処理する

□ 例外を呼び出し元に投げることが可能であり、throwキーワードを使用する。メソッドの呼び出し元に処理を委ねることで、例外処理の責任を適切に分担できる。

□ スタックトレース情報は例外発生の原因を特定する重要な手掛かりである。Exception.StackTraceプロパティを用いてトレース情報を確認する。情報は上から下に読むのが基本であり、自作のクラスやメソッドを重点的に調査する。

□ 例外の処理方法は2つに大別される。

  1. try-catchでその場で処理する。特定の例外だけを処理する場合は、キャッチする例外型を指定する。
  2. 呼び出し元に例外を投げる。この場合、throwキーワードを使用し、メソッドの呼び出し側に処理を任せる。

usingステートメントを使用することで、リソースを明示的に閉じるコードを書く必要がなくなる。

□ 例外発生は握りつぶしてはいけない。最低限、例外情報をログに記録するべきである。ただし、学習中や研修中は、例外情報をコンソールに出力して内容を確認することを推奨する。

まとめができたら、アウトプットとして演習問題にチャレンジしましょう。

以上、今回は「例外処理で想定外の事態に強いシステムにする」方法について見てきました。

例外処理を使うことでプログラムはトラブルの発生をユーザーに伝えることができ、途中終了を避けることができるのでした。また、継承の仕組みを上手く応用しているのも興味深いところでしたね。

実際にお客様の依頼で開発するアプリでは、詳細なエラー情報は画面に表示しないことが多いのです。特に深刻なエラーが起こった場合はなおさらです。
知識が不十分なお客様をいたずらに不安にさせることになりますし、下手をすれば責任問題に発展し、会社同士のもめ事にもなりかねないからです。

次回は、「13章. Listでもっと配列を便利に使いこなす」です。

例外処理で想定外の事態に強いシステムにする 最後までお読みいただきありがとうございます。