C#入門編

C#入門編(14)アプリの「想定外」を防ぐ ~try,catch,throwを用いた例外処理の基本~

C#入門編では、基本的な文法やオブジェクト指向、インターフェイス、ジェネリック型などについてこれまで学んできました。

今回はC#における「例外処理」の基本を解説します。

アプリを使っているときに、エラーで突然強制終了した(クラッシュした)という経験がある方も多いかと思います。

このようなアプリのクラッシュや想定外の動作を防ぐには、アプリを実行させたときに発生しうる様々な事態について適切に対処する(例外処理を行う)ことが大事です。

例えば、Webアプリを開発するならば以下のようなエラーを想定する必要があるでしょう。

このように、アプリを作るときには正常ルートのシナリオだけでなく、様々なエラーを想定したシナリオを考え、適切に対応する必要があります。

プロ太

信頼性を求められるアプリを作る場合には、メインロジックのコードよりも、エラーケースに対応するコードの方が多い場合もあります。

例外処理は実用的なアプリ開発でとても重要になる仕組みです。

一緒に学んでいきましょう!

演習コード一式はGitHubへ置いてあります。

YouTubeの動画は以下になります。

演習:CSVファイルを読み込む ~例外処理の基本~

これまでの演習で扱ってきた「レストランメニュー表作成プログラム」を拡張し、以下のアプリを作ります。

CSV形式のメニューを読み込み、メニュー一覧を生成します。

ユーザが作成したCSV形式のファイルを読み込むため、CSVファイルが妥当な形式かのチェックなどのエラー対処が重要になってきますね。

プロ太

この入門編では、「エクセルなどの表形式のデータを読み込み、HTML形式のレポートファイルを出力」することを目指してきました。

今回の演習でそれが実現します!

元となるプログラム

入門編11の演習2のプログラムついて、メニューを2種類にするなど少し簡易にしたものをベースとして、そこへ今回の演習コードを追加します。

ベースのコードをGitHubに置きます。以下のような構成になっています。

現状では、入力となるメニュー一覧をプログラムでそのまま書き下し(ハードコーディングし)与えています。

また、HTMLのメニュー表をコンソールへ出力しています。

using Restaurant.Generators;
using Restaurant.Menus;

namespace Restaurant
{
    internal class Program
    {
        static void Main(string[] args)
        {

            //メニューデータを定義し、Menu配列に格納
            Menu[] menus = new Menu[] {
                new MainMenu("黒毛和牛ステーキ", "ジューシーで柔らかなステーキです。", 3000, false),
                new MainMenu("ベジタブルカレー", "野菜をたっぷりと使った、スパイシーなカレーです。", 2400, true),
                new DrinkMenu("メロンソーダ", "爽やかな甘さが楽しめるメロンソーダです。", 400, true),
                new DrinkMenu("ホットコーヒー", "丁寧に焙煎されたコーヒー豆を使用しています。", 500, false),
            };

            // メニュー一覧をHTMLコードとして生成する
            MenuTableGenerator generator = new MenuTableGenerator(menus.ToArray());
            string tableHtml = generator.GenerateTable();

            // メニュー表をHTMLページとして生成する
            MenuPageGenerator htmlGenerator = new MenuPageGenerator(tableHtml);
            string html = htmlGenerator.GenerateHtml();

            // 生成されたHTMLをコンソールに出力する
            Console.WriteLine(html);
        }
    }
}

作る機能

以下の機能を追加します。

  • CSV形式のメニュー一覧(menu.csv)を読み込む
  • メニュー表のHTMLファイル(menu.html)を出力する

CSVファイルを読み込む際には、入力チェックについて適切な例外処理も行います。

以下のように、元のコードに対して(1)、(2)の修正・追加を行います。

入力となるCSVファイルは次のような様式です。

CSVファイルはテキストなのでメモ帳でも編集できますが、表形式で編集したい場合は、何らかのエディタを使うとよいでしょう。

本演習では、Visual Studio Code(Excel Viewerプラグイン)を使っています。

今回、CSVファイルの文字コードにはUTF-8を使います。

プロ太

Excelで編集する場合、Excelの標準エンコーディングはShift_JISであることが多いです。

