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

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

前回は、「多態性」について学びました。

今回は例外(Exception)と、例外処理(Exception Handling)について解説します。例外は、主にエラーの発生を呼び出し元に知らせる目的で使われるオブジェクトです。字面だけ見るとなにやらとっつきにくい印象ですが、仕組みがわかれば大変便利でありがたい存在なのです。ではさっそく、学習を始めましょう。

1. 例外(Exception)とは?

例外を一言で言えば、メソッドの呼び出し順序を飛び越して情報を伝える通知のことです。メソッドの呼び出し順序を飛び越す、とはどういうことなのかを、まずは解説しましょう。

皆さんがこれまで学んできたように、アプリケーションは複数のクラスで構成されていて、各クラスのメソッドが、自クラスや他クラスのメソッドを呼び出し、そのメソッドがまた別のメソッドを呼んで...というように、いくつものメソッドを経由して処理が進んでいきます。また、メソッドが返せる戻り値は一つだけでデータ型も決まっており、その戻り値も自分を呼び出したメソッドにしか渡すことができません。

あるファイルからデータを読み出す処理を想像してください。この処理を行う為には5つのメソッドが必要だとします。では、呼び出し順序が四つ先の深い階層にあるメソッドの処理中に致命的なエラーが起こったらどうすればいいでしょうか。

そのメソッドでは、ファイルと通信するためのオブジェクトを返すことになっていました。しかし、あるはずのファイルが見付からず、通信オブジェクトを作ることができませんでした。

一つ考えられる方法としては、エラーが起こったメソッドがnullを返せば異常を知らせることはできます。しかし、呼び出し元にエラーを伝えるには三番目と二番目のメソッドもnullのバケツリレーをしなければなりません。しかも戻り値がnullだという情報しか有りませんから、呼び出し元のメソッドでも何が起こったのかわからず、具体的な対処ができません。

このような事態に力を発揮するのが例外です。例外はオブジェクトですから、どんなエラーが起こったのかという情報を持てるようになっています。エラーが起きたメソッドは「ファイルが見付からないよ!誰かなんとかして!」と情報を詰めた例外を投げることが出来るのです。

例外が投げられたらアプリケーションは直ちに処理を中断します。それからメソッドの呼び出し順序を飛び越して、「その例外が投げられたら私が受け止めます!」と宣言しているメソッドの例外処理を即座に実行することになっています。

こういう仕組みなら、致命的なエラーが起こっても、どんな不具合が出たのかを呼び出し元に知らせられますし、できうる限りの対処も可能になりますね。これが例外と、例外処理の概要です。

2. 例外処理の単純な例trycatchfinally

それでは、実際の例外処理を見てみましょう。まずは、単純な例からです。以下のtry-catch-finally構文を使えば、「例外を受け止めます」という宣言と、例外処理を実行することができます。

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


単純な例

namespace Chap14;

using System;

public class Example01 {
    public static void Run() {
        try {
            // 例外が発生する可能性のある処理
            int div = 0;
            Console.WriteLine(5 / div);

        } catch (DivideByZeroException ex) {
            // ゼロ除算例外を捕捉した場合の処理
            Console.WriteLine("例外が発生しました");
            Console.WriteLine(ex);
        }
        Console.WriteLine("このプログラムを終了します");
    }
}

<実行結果(例)>

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

finally ブロック

finally に書いた処理は例外の発生有無にかかわらず必ず実行されます。ファイル入出力やデータベース接続などのリソース解放や、例外に関わらず必ず実行すべき必須の後処理を記述するブロックです。

namespace Chap14;

using System;

public class Example02 {
    public static void Run() {
        try {
            // 数字では無い文字列を数値化することで例外発生
            Console.WriteLine(int.Parse("数値ではありません"));

        } catch (FormatException ex) {
            // 例外発生をコンソールに出力
            Console.WriteLine(ex);

        } finally {
            Console.WriteLine("例外発生の有無に関わらず必ず実行したい処理です");
        }
        Console.WriteLine("このプログラムを終了します");
    }
}

<実行結果>

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

finallyに書いた処理は例外の有無に関わらずtryブロックの後に必ず実行されることを覚えておいてください。

3. 一般的な例外

では、C#言語が持つ代表的な例外の種類を見ていきましょう。C#の全ての例外は、System.Exceptionを親クラスとするオブジェクトです。また、例外が発生する可能性があるメソッドでもJavaのように例外を明示的に宣言する必要はありませんし、例外が発生する可能性がある処理を実行する場合もtry文を利用するか否かは開発者に任されています。