そのため、日本語が含まれるCSVファイルを編集する場合、文字コードに注意が必要です。

例外処理の基本 ~try,catch,throw~

C#での例外処理は以下の構文で行います。

try
{
    // 例外が発生する可能性のあるコード
    // 「throw new 例外型(...)」で例外を発生させる
}
catch (対象となる例外型1 ex_1)
{
    // 例外1が発生した場合の処理
}
…
catch (対象となる例外型n ex_n)
{
    // 例外nが発生した場合の処理
}

例外が発生する可能性のあるコードをtryで囲み、何かエラーがあったら例外型のインスタンスを生成してthrowし、それをエラーの種類ごとにcatchして処理をします。

簡単な具体例を見てみましょう。

try
{
    string? input = Console.ReadLine();
    int number = int.Parse(input);  // この行で例外が発生する可能性がある
    Console.WriteLine($"数値: {number}"); //↑で例外が発生するとこの行は実行されない
}
catch (FormatException ex)
{
    Console.WriteLine($"エラーが発生しました: {ex.Message}");
}

この例では、コンソールからのユーザ入力として整数値を受け取ることを想定していますが、それ以外(例えば英字)が入力された場合の例外を処理しています。

FormatExceptionが発生した例外の種類です。int.Parseメソッド内でthrowが行われています。

“abc”などと入力すると、実行結果は次のようになります。

エラーが発生しました: The input string 'abc' was not in a correct format.

プログラムで例外が発生すると、通常の処理の流れが中断され、即座に対応するcatchブロックに制御が移ります。

以下のようなイメージです。

プロ美

難しいなぁ。処理を途中で中断するってことは、throwreturnみたいなものなの?

プロ太

throwを使用すると、それまで実行していたメソッドやループなどの処理を即座に全て中断し、対応するcatchブロックまで制御が移ります。

これは通常の実行フローを中断する「例外的な」動作です。

一方、returnは現在のメソッドの実行を終了し、呼び出し元に制御を戻します。

これは通常の実行フローの一部です。

演習コード

例外処理をうまく使いながらコードを書いていきましょう。

(1) Programクラスを修正

Programクラスは以下のように修正します。

using Restaurant.Generators;
using Restaurant.Menus;
using Restaurant.Readers;

namespace Restaurant
{
    internal class Program
    {
        static void Main(string[] args)
        {
            try
            {
                // ★(a) CSVからメニュー一覧を読み込む
                string inputCsvFilePath = "menu.csv";
                var csvReader = new CsvMenuReader(inputCsvFilePath);
                List<Menu> menus = csvReader.ReadMenus();

                // メニュー一覧をHTMLコードとして生成する
                MenuTableGenerator generator = new MenuTableGenerator(menus.ToArray());
                string tableHtml = generator.GenerateTable();

                // メニュー表をHTMLページとして生成する
                MenuPageGenerator htmlGenerator = new MenuPageGenerator(tableHtml);
                string html = htmlGenerator.GenerateHtml();

                // ★(b) 生成されたHTMLをファイルへ出力する
                const string outputHtmlFilePath = "menu.html";
                File.WriteAllText(outputHtmlFilePath, html);
            }
            catch (MenuLoadException e)
            {
                // ★(c) メニューの読み込みに失敗した場合のエラーメッセージを表示する
                Console.WriteLine($"メニューの読み込みに失敗しました: {e.Message}");
            }
            catch (Exception e)
            {
                // ★(d) その他の予期せぬエラーが発生した場合のエラーメッセージを表示する
                Console.WriteLine($"予期せぬエラーが発生しました: {e.Message}");
            }
        }
    }
}

(a)では、CsvMenuReaderクラスを使ってCSVファイルを読み込み、メニューのリストを得ています。(CsvMenuReaderの実装について次節で説明します。)

(b)では、標準ライブラリのFile.WriteAllTextメソッドを使い、生成されたHTMLのテキストを”menu.html”というファイル名で出力しています。

これらの処理はtryブロック内にあり、例外が発生するとcatchで補足します。

(c)では、メニュー読み込みの失敗を表すMenuLoadExceptionという型の例外を補足し、エラー内容を表示します。(MenuLoadExceptionクラスについては次節で説明します。)

プロ太

CSV読み込み時のエラー内容を表示することで、「何が誤りでどのようにCSVをどう修正すればよいか?」をユーザが把握しやすくなります。

(d)では、一般的な例外(Exception型)を補足してエラー内容を表示しています。

全ての例外型はException型の派生クラスとして定義します。

catchでException型を指定すると全ての例外型が補足されることになります。

例外を補足する際、catchブロック上の上から順番にチェックしていきます。以下だとthrowされたものが、例外型1か?例外型2か?…と順に確認します。

catch(例外型1 ex_1){
  …
}
catch(例外型2 ex_2){
  …
}
…
catch(例外型n ex_n){
  …
}

なので、より特殊な例外型を上に配置し、下にいくほどより一般的な例外型になるように書きましょう。

プロ太

最初にcatch(Exception e){…}と書くと、全ての例外がそこで補足されてしまうので注意です!

オブジェクト指向における派生クラス・継承などの概念については以下の記事を参考にしてください。

C#入門編(9)オブジェクト指向とは?「継承」 ~クラスを機能拡張して再利用する~ 前回の演習では、オブジェクト指向とカプセル化についての基本的な考え方を学びました。 https://prota-p.com/cs...

(2) CsvMenuReader・MenuLoadExceptionクラスを追加

CsvMenuReaderで発生する例外を扱う例外型を、MenuLoadExceptionクラスとして定義します。

namespace Restaurant.Readers
{
    internal class MenuLoadException : Exception
    {
        public MenuLoadException(string message, int? lineNumber = null)
            : base(lineNumber.HasValue ? $"{message} (行: {lineNumber})" : message)
        {
        }
    }
}

エラーメッセージに加えて、CSVのどの行でエラーが発生したかがわかる情報(lineNumber引数)も与えています。

プロ太

ユーザが次のアクションをとりやすいエラーメッセージが良いですね。

CSVの行も出力すると、ユーザにとってはわかりやすいでしょう。

次に、CsvMenuReaderクラスを実装します。

using Restaurant.Menus;

namespace Restaurant.Readers
{
    internal class CsvMenuReader
    {
        private readonly string _filePath;

        // CSV列の定数定義
        private const int ColumnType = 0;
        private const int ColumnName = 1;
        private const int ColumnDescription = 2;
        private const int ColumnPrice = 3;
        private const int ColumnIsVegetarian = 4;
        private const int ColumnIsCold = 5;
        private const int ExpectedColumnCount = 6;

        public CsvMenuReader(string filePath)
        {
            _filePath = filePath;
        }

        public List<Menu> ReadMenus()
        {
            // CSVファイルの全行を読み込む
            string[] lines = File.ReadAllLines(_filePath);
            var menus = new List<Menu>();

            // ヘッダー行をスキップしてデータ行を処理する
            for (int lineIndex = 1; lineIndex < lines.Length; lineIndex++)
            {
                int lineNumber = lineIndex + 1; // 行番号は1から始まる
                string[] parts = lines[lineIndex].Split(',');

                if (parts.Length != ExpectedColumnCount)
                {//★(a)
                    throw new MenuLoadException("列数が正しくありません。", lineNumber);
                }

                string type = parts[ColumnType].Trim();
                string name = parts[ColumnName].Trim();
                string description = parts[ColumnDescription].Trim();

                if (!int.TryParse(parts[ColumnPrice].Trim(), out int price))
                {//★(b)
                    throw new MenuLoadException("Priceの値が無効です。", lineNumber);
                }

                Menu menu;
                switch (type.ToLower())
                {
                    case "main":
                        if (!bool.TryParse(parts[ColumnIsVegetarian].Trim(), out bool isVegetarian))
                        {//★(c)
                            throw new MenuLoadException("IsVegetarianの値が無効です。", lineNumber);
                        }
                        menu = new MainMenu(name, description, price, isVegetarian);
                        break;
                    case "drink":
                        if (!bool.TryParse(parts[ColumnIsCold].Trim(), out bool isCold))
                        {//★(d)
                            throw new MenuLoadException("IsColdの値が無効です。", lineNumber);
                        }
                        menu = new DrinkMenu(name, description, price, isCold);
                        break;
                    default://★(e)
                        throw new MenuLoadException($"{type}というMenuの値は無効です。", lineNumber);
                }

                menus.Add(menu);
            }
            return menus;
        }
    }
}