これは一見楽ができそうに見える言語仕様ですが、try文を使わずに処理が書けるからといって、例外処理を省略して良いということでは、もちろんありません。開発者に任されているからこそ、開発する側は責任を持って要所にきちんと例外処理を書くべきです。

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

  • DivideByZeroException ゼロ除算例外 - ゼロで除算を行った場合に発生
  • IndexOutOfRangeException 配列やリストの存在しない要素への参照
  • NullReferenceException null参照例外 - null のオブジェクトを操作しようとしたときに発生する例外
  • InvalidCastException 不正キャスト例外 - 互換性のない型へキャストした場合の例外
  • FormatException フォーマット例外 - 変換できない文字列を数値などに変換しようとした場合に発生

いずれも実行時に起こります。これらの例外は、System.SystemExceptionが直接の親クラスです。

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

このほか、本当に致命的で深刻な障害が発生した場合には、try文では対処すべきでは無いとされる例外もあります。

  • StackOverflowException スタックオーバーフロー
  • OutOfMemoryException メモリ不足 - 回復は困難ですが、一応try文でcatchすることは可能です
  • AccessViolationException不正なメモリアクセス

これらの例外は、catchしたところでアプリケーション側ではどうにもできない事態であることを示しています。なので、無理にtry-catch文で処理を継続させようとせずに、安全にアプリケーションを終了し、できれば詳細な記録を残すという対応に止めましょう。

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

次に、複数の例外が起こり得る場合に書くtry-catch-finally構文を見てみましょう。

namespace Chap14;

using System;

public class Example03 {
    public static void Run() {
        try {
            int[] numbers = { 1, 2, 3 };
            int dev = 0;
            numbers[3] = 4;         // IndexOutOfRangeException
            Console.WriteLine(5 / dev); // DivideByZeroException
            Console.WriteLine(int.Parse("数値ではありません")); // FormatException

        } catch (IndexOutOfRangeException ex) {
            Console.WriteLine($"Indexが不正です: {ex}");

        } catch (DivideByZeroException ex) {
            Console.WriteLine($"割り算が不正です: {ex}");

        } catch (FormatException ex) {
            Console.WriteLine($"変換が不正です: {ex}");
        }

        Console.WriteLine("このプログラムを終了します");
    }
}

このサンプルには、tryブロックに3つの例外が発生するコードがあります。最初に IndexOutOfRangeException が起きると、すぐ catch(IndexOutOfRangeException ...) にジャンプし、以降の行は実行されません。10行目、11行目と順番にコメントアウトして、それぞれの例外処理が実行されるのを確認してみましょう。

5. usingステートメント ~ リソースの自動開放 ~

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

namespace Chap14;

using System;

public class Example04 {
    public static void Run() {
        Console.Write("ファイル名を入力してください: ");
        string filePath = Console.ReadLine();
        try {
            // tryブロックにusingステートメントを書く
            using (StreamReader sr = new StreamReader(filePath)) {

                string content = sr.ReadToEnd();
                Console.WriteLine(content);

            } // ここで自動的に sr.Dispose() が呼ばれる

        } catch (FileNotFoundException ex) {
            Console.WriteLine($"ファイルが見つかりません: {ex.FileName}");

        } catch (IOException ex) {
            Console.WriteLine($"IOエラー: {ex.Message}");
        }
    }
}

using(...) ブロックを抜けると自動で Dispose()が呼ばれます。この書き方は、上記の例のようにファイル入出力やデータベース接続などに利用されるオブジェクト(このようなオブジェクトをリソースと呼びます)をC#が破棄してくれるようにする構文です。積極的に利用して、安全な開発に役立ててください。

6. 設計側から見た例外の分類

ここまでの講義で、「例外がメソッドの呼び出し順を飛び越える通知なら、深刻なエラー時以外にも使えるのでは?」と思ったあなたは、プログラマーに向いています。そう、アプリケーションの開発側では、今まで見て来たような例外だけでなく、業務例外と呼ばれる別の種類の例外クラスを設計して利用するのです。

ここでは、実際にアプリケーションを開発する際に利用される例外の種類についても解説しておきます。設計側(アプリケーションを開発する側)から見た例外の分類法です。

まずは例外を次の2種に分類するのが一般的です。

  1. 業務例外 - ユーザーの入力ミスなど、想定内の操作が原因で投げられる例外
  2. システム例外 - ゼロ除算などC#のシステムが投げる例外 - これまで紹介した例外は全てこちらに含まれる