ざっくりとは以下の流れで処理を行っています。

  • File.ReadAllLinesメソッドでCSVファイルを各行のテキストの配列(lines)として読み込む。
  • linesの各要素(各行のテキスト)をSplitメソッドでカンマ区切りで、列ごとの要素(parts)に分解する。
  • 各行それぞれについて各列を分析し、メニュークラスを構築する。
  • エラーがあれば例外(ManuLoadException)をthrowする。

今回の演習は例外処理がメインなので、そこに焦点を当てて説明します。

以下の(a)~(e)のように、CSVファイルの様式が正しくない場合はMenuLoadExceptionをthrowしています。(コードの該当部分を抜粋しています。)

                …
                if (parts.Length != ExpectedColumnCount)
                {//★(a)
                    throw new MenuLoadException("列数が正しくありません。", lineNumber);
                }
                …
                if (!int.TryParse(parts[ColumnPrice].Trim(), out int price))
                {//★(b)
                    throw new MenuLoadException("Priceの値が無効です。", lineNumber);
                }
                …
                    case "main":
                        if (!bool.TryParse(parts[ColumnIsVegetarian].Trim(), out bool isVegetarian))
                        {//★(c)
                            throw new MenuLoadException("IsVegetarianの値が無効です。", lineNumber);
                        }
                        …
                    case "drink":
                        if (!bool.TryParse(parts[ColumnIsCold].Trim(), out bool isCold))
                        {//★(d)
                            throw new MenuLoadException("IsColdの値が無効です。", lineNumber);
                        }
                        …
                    default://★(e)
                        throw new MenuLoadException($"{type}というMenuの値は無効です。", lineNumber);
                }
                …

それぞれ以下の例外を検出してthrowしています。

  • (a) 列数が正しくない
  • (b) Price列で整数以外の値が記載されている
  • (c) IsVegetarian列でtrue/false以外の値が記載されている
  • (d) IsCold列でtrue/false以外の値が記載されている
  • (e) Menu列で存在しないメニューが記載されている

ここでthrowした例外は、CsvMenuReader.ReadMenusメソッドを呼び出しているProgram.Mainメソッド内のtry,catchブロックで補足されます。

プログラムを実行

正常な入力

以下のmenu.csvファイルを用意して、プログラムをデバッグ実行してみましょう。

Type,Name,Description,Price,IsVegetarian,IsCold
Main,黒毛和牛ステーキ,ジューシーで柔らかなステーキです。,3000,false,
Main,ベジタブルカレー,野菜をたっぷりと使った、スパイシーなカレーです。,2400,true,
Drink,メロンソーダ,爽やかな甘さが楽しめるメロンソーダです。,400,,true
Drink,ホットコーヒー,丁寧に焙煎されたコーヒー豆を使用しています。,500,,false

csvファイルはデバッグ用のビルド出力フォルダに置きましょう。「Restaurant\bin\Debug\net7.0」といったパスのフォルダです。

実行すると、menu.htmlが出力されるはずです。

正常でない入力

入力に誤りがあるmenu.csvファイルを用意して、デバッグ実行してみましょう。
3行目で「true」とする箇所を「真」としています。

Type,Name,Description,Price,IsVegetarian,IsCold
Main,黒毛和牛ステーキ,ジューシーで柔らかなステーキです。,3000,false,
Main,ベジタブルカレー,野菜をたっぷりと使った、スパイシーなカレーです。,2400,真,
Drink,メロンソーダ,爽やかな甘さが楽しめるメロンソーダです。,400,,true
Drink,ホットコーヒー,丁寧に焙煎されたコーヒー豆を使用しています。,500,,false

以下のように出力されます。

メニューの読み込みに失敗しました: IsVegetarianの値が無効です。 (行: 3)
プロ美

3行目のIsVegetarianの値に間違いがあるってわかるね!

補足

今回の演習では入力チェックの一部に着目して例外処理を実装しました。

もう少しきちんと例外処理を行うならば、ファイル読み込みが失敗した場合なども想定する必要があるでしょう。

例えば、今回CsvReaderクラスでファイル読み込みのために「File.ReadAllLines」メソッドを使いました。

File.ReadAllLinesのドキュメントをみると、どのような例外が発生する可能性があるかが記載されています。

外部ライブラリ使用時は発生しうる例外に対して適切な対応を行っておくとよいでしょう。

例外処理についてはMicrosoft Learnも参考にしてください。

今回紹介していませんが、try,catchブロックの他に例外発生時にもリソース開放等の後始末を確実に行うための「finallyブロック」もあります。

講義:例外処理/エラー処理は何のために行うのか?

プロ美

例外処理とかエラー処理、書かなくてもとりあえずプログラムは動くよね…。

プロ太

例外処理やエラー処理は何のために必要かを考えてみましょうか。

演習で作成したプログラムで、CSVファイルの各行の列数が正しいかをチェックしているコードがありましたね。

            …
            for (int lineIndex = 1; lineIndex < lines.Length; lineIndex++)
            {
                int lineNumber = lineIndex + 1; // 行番号は1から始まる
                string[] parts = lines[lineIndex].Split(',');

                if (parts.Length != ExpectedColumnCount)
                {
                    throw new MenuLoadException("列数が正しくありません。", lineNumber);
                }
            …

この(a)例外処理を記述した場合/(b)記述しなかった場合を考えてみましょう。

列数に誤りがあるCSVを読み込ませたとき、それぞれの実行結果は以下のようになります。

プロ美

(b)のエラーメッセージを見ても、「列数の間違っていることが原因」ってわからない!

というか、何かよくわからないエラーが発生してる

プロ太

(b)で、開発者の想定外のエラーが発生している(想定していない動作をしている)ということは、アプリ品質に問題があるということになります。

ユーザは何が起こっているかわからず、ユーザビリティにも問題がありますね。

加えて、開発者がバグの原因特定を行うのが大変という問題もあります。

このような問題が発生しないようにするため、例外処理を含めたエラー処理は重要です。

ただ、どこまで頑張ってエラー対応を行うかはアプリの要件や求められる信頼性にもよります。

ミッションクリティカルなシステム(例:医療機器、金融システム)ならば非常に厳密なエラー処理が必要でしょうし、実験用のコードであればエラー処理は最低限でよいでしょう。

まとめ

C#における「例外処理」の基本について解説しました。

try,catch,throwを使うことで、アプリを実行しているときに発生する様々なエラーについて、適切に対応できるようになります。

レストランメニューのCSVファイルの読み込み部分を題材として、実際に例外処理の実装を行い、入力チェックを行えるようにしました。

例外・エラー処理はアプリ品質やユーザビリティ確保・デバッグ容易化のために重要です。

エラーにどのような種類があり、エラー発生したときにどのようにデバッグしていけばよいのか?…という点については以下の記事も参考にしてください。

C#入門編(6)エラーの種類とデバッグ方法 ~初心者が最低限覚えておきたいポイント~ 今回は、初心者がプログラミングをするときに、つまづきやすいエラーについての話をします。 プログラミングをしていると、確実にエラー...

今回、入門編の目標である「エクセルなどの表形式のデータを読み込み、HTML形式のレポートファイルを出力」をついに達成しました!

ここまででC#プログラミングの基本のなかでも特に大事な項目を説明してきました。

これからも、外部ライブラリの使い方、Delegate/ラムダ式、LINQ…などC#でより効率よくプログラミングを行う仕組みを解説します。

プロ太

引き続き、C#やプログラミングの考え方を一緒に学んでいきましょう!

ABOUT ME
プロ太
プログラミングを勉強している人へ情報を発信していきます! ・情報工学分野で博士(工学)の学位取得 ・言語:C# 、Java、C/C++、Python、JavaScript/TypeScript等 ・仕事は主に上流工程(WF開発・Agile開発、OSS開発経験あり) ・趣味で開発:3Dゲーム、Webアプリ、言語処理系等

ご依頼・ご相談について

プログラミング学習のご相談、お仕事のご依頼については、
こちらのお問い合わせページをご確認ください。