ECサイトを開発する場合に作成されるであろう業務例外の体系例を、下図に示しました。

System.Object
 └─ System.Exception
     └─ System.BussinessException
          ├─ ValidationException        (入力に不備があった場合の例外)
          ├─ PointsNotEnoughException   (ECサイトなどで使いたいポイントが足りない場合の例外)
          ├─ InvalidIdException         (ログイン時の不正IDの例外)
          ├─ InvalidPasswordException   (ログイン時の不正パスワードの例外)
          └─ IllegalOperationException   (戻るボタンなど、禁止された操作を行った場合の例外)

今までに見てきた例外とは全く違った使われ方をしそうなものが並んでいますね。システム例外がSystem.SystemExceptionを継承しているのに対し、業務例外はSystem.Exceptionを継承したSystem.BussinessExceptionを親クラスにしていることに注目です。こうやって、業務例外とシステム例外を完全に分離して体系を作ることが大切です。

大きなアプリケーションでは扱う例外の種類も膨大になりますから、親クラスを見ただけで業務かシステムかを判断できるようにしておく必要があるからです。

要するに、システム例外がバグや設計ミスなどのいわゆる不具合によってC#が投げる例外であるのに対して、業務例外は開発者が、ユーザが行う想定内の操作に対して意図的に投げる処理を書き、アプリケーションの動作を制御し、ユーザに正しい使い方を示唆するために使う例外である、ということです。

throw キーワード

C#で例外を投げる処理は、throwを使って書くことが出来ます。以下は例文です。

public static void login(string id, string pw) {
    if (idが見付からない) {
        throw new InvalidIdException("IDが不正です");
    } else if (パスワードが違う) {
        throw new PasswordException("パスワードが間違っています");
    }
}

それでは、実際に例外を作って投げてみましょう。

7. 業務例外の実際

以下のサンプルは単純な作りですが、ECサイトでの業務例外の発生と、それに対する処理をごく簡単に切り替えられるように作られています。7行目のErrorNumberを0 ~ 4の値に設定することで、それぞれの例外が投げられ、例外処理が動作します。メソッドの階層が無駄に深くなっているのは、次でスタックトレースの見方を解説するためです。実行して、色々な番号を指定してみてください。

using System;

namespace Chap14 {
    internal class Example05 {

        // この値を変更することで例外を切り替えられます(0 - 4)
        static int ErrorNumber = 0;

        public static void Run() {
            try {

                Operation();

            } catch (ValidationException ex) {
                Console.WriteLine(ex);
                Console.WriteLine("画面にメッセージを表示して再入力を促します");

            } catch (PointsNotEnoughException ex) {
                Console.WriteLine(ex);
                Console.WriteLine("決済画面に戻ってメッセージを表示します");

            } catch (InvalidIdException ex) {
                Console.WriteLine(ex);
                Console.WriteLine("ログイン画面に戻ってメッセージを表示します");

            } catch (InvalidPasswordException ex) {
                Console.WriteLine(ex);
                Console.WriteLine("ログイン画面に戻ってメッセージを表示します");

            } catch (IllegalOperationException ex) {
                Console.WriteLine(ex);
                Console.WriteLine("カートの画面に戻ってメッセージを表示します");
            }
        }

        private static void Operation() { Operation01(); }
        private static void Operation01() { Operation02(); }
        private static void Operation02() { Operation03(); }
        private static void Operation03() { Operation04(); }
        private static void Operation04() { Operation05(); }

        // 例外を投げる処理
        private static void Operation05() {

            int errNo = ErrorNumber;

            // このswitch文にbreakは不要
            switch (errNo) {
            case 0:
                throw new ValidationException("住所を入力してください");
            case 1:
                throw new PointsNotEnoughException("ポイントが不足しています");
            case 2:
                throw new InvalidIdException("ログインIDが無効です");
            case 3:
                throw new InvalidPasswordException("パスワードが間違っています");
            case 4:
            default:
                throw new IllegalOperationException("この画面ではその操作はできません");
            }
        }
    }

    // 業務例外の親クラス
    internal class BussinessException : Exception {
        public BussinessException(string msg) : base(msg) {}
    }

    // 入力不備業務例外
    internal class ValidationException : BussinessException {
        public ValidationException(string msg) : base(msg) { }
    }

    // ポイント不足業務例外
    internal class PointsNotEnoughException : BussinessException {
        public PointsNotEnoughException(string msg) : base(msg) { }
    }

    // 不正ID業務例外
    internal class InvalidIdException : BussinessException {
        public InvalidIdException(string msg) : base(msg) { }
    }

    // 不正パスワード業務例外
    internal class InvalidPasswordException : BussinessException {
        public InvalidPasswordException(string msg) : base(msg) { }
    }

    // 不正操作業務例外
    internal class IllegalOperationException : BussinessException {
        public IllegalOperationException(string msg) : base(msg) { }
    }
}

<実行結果(例)>

Chap14.ValidationException: 住所を入力してください
at Chap14.Example05.Operation05() in C:\workspace\Sample\Chap14\Example05.cs:line 49
at Chap14.Example05.Operation04() in C:\workspace\Sample\Chap14\Example05.cs:line 40
at Chap14.Example05.Operation03() in C:\workspace\Sample\Chap14\Example05.cs:line 39
at Chap14.Example05.Operation02() in C:\workspace\Sample\Chap14\Example05.cs:line 38
at Chap14.Example05.Operation01() in C:\workspace\Sample\Chap14\Example05.cs:line 37
at Chap14.Example05.Operation() in C:\workspace\Sample\Chap14\Example05.cs:line 36
at Chap14.Example05.Run() in C:\workspace\Sample\Chap14\Example05.cs:line 12
画面にメッセージを表示して再入力を促します

皆さんも、思いついた例外と例外処理を追加してみましょう。

例外処理(例外ハンドリング)の実際

ここまで、catchした例外に対しての処理はコンソールにメッセージやスタックトレースを出力するだけでしたが、実際の例外ハンドリングでは、以下のような対応をすることが定番になっています。

  1. ユーザに親切なメッセージを出力する
  2. 詳細なログを出力する - 障害が起こった日時と処理の箇所をエラーログとして詳細に記録します
  3. リソースを閉じる- データベースやファイル入出力などのオブジェクトは必ず破棄しておきます
  4. 復旧作業 - 場合よって処理が変わりますが、アプリが継続して動作できるようならば、復旧作業をします
  5. 再試行が可能な場合は、再試行を行う

よく、例外をキャッチだけして何もしない(例外を握りつぶすと言います)というようなことをする例もありますが、これは決してやってはいけません。堅牢なアプリケーションを開発するためには、しっかりした例外処理が必要不可欠です。

8. スタックトレースの見方

この講義の最後に、スタックトレースの見方を解説します。スタックトレースとは、オブジェクトとメモリ領域の章で解説したエラー発生時のスタックを追った記録です。上の<実行結果>を見てください。Example05.Runメソッドから、5つのメソッドを経て例外が発生したことが読み取れますね。

スタックトレースを見るときは、一番上に表示されているのが直接例外を投げたメソッドであることと、一番下に表示されているメソッドが最初の呼び出し元メソッドであることを意識してください。

一番上の行のline xxとある表示が、何行目で例外が投げられたかを示しています。上の例ではExample05.csの49行目で例外が投げられたと表示されており、実際の行数とも合っていますね。こうやって不具合が起きた箇所を特定します。

スタックトレースは用語としても初心者の方には縁遠いですし、出力された文字列も読みにくい印象の物ですが、見方さえわかってしまえば不具合の原因を特定する大きな助けになってくれます。皆さんも一日も早く、スタックトレースの活用法を身に付けましょう。

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

□ 例外(Exception)はメソッドの呼び出し順序を飛び越して情報を伝える通知である

□ try-catch構文は例外を受け止め、適切な例外処理を行う

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

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

□ 設計側から見た例外は業務例外とシステム例外の2つに大別される。

  1. 業務例外はユーザの想定内の操作に反応し、正しい使い方を促すために使われる例外
  2. システム例外はバグや設計ミスなどのいわゆる不具合によってC#が投げる例外

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

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

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

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

ただ、実際にお客様の依頼で開発するアプリでは、詳細なエラー情報は画面に表示しないことが多いのです。特に深刻なエラーが起こった場合はなおさらです。知識が不十分なお客様をいたずらに不安にさせることになりますし、下手をすれば責任問題に発展し、会社同士のもめ事にもなりかねないからです。このような場合は、画面に「管理者に連絡を」とメッセージを出し、詳細なログを残すように処理を書いておきます。

次回は、「ラムダ式と非同期プログラミング」の講義です。

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