例外処理

【C#】FormatExceptionの原因を完全理解!TryParseと例外処理で安全に型変換する方法

C#のFormatExceptionは、文字列を数値や日付などに変換する際に形式が合わないと発生する例外です。

ユーザー入力や外部データが期待形式か確認せずにParseすると起きやすいので、TryParseDateTime.TryParseExactで検証し、適切な例外処理を行うことで防げます。

目次から探す
  1. FormatExceptionとは何か
  2. 主な発生原因
  3. ParseとTryParseの比較
  4. TryParse活用テクニック
  5. DateTime.ParseExactとTryParseExact
  6. CultureInfoを用いたグローバル対応
  7. 例外処理パターン
  8. よくある誤解と落とし穴
  9. 正規表現での事前バリデーション
  10. データ検証フローの設計
  11. ログと診断の推奨方法
  12. 単体テストによる再現と検証
  13. パフォーマンス評価
  14. サンプルコード集
  15. リファクタリング事例
  16. まとめ

FormatExceptionとは何か

C#でプログラムを開発していると、文字列を数値や日付などの特定の型に変換する場面が多くあります。

このとき、変換しようとした文字列の形式が期待しているものと異なる場合に発生するのがFormatExceptionです。

FormatExceptionは、データの形式が正しくないことを示す例外であり、主にParseメソッドやConvertクラスのメソッドで発生します。

例えば、ユーザーからの入力や外部ファイルから読み込んだデータを数値に変換しようとした際に、数字以外の文字が混じっているとFormatExceptionが発生します。

この例外は、プログラムの実行を停止させる原因となるため、適切に対処することが重要です。

発生のタイミング

FormatExceptionは、主に以下のような状況で発生します。

  • 文字列を数値に変換する際

例えば、int.Parsedouble.Parseを使って文字列を数値に変換しようとしたとき、文字列が数値として解釈できない場合に発生します。

例:int.Parse("abc")FormatExceptionを投げます。

  • 文字列を日付に変換する際

DateTime.ParseDateTime.ParseExactで、指定したフォーマットに合わない文字列を変換しようとすると発生します。

例:DateTime.ParseExact("2023/13/01", "yyyy/MM/dd", null)は無効な月のため例外が発生します。

  • 列挙型(enum)への変換時

Enum.Parseで、文字列が列挙型の定義に存在しない場合に発生します。

例:Enum.Parse(typeof(DayOfWeek), "Funday")は例外を投げます。

  • その他のフォーマットが厳密に求められる変換

例えば、Guid.ParseTimeSpan.Parseなど、特定の形式を要求する変換でも、形式が合わないとFormatExceptionが発生します。

このように、FormatExceptionは「変換しようとした文字列の形式が正しくない」ことを示す例外であり、変換処理の失敗を検知するために重要な役割を果たします。

エラーメッセージの読み取り方

FormatExceptionが発生した際には、例外オブジェクトのMessageプロパティにエラーメッセージが格納されます。

このメッセージは、何が原因で例外が発生したのかを理解する手がかりになります。

例えば、以下のようなメッセージが表示されることがあります。

  • "Input string was not in a correct format."

これは最も一般的なメッセージで、入力文字列が期待される形式に合致しないことを示しています。

例えば、数値変換で数字以外の文字が含まれている場合などです。

  • "String was not recognized as a valid DateTime."

日付変換でフォーマットが合わない場合に表示されます。

DateTime.ParseExactで指定したフォーマットと入力文字列が一致しないときに多いです。

  • "Requested value 'XXX' was not found."

列挙型変換で、指定した文字列が列挙型の定義に存在しない場合に表示されます。

Enum.Parseでのエラーです。

エラーメッセージは英語で表示されることが多いですが、Visual Studioのデバッガやログに出力される内容をよく確認することで、どの変換処理で失敗したのか、どの文字列が問題なのかを特定できます。

また、例外のスタックトレースも重要です。

スタックトレースを見ることで、どのメソッドのどの行で例外が発生したかがわかり、原因の特定や修正がしやすくなります。

以下に、FormatExceptionが発生する簡単な例を示します。

using System;
class Program
{
    static void Main()
    {
        try
        {
            // 数値変換で不正な文字列を指定
            int number = int.Parse("abc"); // ここでFormatExceptionが発生
        }
        catch (FormatException ex)
        {
            Console.WriteLine("FormatExceptionが発生しました。");
            Console.WriteLine("エラーメッセージ: " + ex.Message);
        }
    }
}
FormatExceptionが発生しました。
エラーメッセージ: The input string 'abc' was not in a correct format.

この例では、int.Parseに数字以外の文字列を渡しているため、FormatExceptionが発生しています。

例外のメッセージから、入力文字列の形式が正しくないことがわかります。

このように、FormatExceptionは変換処理の失敗を示す重要な例外であり、発生のタイミングやメッセージを正しく理解することが、堅牢なプログラムを書く第一歩となります。

主な発生原因

数値変換での不正入力

数値変換時にFormatExceptionが発生する最も一般的な原因は、文字列が数値として解釈できない場合です。

例えば、数字以外の文字が混入していたり、空文字列やnullが渡された場合に例外が起きます。

int.Parsedouble.Parseは、こうした不正な文字列を受け取ると例外を投げるため、注意が必要です。

ユーザー入力フォームの例

ユーザーが入力フォームに数値を入力する場面では、誤入力がよく起こります。

例えば、年齢を入力する欄に「twenty」や「25歳」といった文字列が入ると、int.Parseで変換しようとした際にFormatExceptionが発生します。

using System;
class Program
{
    static void Main()
    {
        Console.Write("年齢を入力してください: ");
        string input = Console.ReadLine();
        try
        {
            int age = int.Parse(input); // 数値以外の入力で例外発生
            Console.WriteLine($"入力された年齢は {age} 歳です。");
        }
        catch (FormatException)
        {
            Console.WriteLine("入力が数値の形式ではありません。正しい数字を入力してください。");
        }
    }
}
年齢を入力してください: 25歳
入力が数値の形式ではありません。正しい数字を入力してください。

この例では、ユーザーが「25歳」と入力したため、int.Parseが失敗しFormatExceptionが発生しています。

こうしたケースでは、TryParseを使って変換の成否を判定するか、入力値のバリデーションを行うことが推奨されます。

CSVインポートでの混在データ

CSVファイルなどの外部データを読み込む際も、数値変換でFormatExceptionが起こりやすいです。

特に、数値列に空文字や文字列が混入している場合、Parseメソッドは例外を投げます。

using System;
class Program
{
    static void Main()
    {
        string[] csvData = { "100", "200", "abc", "", "300" };
        foreach (var item in csvData)
        {
            try
            {
                int value = int.Parse(item); // "abc"や空文字で例外発生
                Console.WriteLine($"読み込んだ値: {value}");
            }
            catch (FormatException)
            {
                Console.WriteLine($"不正な数値データ: '{item}'");
            }
        }
    }
}
読み込んだ値: 100
読み込んだ値: 200
不正な数値データ: 'abc'
不正な数値データ: ''
読み込んだ値: 300

このように、外部データの混在によりFormatExceptionが発生することが多いため、データの前処理やTryParseの活用が重要です。

日付変換でのフォーマット不一致

日付文字列をDateTime型に変換する際、指定したフォーマットと入力文字列が一致しないとFormatExceptionが発生します。

特にDateTime.ParseExactはフォーマットが厳密に一致しないと例外を投げるため、フォーマットの指定ミスや入力のばらつきに注意が必要です。

ローカライズされた日付形式

日本語環境などローカライズされた日付形式では、年月日の区切り文字や順序が異なるため、フォーマット指定が合わないと例外が発生します。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string dateStr = "2023年04月01日";
        string format = "yyyy年MM月dd日";
        try
        {
            DateTime date = DateTime.ParseExact(dateStr, format, CultureInfo.InvariantCulture);
            Console.WriteLine($"変換成功: {date.ToShortDateString()}");
        }
        catch (FormatException)
        {
            Console.WriteLine("日付の形式が正しくありません。");
        }
    }
}
変換成功: 2023/04/01

もしフォーマットが"yyyy/MM/dd"のようにスラッシュ区切りで指定されていると、この文字列は変換できずFormatExceptionが発生します。

ISO8601との違い

ISO8601形式(例:2023-04-01T15:30:00Z)は国際的に標準化された日付時刻の表記ですが、これと異なる形式の文字列をParseExactで変換しようとすると例外が発生します。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string isoDate = "2023-04-01T15:30:00Z";
        string format = "yyyy-MM-ddTHH:mm:ssZ";
        try
        {
            DateTime date = DateTime.ParseExact(isoDate, format, CultureInfo.InvariantCulture);
            Console.WriteLine($"ISO8601形式の変換成功: {date.ToString("u")}");
        }
        catch (FormatException)
        {
            Console.WriteLine("ISO8601形式の変換に失敗しました。");
        }
    }
}
ISO8601形式の変換成功: 2023-04-01 15:30:00Z

ただし、タイムゾーン表記やミリ秒の有無など細かい違いがあると、FormatExceptionが発生することがあります。

正確なフォーマット指定が必要です。

小数点・区切り記号のカルチャ差異

数値や日付の変換では、カルチャ(文化圏)による表記の違いが原因でFormatExceptionが発生することがあります。

特に小数点や桁区切り記号の違いは注意が必要です。

CultureInfoとNumberFormatInfo

例えば、英語圏en-USでは小数点は.(ドット)、桁区切りは,(カンマ)ですが、日本ja-JPでは小数点は.で同じですが、桁区切りの使い方や日付の区切り文字が異なります。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string numberStr = "1,234.56"; // 英語圏の表記
        CultureInfo usCulture = new CultureInfo("en-US");
        CultureInfo jpCulture = new CultureInfo("ja-JP");
        try
        {
            double valueUS = double.Parse(numberStr, usCulture);
            Console.WriteLine($"USカルチャでの変換成功: {valueUS}");
        }
        catch (FormatException)
        {
            Console.WriteLine("USカルチャでの変換失敗");
        }
        try
        {
            double valueJP = double.Parse(numberStr, jpCulture);
            Console.WriteLine($"JPカルチャでの変換成功: {valueJP}");
        }
        catch (FormatException)
        {
            Console.WriteLine("JPカルチャでの変換失敗");
        }
    }
}
USカルチャでの変換成功: 1234.56
JPカルチャでの変換失敗

この例では、ja-JPカルチャではカンマが桁区切りとして認識されず、FormatExceptionが発生しています。

NumberFormatInfoをカスタマイズすることで対応可能ですが、基本的には変換時に適切なカルチャを指定することが重要です。

ParseとTryParseの比較

C#で文字列を数値や日付などの型に変換する際、ParseメソッドとTryParseメソッドのどちらを使うかは重要な選択ポイントです。

両者は似た目的を持ちますが、動作や例外処理の扱いに大きな違いがあります。

例外発生コストの差

Parseメソッドは、変換に失敗するとFormatExceptionなどの例外を投げます。

例外処理は便利ですが、例外が発生するとスタックトレースの生成や例外オブジェクトの作成など、処理コストが高くなります。

頻繁に例外が発生する可能性がある場合、パフォーマンスに悪影響を及ぼすことがあります。

一方、TryParseは例外を投げず、変換の成否をboolで返します。

失敗しても例外処理のオーバーヘッドがないため、パフォーマンス面で優れています。

大量のデータを処理したり、ユーザー入力の検証を繰り返す場合はTryParseの利用が推奨されます。

メソッド名例外発生時の挙動パフォーマンス使いどころ
Parse例外を投げる例外発生時にコスト高変換失敗が稀な場合や例外処理が必要な場合
TryParse例外を投げない例外処理オーバーヘッドなし変換失敗が想定される場合や高速処理が必要な場合

成功・失敗分岐の実装例

Parseは例外をキャッチして失敗を検知しますが、TryParseは戻り値で成功・失敗を判定します。

以下に両者の実装例を示します。

Parseを使った例外処理

using System;
class Program
{
    static void Main()
    {
        string input = "123a";
        try
        {
            int number = int.Parse(input);
            Console.WriteLine($"変換成功: {number}");
        }
        catch (FormatException)
        {
            Console.WriteLine("変換に失敗しました。入力が数値ではありません。");
        }
    }
}
変換に失敗しました。入力が数値ではありません。

TryParseを使った成功・失敗判定

using System;
class Program
{
    static void Main()
    {
        string input = "123a";
        if (int.TryParse(input, out int number))
        {
            Console.WriteLine($"変換成功: {number}");
        }
        else
        {
            Console.WriteLine("変換に失敗しました。入力が数値ではありません。");
        }
    }
}
変換に失敗しました。入力が数値ではありません。

TryParseは例外を使わずに失敗を判定できるため、コードがシンプルでパフォーマンスも良好です。

Nullable型との組み合わせ

数値や日付の変換結果をNullable<T>int?DateTime?で扱うと、変換失敗時にnullを返す形で安全に値を管理できます。

TryParseと組み合わせることで、例外を使わずに変換結果の有無を表現できます。

using System;
class Program
{
    static int? ParseNullableInt(string input)
    {
        if (int.TryParse(input, out int result))
        {
            return result;
        }
        else
        {
            return null;
        }
    }
    static void Main()
    {
        string[] inputs = { "100", "abc", "200" };
        foreach (var input in inputs)
        {
            int? value = ParseNullableInt(input);
            if (value.HasValue)
            {
                Console.WriteLine($"変換成功: {value.Value}");
            }
            else
            {
                Console.WriteLine($"変換失敗: '{input}' は数値ではありません。");
            }
        }
    }
}
変換成功: 100
変換失敗: 'abc' は数値ではありません。
変換成功: 200

この方法は、変換結果が存在しない場合にnullで表現できるため、呼び出し側での判定が簡単になります。

例外処理を使わずに安全に値を扱いたい場合に便利です。

Parseは例外を使った厳密な変換処理に向いていますが、例外発生時のコストが高いため、頻繁に失敗が予想される場面ではTryParseを使うのがベストプラクティスです。

TryParseは戻り値で成功・失敗を判定でき、Nullable型と組み合わせることでより安全で読みやすいコードが書けます。

TryParse活用テクニック

TryParseメソッドは例外を発生させずに文字列の変換を試みるため、堅牢な入力処理に欠かせません。

ここでは、TryParseをより便利に使うためのテクニックを紹介します。

out変数とvar型推論

C# 7.0以降では、TryParseoutパラメータに対して変数宣言をメソッド呼び出しの中で行うことができます。

これにより、コードがすっきりし、変数のスコープも限定されて可読性が向上します。

using System;
class Program
{
    static void Main()
    {
        string input = "123";
        // 変数宣言をTryParseの引数内で行う
        if (int.TryParse(input, out int number))
        {
            Console.WriteLine($"変換成功: {number}");
        }
        else
        {
            Console.WriteLine("変換失敗");
        }
    }
}
変換成功: 123

さらに、varキーワードを使って型推論を活用することも可能ですが、TryParseoutパラメータでは型を明示的に指定することが一般的です。

varout変数宣言には直接使えませんが、out変数を受け取った後の変数に対しては使えます。

using System;
class Program
{
    static void Main()
    {
        string input = "456";
        if (int.TryParse(input, out var number)) // C# 7.0以降で可能
        {
            Console.WriteLine($"変換成功: {number}");
        }
        else
        {
            Console.WriteLine("変換失敗");
        }
    }
}
変換成功: 456

このように、out変数宣言をメソッド呼び出しの中で行うことで、コードが簡潔になり、変数のスコープも限定されるため、バグの発生を抑えられます。

拡張メソッドで共通化

複数の場所で同じ型の変換処理を繰り返す場合、拡張メソッドを作成して共通化すると便利です。

これにより、変換失敗時の処理やデフォルト値の設定などを一元管理できます。

以下は、string型に対してTryParseを使い、変換に成功すれば値を返し、失敗すればnullを返す拡張メソッドの例です。

using System;
static class StringExtensions
{
    public static int? ToNullableInt(this string s)
    {
        return int.TryParse(s, out int result) ? result : (int?)null;
    }
}
class Program
{
    static void Main()
    {
        string[] inputs = { "100", "abc", "200" };
        foreach (var input in inputs)
        {
            int? value = input.ToNullableInt();
            if (value.HasValue)
            {
                Console.WriteLine($"変換成功: {value.Value}");
            }
            else
            {
                Console.WriteLine($"変換失敗: '{input}' は数値ではありません。");
            }
        }
    }
}
変換成功: 100
変換失敗: 'abc' は数値ではありません。
変換成功: 200

このように拡張メソッドを使うと、変換処理が簡潔になり、コードの重複を減らせます。

日付や他の型でも同様に拡張メソッドを作成可能です。

入力チェックフローの構築

ユーザー入力や外部データの受け取り時には、TryParseを活用して段階的に入力チェックを行うフローを設計すると安全です。

例えば、まず文字列の空チェックを行い、その後TryParseで型変換を試み、失敗した場合はエラーメッセージを返すといった流れです。

using System;
class Program
{
    static bool ValidateAndParseInt(string input, out int result, out string errorMessage)
    {
        result = 0;
        errorMessage = string.Empty;
        if (string.IsNullOrWhiteSpace(input))
        {
            errorMessage = "入力が空です。数値を入力してください。";
            return false;
        }
        if (!int.TryParse(input, out result))
        {
            errorMessage = "入力が数値の形式ではありません。";
            return false;
        }
        return true;
    }
    static void Main()
    {
        string[] testInputs = { "", "abc", "123" };
        foreach (var input in testInputs)
        {
            if (ValidateAndParseInt(input, out int number, out string error))
            {
                Console.WriteLine($"変換成功: {number}");
            }
            else
            {
                Console.WriteLine($"エラー: {error}");
            }
        }
    }
}
エラー: 入力が空です。数値を入力してください。
エラー: 入力が数値の形式ではありません。
変換成功: 123

このように、入力チェックを段階的に行うことで、ユーザーに対して適切なフィードバックを返しつつ、安全に変換処理を進められます。

TryParseは例外を発生させないため、こうしたフローの中で非常に使いやすいメソッドです。

DateTime.ParseExactとTryParseExact

DateTime.ParseExactDateTime.TryParseExactは、文字列を指定したフォーマットに厳密に従ってDateTime型に変換するメソッドです。

フォーマットが一致しない場合、ParseExactFormatExceptionを投げ、TryParseExactfalseを返します。

これらを使うことで、日付文字列の形式を厳密に制御できます。

フォーマット文字列の指定方法

フォーマット文字列は、日付や時刻の各要素を表す特定のパターンで構成されます。

主なフォーマット指定子は以下の通りです。

フォーマット指定子説明例(2023年4月1日15時30分)
yyyy4桁の西暦年2023
yy2桁の西暦年23
MM2桁の月(01~12)04
M1桁または2桁の月4
dd2桁の日(01~31)01
d1桁または2桁の日1
HH24時間制の時(00~23)15
hh12時間制の時(01~12)03
mm分(00~59)30
ss秒(00~59)00
fffミリ秒(3桁)123
ttAM/PM 指定PM
zzzタイムゾーンオフセット+09:00

例えば、"yyyy/MM/dd HH:mm:ss"は「2023/04/01 15:30:00」のような形式を表します。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string dateStr = "2023/04/01 15:30:00";
        string format = "yyyy/MM/dd HH:mm:ss";
        DateTime date = DateTime.ParseExact(dateStr, format, CultureInfo.InvariantCulture);
        Console.WriteLine(date.ToString("u")); // 2023-04-01 15:30:00Z
    }
}
2023-04-01 15:30:00Z

フォーマット文字列は正確に入力文字列と一致させる必要があります。

例えば、区切り文字の違いや桁数の違いがあるとFormatExceptionが発生します。

カスタムフォーマットの活用例

カスタムフォーマットを使うと、独自の形式の日時文字列を正確に解析できます。

例えば、タイムゾーン情報やミリ秒を含む複雑なフォーマットも指定可能です。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string dateStr = "2023-04-01T15:30:45.123+09:00";
        string format = "yyyy-MM-dd'T'HH:mm:ss.fffzzz";
        if (DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto))
        {
            Console.WriteLine($"変換成功: {dto}");
            Console.WriteLine($"UTC時間: {dto.UtcDateTime}");
        }
        else
        {
            Console.WriteLine("変換失敗");
        }
    }
}
変換成功: 2023/04/01 15:30:45 +09:00
UTC時間: 2023/04/01 6:30:45

この例では、ISO8601形式に近い日時文字列をDateTimeOffsetで解析しています。

'T'はリテラル文字として指定し、zzzでタイムゾーンオフセットを扱っています。

ミリ秒はfffで表現しています。

カスタムフォーマットは、ログファイルの日時解析や外部システムとのデータ連携で非常に役立ちます。

フォーマットを正確に指定することで、誤った解析やFormatExceptionの発生を防げます。

CultureInfoを用いたグローバル対応

C#のCultureInfoクラスは、アプリケーションのグローバル対応に欠かせない機能です。

数値や日付のフォーマット、文字列比較などがカルチャ(文化圏)によって異なるため、適切にCultureInfoを扱うことで多言語・多地域対応が可能になります。

UIカルチャとスレッドカルチャ

.NETでは、カルチャには主に「UIカルチャ」と「スレッドカルチャ」の2種類があります。

  • スレッドカルチャCurrentCulture

数値や日付のフォーマット、文字列の大文字・小文字変換など、データのローカライズに影響します。

例えば、double.ParseDateTime.ToStringはこのカルチャを参照します。

例:日本のCurrentCultureでは小数点は.、桁区切りは,ですが、ドイツのCurrentCultureでは小数点が,、桁区切りが.になります。

  • UIカルチャCurrentUICulture

リソースファイル(.resx)などのローカライズされた文字列の選択に使われます。

ユーザーインターフェースの言語設定に影響します。

これらはスレッド単位で設定されるため、マルチスレッド環境ではスレッドごとに異なるカルチャを設定可能です。

using System;
using System.Globalization;
using System.Threading;
class Program
{
    static void Main()
    {
        Console.WriteLine($"初期CurrentCulture: {Thread.CurrentThread.CurrentCulture.Name}");
        Console.WriteLine($"初期CurrentUICulture: {Thread.CurrentThread.CurrentUICulture.Name}");
        // スレッドカルチャをドイツに変更
        Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
        // UIカルチャをフランスに変更
        Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR");
        Console.WriteLine($"変更後CurrentCulture: {Thread.CurrentThread.CurrentCulture.Name}");
        Console.WriteLine($"変更後CurrentUICulture: {Thread.CurrentThread.CurrentUICulture.Name}");
        double number = 1234.56;
        Console.WriteLine(number.ToString()); // ドイツ式の小数点・桁区切りで表示
    }
}
初期CurrentCulture: ja-JP
初期CurrentUICulture: ja-JP
変更後CurrentCulture: de-DE
変更後CurrentUICulture: fr-FR
1234,56

このように、CurrentCultureは数値や日付のフォーマットに影響し、CurrentUICultureはUIの言語選択に影響します。

グローバル対応では両者を適切に設定することが重要です。

カスタムカルチャの作成手順

標準のカルチャでは対応できない独自のフォーマットやルールが必要な場合、CultureAndRegionInfoBuilderクラスを使ってカスタムカルチャを作成できます。

このクラスは.NET Frameworkで利用可能で、.NET Coreや.NET 5以降ではサポートされていません。

カスタムカルチャの作成手順は以下の通りです。

  1. 既存のカルチャをベースに作成

例えば、ja-JPをベースにカスタムカルチャを作成します。

  1. 必要なフォーマット情報を変更

数値の小数点や日付の区切り文字などをカスタマイズします。

  1. カスタムカルチャを登録

システムにカスタムカルチャを登録して利用可能にします。

以下は.NET Frameworkでの例です。

using System;
using System.Globalization;
using System.Globalization;
class Program
{
    static void Main()
    {
        var builder = new CultureAndRegionInfoBuilder("ja-JP-Custom", CultureAndRegionModifiers.None);
        var baseCulture = new CultureInfo("ja-JP");
        builder.LoadDataFromCultureInfo(baseCulture);
        // 小数点記号をカンマに変更
        builder.NumberFormat.NumberDecimalSeparator = ",";
        // カスタムカルチャを登録(管理者権限が必要)
        builder.Register();
        // 登録したカスタムカルチャを使用
        var customCulture = new CultureInfo("ja-JP-Custom");
        Console.WriteLine(customCulture.NumberFormat.NumberDecimalSeparator); // ","
    }
}

カスタムカルチャの登録はシステムに影響を与えるため、管理者権限が必要であり、慎重に扱う必要があります。

現在の.NET Core以降の環境では、カスタムカルチャの代わりにCultureInfoのインスタンスをコピーしてNumberFormatDateTimeFormatを直接変更して使う方法が一般的です。

CurrentCulture変更の影響

Thread.CurrentThread.CurrentCultureを変更すると、そのスレッド内での数値や日付のフォーマット、解析処理に影響します。

例えば、double.ParseDateTime.ToStringの挙動が変わります。

using System;
using System.Globalization;
using System.Threading;
class Program
{
    static void Main()
    {
        string numberStr = "1,234.56";
        // 日本カルチャ(ja-JP)での解析(カンマは桁区切り、ドットは小数点)
        Thread.CurrentThread.CurrentCulture = new CultureInfo("ja-JP");
        double numberJP = double.Parse(numberStr);
        Console.WriteLine($"ja-JPで解析: {numberJP}");
        // ドイツカルチャ(de-DE)での解析(カンマは小数点、ドットは桁区切り)
        Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
        try
        {
            double numberDE = double.Parse(numberStr);
            Console.WriteLine($"de-DEで解析: {numberDE}");
        }
        catch (FormatException)
        {
            Console.WriteLine("de-DEでの解析に失敗しました。");
        }
    }
}
ja-JPで解析: 1234.56
de-DEでの解析に失敗しました。

この例では、"1,234.56"は日本カルチャでは正しく解析されますが、ドイツカルチャではカンマが小数点として扱われるため、フォーマットが合わず例外が発生します。

また、CurrentCultureの変更はスレッド単位で影響するため、マルチスレッド環境ではスレッドごとに適切なカルチャを設定する必要があります。

グローバル対応アプリケーションでは、ユーザーのロケールに応じてCurrentCultureCurrentUICultureを設定し、正しい表示や解析を行うことが求められます。

例外処理パターン

C#でFormatExceptionをはじめとする例外を適切に扱うためには、例外処理の基本パターンを理解し、状況に応じて使い分けることが重要です。

ここでは、try-catch-finallyの基本形、例外フィルター句を使った分岐、そしてログ出力とユーザー通知の実装例を紹介します。

try-catch-finallyの基本形

try-catch-finally構文は、例外が発生する可能性のある処理をtryブロックに記述し、例外が発生した場合にcatchブロックで捕捉・処理します。

finallyブロックは例外の有無にかかわらず必ず実行され、リソースの解放などに使います。

using System;
class Program
{
    static void Main()
    {
        try
        {
            Console.WriteLine("数値を入力してください:");
            string input = Console.ReadLine();
            int number = int.Parse(input); // FormatExceptionが発生する可能性あり
            Console.WriteLine($"入力された数値は {number} です。");
        }
        catch (FormatException ex)
        {
            Console.WriteLine("入力が数値の形式ではありません。");
            Console.WriteLine($"エラー詳細: {ex.Message}");
        }
        finally
        {
            Console.WriteLine("処理が終了しました。");
        }
    }
}
数値を入力してください:
abc
入力が数値の形式ではありません。
エラー詳細: Input string was not in a correct format.
処理が終了しました。

この基本形では、例外が発生した場合にユーザーにエラーメッセージを表示し、finallyで後処理を行っています。

finallyはファイルやネットワークのクローズ処理などに使うことが多いです。

フィルター句での分岐

C# 6.0以降では、catch節に条件を指定できる「例外フィルター句」が使えます。

これにより、例外の種類だけでなく、例外の内容や状態に応じて処理を分岐できます。

using System;
class Program
{
    static void Main()
    {
        try
        {
            string input = "abc";
            int number = int.Parse(input);
        }
        catch (FormatException ex) when (ex.Message.Contains("correct format"))
        {
            Console.WriteLine("フォーマットが正しくない入力です。");
        }
        catch (FormatException)
        {
            Console.WriteLine("その他のFormatExceptionが発生しました。");
        }
    }
}
フォーマットが正しくない入力です。

この例では、FormatExceptionのメッセージに特定の文字列が含まれている場合のみ最初のcatchが実行され、それ以外は次のcatchに処理が渡ります。

複雑な例外処理ロジックをシンプルに記述できるため、状況に応じて使い分けると便利です。

ログ出力とユーザー通知

例外発生時には、ユーザーへの通知だけでなく、ログに詳細情報を記録することが重要です。

ログは後から問題の原因を調査する際に役立ちます。

ここでは、簡単なログ出力とユーザー通知の例を示します。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        try
        {
            string input = "abc";
            int number = int.Parse(input);
        }
        catch (FormatException ex)
        {
            // ログファイルに例外情報を書き込む
            File.AppendAllText("error.log", $"{DateTime.Now}: {ex}\n");
            // ユーザーにわかりやすいメッセージを表示
            Console.WriteLine("入力が正しい数値形式ではありません。再度入力してください。");
        }
    }
}
入力が正しい数値形式ではありません。再度入力してください。

この例では、例外の詳細をerror.logファイルに追記しつつ、ユーザーには簡潔でわかりやすいメッセージを表示しています。

実際の開発では、NLogやSerilogなどのログライブラリを使うことで、ログレベルや出力先の管理が容易になります。

これらの例外処理パターンを適切に使い分けることで、FormatExceptionを含む例外発生時のトラブルを最小限に抑え、ユーザー体験を損なわずに堅牢なアプリケーションを構築できます。

よくある誤解と落とし穴

プログラミングでFormatExceptionに遭遇すると、原因の特定や対処に戸惑うことがあります。

ここでは、FormatExceptionと似た例外との違いや、文字列補間やシリアライズ時に起こりやすいミスについて解説します。

FormatExceptionとArgumentExceptionの違い

FormatExceptionArgumentExceptionはどちらも例外の一種ですが、発生する原因や意味合いが異なります。

  • FormatException

文字列の形式が期待されるフォーマットに合致しない場合に発生します。

例えば、int.Parse("abc")DateTime.ParseExactでフォーマットが合わない文字列を変換しようとしたときに投げられます。

つまり「入力データの形式が不正」という意味合いです。

  • ArgumentException

メソッドに渡された引数が無効な場合に発生します。

例えば、Enum.Parseに存在しない列挙値の文字列を渡したり、string.Substringで範囲外のインデックスを指定した場合などです。

こちらは「引数の値自体が不正」という意味合いが強いです。

混同しやすい例として、Enum.Parseは存在しない文字列を渡すとArgumentExceptionを投げますが、int.Parseは不正な文字列でFormatExceptionを投げます。

例外の種類を正しく理解し、適切にキャッチすることが重要です。

文字列補間によるミスフォーマット

C#の文字列補間$"..."は便利ですが、フォーマット指定子の誤りや変数の型により、意図しない文字列が生成されることがあります。

これが原因でFormatExceptionが発生するケースもあります。

例えば、数値のフォーマット指定子に誤りがある場合です。

using System;
class Program
{
    static void Main()
    {
        int number = 123;
        // 正しいフォーマット指定子
        string correct = $"{number:D5}"; // 5桁のゼロ埋め
        Console.WriteLine(correct); // 00123
        // 誤ったフォーマット指定子(存在しない)
        try
        {
            string wrong = string.Format("{0:XZ}", number); // FormatException発生
            Console.WriteLine(wrong);
        }
        catch (FormatException ex)
        {
            Console.WriteLine("フォーマット指定子が不正です。");
            Console.WriteLine(ex.Message);
        }
    }
}
00123
フォーマット指定子が不正です。
Format specifier was invalid.

また、文字列補間内で変数の型が想定外の場合、フォーマットが合わず例外が発生することもあります。

フォーマット指定子は対象の型に適合しているか注意しましょう。

シリアライズ時の暗黙変換

JSONやXMLなどのシリアライズ処理で、オブジェクトのプロパティを文字列に変換する際に暗黙的な型変換が行われます。

このとき、変換対象の文字列が期待する形式でないと、FormatExceptionが発生することがあります。

例えば、日付文字列をDateTime型に変換する際、シリアライズ元の文字列がフォーマットに合わない場合です。

using System;
using System.Text.Json;
class Sample
{
    public DateTime Date { get; set; }
}
class Program
{
    static void Main()
    {
        string json = "{\"Date\":\"2023-13-01T00:00:00\"}"; // 月が13で不正
        try
        {
            var obj = JsonSerializer.Deserialize<Sample>(json);
        }
        catch (FormatException ex)
        {
            Console.WriteLine("シリアライズ時にFormatExceptionが発生しました。");
            Console.WriteLine(ex.Message);
        }
        catch (JsonException ex)
        {
            // JsonSerializerは通常JsonExceptionを投げるためこちらが捕捉されることが多い
            Console.WriteLine("JsonExceptionが発生しました。");
            Console.WriteLine(ex.Message);
        }
    }
}
JsonExceptionが発生しました。
The JSON value could not be converted to System.DateTime. Path: $.Date | LineNumber: 0 | BytePositionInLine: 18.

この例ではFormatExceptionではなくJsonExceptionが発生しますが、内部的には日付のフォーマット不正が原因です。

シリアライズ時の例外はラップされることが多いため、例外の種類を正確に把握し、適切にハンドリングする必要があります。

また、カスタムコンバーターを使って変換処理を制御することで、フォーマット不正による例外を防ぐことも可能です。

これらの誤解や落とし穴を理解しておくことで、FormatExceptionの原因を正確に特定し、適切な対処ができるようになります。

特に例外の種類の違いや文字列補間のフォーマット指定、シリアライズ時の変換処理には注意が必要です。

正規表現での事前バリデーション

FormatExceptionの発生を未然に防ぐために、文字列を型変換する前に正規表現(Regex)で形式をチェックする方法があります。

正規表現を使うことで、入力文字列が期待するパターンに合致しているかを効率的に判定でき、無効なデータを早期に排除できます。

数値チェック用パターン

数値の入力チェックでは、整数や小数、符号付きの数値など、用途に応じて正規表現を使い分けます。

  • 整数(正の整数のみ)

^\d+$

  • 整数(符号付き)

^[+-]?\d+$

  • 小数(符号付き、小数点あり)

^[+-]?(\d+)(\.\d+)?$

  • 指数表記を含む浮動小数点数

^[+-]?(\d+)(\.\d+)?([eE][+-]?\d+)?$ 以下は符号付き小数のチェック例です。

using System;
using System.Text.RegularExpressions;
class Program
{
    static bool IsValidDecimal(string input)
    {
        string pattern = @"^[+-]?(\d+)(\.\d+)?$";
        return Regex.IsMatch(input, pattern);
    }
    static void Main()
    {
        string[] testInputs = { "123", "-123.45", "+0.99", "12a", "1.2.3", "" };
        foreach (var input in testInputs)
        {
            Console.WriteLine($"{input}: {(IsValidDecimal(input) ? "有効" : "無効")}");
        }
    }
}
123: 有効
-123.45: 有効
+0.99: 有効
12a: 無効
1.2.3: 無効
: 無効

このように、正規表現で数値の形式を事前に検証することで、ParseTryParseの失敗を減らせます。

日付チェック用パターン

日付の形式は多様ですが、特定のフォーマットに限定してチェックする場合は正規表現が有効です。

例えば、yyyy/MM/dd形式の日付チェックは以下のようになります。

^\d{4}/(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$

このパターンは、

  • 年は4桁の数字
  • 月は01~12
  • 日は01~31

を表現しています。

ただし、うるう年や月ごとの日数の違いまでは判定できません。

以下はこのパターンを使った例です。

using System;
using System.Text.RegularExpressions;
class Program
{
    static bool IsValidDate(string input)
    {
        string pattern = @"^\d{4}/(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$";
        return Regex.IsMatch(input, pattern);
    }
    static void Main()
    {
        string[] testDates = { "2023/04/01", "2023/13/01", "2023/02/30", "2023-04-01", "20230401" };
        foreach (var date in testDates)
        {
            Console.WriteLine($"{date}: {(IsValidDate(date) ? "有効" : "無効")}");
        }
    }
}
2023/04/01: 有効
2023/13/01: 無効
2023/02/30: 有効
2023-04-01: 無効
20230401: 無効

この例では、日付の基本的な形式はチェックできますが、2023/02/30のような実際には存在しない日付は検出できません。

より厳密な日付検証はDateTime.TryParseなどで行う必要があります。

Regexオプションの選定

正規表現のマッチングにはオプションを指定できます。

代表的なものを紹介します。

  • RegexOptions.IgnoreCase

大文字・小文字を区別しないマッチングを行います。

数値や日付のチェックではあまり使いませんが、アルファベットを含む場合に有効です。

  • RegexOptions.Compiled

正規表現をコンパイルして高速化します。

頻繁に同じパターンを使う場合に効果的です。

  • RegexOptions.CultureInvariant

文化依存の文字クラスを無視してマッチングします。

グローバル対応のアプリケーションで安定した動作を期待できます。

  • RegexOptions.Multiline

複数行の文字列に対して、^$が行頭・行末にマッチするようになります。

単一行の入力チェックでは不要です。

以下はRegexOptions.Compiledを使った例です。

using System;
using System.Text.RegularExpressions;
class Program
{
    static Regex decimalRegex = new Regex(@"^[+-]?(\d+)(\.\d+)?$", RegexOptions.Compiled);
    static bool IsValidDecimal(string input)
    {
        return decimalRegex.IsMatch(input);
    }
    static void Main()
    {
        string input = "123.45";
        Console.WriteLine(IsValidDecimal(input) ? "有効" : "無効");
    }
}
有効

RegexOptions.Compiledは初回のコンパイルにコストがかかりますが、その後のマッチングが高速になるため、パフォーマンスが求められる場面で推奨されます。

正規表現による事前バリデーションは、FormatExceptionの発生を減らし、ユーザー体験の向上やシステムの安定化に寄与します。

ただし、正規表現だけで完全な検証は難しいため、必要に応じてTryParseなどの型変換メソッドと組み合わせて使うことが望ましいです。

データ検証フローの設計

堅牢なアプリケーションを作るためには、データの検証を適切な層で行い、FormatExceptionの発生を未然に防ぐことが重要です。

ここでは、UI層での早期検証、ドメイン層でのフェールファスト設計、そしてFluentValidationを使った検証の連携例を紹介します。

UI層での早期検証

ユーザーが入力したデータは、できるだけ早い段階で検証し、不正な値を受け付けないようにすることが望ましいです。

UI層での検証は、ユーザー体験の向上とサーバー負荷の軽減に寄与します。

例えば、Webフォームやデスクトップアプリの入力欄で、数値や日付の形式チェックを行います。

JavaScriptやWPFのバリデーション機能を使って、入力時にリアルタイムでエラーを表示することが一般的です。

C#のコンソールアプリでの簡単な例を示します。

using System;
class Program
{
    static bool ValidateAge(string input, out int age)
    {
        age = 0;
        if (string.IsNullOrWhiteSpace(input))
        {
            Console.WriteLine("年齢を入力してください。");
            return false;
        }
        if (!int.TryParse(input, out age))
        {
            Console.WriteLine("年齢は数値で入力してください。");
            return false;
        }
        if (age < 0 || age > 150)
        {
            Console.WriteLine("年齢は0から150の範囲で入力してください。");
            return false;
        }
        return true;
    }
    static void Main()
    {
        Console.Write("年齢を入力してください: ");
        string input = Console.ReadLine();
        if (ValidateAge(input, out int age))
        {
            Console.WriteLine($"入力された年齢は {age} 歳です。");
        }
    }
}
年齢を入力してください: abc
年齢は数値で入力してください。

このように、UI層での早期検証により、無効なデータがドメイン層に渡るのを防げます。

ドメイン層でのフェールファスト

UI層での検証があっても、外部APIやデータベースからの入力、プログラム内部の不整合などにより不正なデータがドメイン層に到達する可能性があります。

ドメイン層では「フェールファスト(Fail Fast)」の原則に従い、不正なデータを検知したら即座に例外を投げて処理を中断する設計が推奨されます。

これにより、不正な状態がシステム全体に波及するのを防ぎ、バグの早期発見につながります。

using System;
class Person
{
    private int _age;
    public int Age
    {
        get => _age;
        set
        {
            if (value < 0 || value > 150)
            {
                throw new ArgumentOutOfRangeException(nameof(value), "年齢は0から150の範囲でなければなりません。");
            }
            _age = value;
        }
    }
    public Person(int age)
    {
        Age = age; // セッターで検証される
    }
}
class Program
{
    static void Main()
    {
        try
        {
            var person = new Person(200); // 例外発生
        }
        catch (ArgumentOutOfRangeException ex)
        {
            Console.WriteLine($"エラー: {ex.Message}");
        }
    }
}
エラー: 年齢は0から150の範囲でなければなりません。 (パラメーター名: value)

ドメイン層での厳密な検証により、システムの整合性を保てます。

FluentValidation連携例

FluentValidationは.NETで広く使われるバリデーションライブラリで、宣言的に検証ルールを記述でき、UI層やドメイン層での検証に柔軟に対応します。

FluentValidationのインストール

FluentValidationは、Nugetからインストールする必要があります。

「FluentValidation」と検索してインストールするようにしてください。

dotnet add package FluentValidation

以下は、Personクラスの年齢を検証するFluentValidationの例です。

using System;
using FluentValidation;
class Person
{
    public int Age { get; set; }
}
class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator()
    {
        RuleFor(person => person.Age)
            .InclusiveBetween(0, 150)
            .WithMessage("年齢は0から150の範囲で入力してください。");
    }
}
class Program
{
    static void Main()
    {
        var person = new Person { Age = 200 };
        var validator = new PersonValidator();
        var result = validator.Validate(person);
        if (!result.IsValid)
        {
            foreach (var failure in result.Errors)
            {
                Console.WriteLine($"検証エラー: {failure.ErrorMessage}");
            }
        }
        else
        {
            Console.WriteLine("検証成功");
        }
    }
}
検証エラー: 年齢は0から150の範囲で入力してください。

FluentValidationを使うと、検証ルールを集中管理でき、UI層やAPI層、ドメイン層で共通の検証ロジックを再利用しやすくなります。

また、ASP.NET Coreなどのフレームワークと連携して自動的にバリデーションを行うことも可能です。

これらの検証フローを適切に設計し、UI層での早期検証とドメイン層での厳密な検証を組み合わせることで、FormatExceptionの発生を抑え、堅牢で保守性の高いシステムを構築できます。

さらにFluentValidationのようなライブラリを活用すると、検証ロジックの一元管理と再利用が容易になります。

ログと診断の推奨方法

例外発生時のログ記録と診断は、問題の早期発見と解決に不可欠です。

特にFormatExceptionのような変換エラーは、ユーザー入力や外部データの不整合が原因となることが多いため、詳細なログを残すことが重要です。

ここでは、代表的なログライブラリであるNLogとSerilogを使った例外記録、スタックトレースの保存戦略、そして例外の再スロー方法の違いについて解説します。

NLog/Serilogでの例外記録

NLogでの例外ログ記録

NLogは柔軟で設定が簡単な.NET向けのログライブラリです。

例外をログに記録する際は、Logger.Error(Exception ex, string message)メソッドを使うことで、例外の詳細情報を含めてログ出力できます。

using System;
using NLog;
class Program
{
    private static readonly Logger logger = LogManager.GetCurrentClassLogger();
    static void Main()
    {
        try
        {
            int.Parse("abc"); // FormatException発生
        }
        catch (FormatException ex)
        {
            logger.Error(ex, "数値変換に失敗しました。入力値が不正です。");
            Console.WriteLine("エラーが発生しました。ログを確認してください。");
        }
    }
}

NLogの設定ファイルNLog.configでログの出力先やフォーマットを指定できます。

例外のスタックトレースも自動的に記録されるため、トラブルシューティングに役立ちます。

Serilogでの例外ログ記録

Serilogは構造化ログに強みを持つライブラリで、ログの検索や分析に適しています。

例外をログに含めるには、Log.Error(Exception ex, string messageTemplate, params object[] propertyValues)を使います。

using System;
using Serilog;
class Program
{
    static void Main()
    {
        Log.Logger = new LoggerConfiguration()
            .WriteTo.Console()
            .WriteTo.File("log.txt")
            .CreateLogger();
        try
        {
            int.Parse("abc"); // FormatException発生
        }
        catch (FormatException ex)
        {
            Log.Error(ex, "数値変換に失敗しました。入力値が不正です。");
            Console.WriteLine("エラーが発生しました。ログを確認してください。");
        }
        finally
        {
            Log.CloseAndFlush();
        }
    }
}

SerilogはJSON形式などの構造化ログも簡単に出力でき、ログ解析ツールとの連携が容易です。

スタックトレースの保存戦略

例外のスタックトレースは、どのコードのどの行で例外が発生したかを示す重要な情報です。

ログにスタックトレースを含めることで、原因の特定が迅速になります。

  • 完全なスタックトレースを保存する

例外オブジェクトのex.ToString()ex.StackTraceをログに含めることで、詳細な情報を残せます。

NLogやSerilogは例外オブジェクトを渡すだけで自動的にスタックトレースを記録します。

  • ログのサイズとプライバシーに配慮する

スタックトレースは詳細ですがログサイズが大きくなるため、ログローテーションや圧縮を設定しましょう。

また、スタックトレースに含まれるパスやコードの詳細が機密情報になる場合は、マスクやフィルタリングを検討します。

  • 例外のネスト(InnerException)も記録する

複数の例外が連鎖している場合、InnerExceptionも含めて記録することで根本原因の追跡が容易になります。

throw;とthrow ex;の違い

例外をキャッチした後に再スローする際、throw;throw ex;の使い分けは重要です。

  • throw;

現在キャッチしている例外をそのまま再スローします。

スタックトレースは保持され、例外発生箇所の情報が失われません。

  • throw ex;

例外オブジェクトを指定して再スローしますが、この場合スタックトレースがリセットされ、再スローした箇所が例外発生箇所として記録されてしまいます。

以下のコードで違いを確認できます。

using System;
class Program
{
    static void MethodA()
    {
        try
        {
            int.Parse("abc");
        }
        catch (FormatException ex)
        {
            // throw; // スタックトレースを保持して再スロー
            throw ex; // スタックトレースがリセットされる
        }
    }
    static void Main()
    {
        try
        {
            MethodA();
        }
        catch (FormatException ex)
        {
            Console.WriteLine("例外メッセージ: " + ex.Message);
            Console.WriteLine("スタックトレース:\n" + ex.StackTrace);
        }
    }
}

throw;を使うと、例外発生元のint.Parseの行番号がスタックトレースに残りますが、throw ex;だとthrow ex;の行が例外発生箇所として記録されてしまいます。

トラブルシューティングのためにはthrow;を使うことが推奨されます。

これらのログと診断のポイントを押さえることで、FormatExceptionを含む例外発生時の原因特定がスムーズになり、迅速な問題解決につながります。

適切なログ記録と例外の再スロー方法を実践しましょう。

単体テストによる再現と検証

FormatExceptionの発生を確実に検証し、再発を防ぐためには単体テストが欠かせません。

ここでは、.NETの代表的なテストフレームワークであるxUnitを使った例外検証方法、境界値を意識したテストデータの設計、そして外部入力をモック化してテストを安定させる方法を解説します。

xUnitでAssert.Throwsを使う

xUnitでは、特定のコードが例外を投げることを検証するためにAssert.Throws<TException>メソッドが用意されています。

FormatExceptionが発生することを期待するテストケースを簡潔に記述できます。

using System;
using Xunit;
public class FormatExceptionTests
{
    [Fact]
    public void IntParse_InvalidString_ThrowsFormatException()
    {
        // Arrange
        string invalidInput = "abc";
        // Act & Assert
        var ex = Assert.Throws<FormatException>(() => int.Parse(invalidInput));
        Assert.Equal("Input string was not in a correct format.", ex.Message);
    }
}

このテストでは、int.Parseに不正な文字列を渡した際にFormatExceptionが発生することを検証しています。

Assert.Throwsは例外が発生しなかった場合にテストを失敗させるため、例外の発生を確実に検証できます。

境界値テストデータ

FormatExceptionの発生を検証する際は、単に不正な文字列だけでなく、境界値や特殊ケースもテストデータに含めることが重要です。

これにより、想定外の入力に対する堅牢性を高められます。

例として、数値変換の境界値テストを考えます。

テスト入力期待結果備考
“0”変換成功最小の非負整数
“-1”変換成功負の整数
“2147483647”変換成功int.MaxValue
“2147483648”OverflowException発生intの範囲外
“”FormatException発生空文字
” “FormatException発生空白文字
“123abc”FormatException発生数字と文字の混在

境界値を含めたテストケースを用意することで、例外処理の抜け漏れを防げます。

外部入力のモック化

外部からの入力(ファイル、データベース、Web APIなど)を直接テストに使うと、環境依存や不安定なテスト結果の原因になります。

そこで、外部入力をモック化(模擬オブジェクト化)して安定したテストを実現します。

例えば、CSVファイルから数値を読み込む処理をテストする場合、ファイルI/Oをモックして文字列配列などで代替します。

using System;
using System.Collections.Generic;
using Xunit;
public class CsvProcessor
{
    public List<int> ParseNumbers(IEnumerable<string> lines)
    {
        var results = new List<int>();
        foreach (var line in lines)
        {
            results.Add(int.Parse(line)); // FormatExceptionが発生する可能性あり
        }
        return results;
    }
}
public class CsvProcessorTests
{
    [Fact]
    public void ParseNumbers_InvalidData_ThrowsFormatException()
    {
        // Arrange
        var processor = new CsvProcessor();
        var mockData = new List<string> { "100", "abc", "200" };
        // Act & Assert
        Assert.Throws<FormatException>(() => processor.ParseNumbers(mockData));
    }
}

このように、外部依存を排除してテスト対象のロジックだけを検証できるため、テストの信頼性と実行速度が向上します。

単体テストでFormatExceptionの発生を再現・検証することで、例外処理の正確性を担保し、品質の高いコードを維持できます。

xUnitのAssert.Throwsを活用し、境界値を含む多様なテストデータを用意し、外部入力はモック化して安定したテスト環境を構築しましょう。

パフォーマンス評価

文字列から数値や日付への変換は頻繁に行われる処理であり、特に大量データの処理やリアルタイム性が求められる場面ではパフォーマンスが重要です。

ここでは、.NETのベンチマークツールであるBenchmarkDotNetを使った計測手順、ParseTryParseの速度比較、そしてCultureInfo指定によるオーバーヘッドについて解説します。

BenchmarkDotNetの利用手順

BenchmarkDotNetは.NET向けの高精度ベンチマークライブラリで、簡単にメソッドの実行速度を計測できます。

利用手順は以下の通りです。

  1. プロジェクトにBenchmarkDotNetを導入

NuGetパッケージマネージャーでBenchmarkDotNetをインストールします。

Install-Package BenchmarkDotNet
  1. ベンチマーククラスを作成

計測したいメソッドを[Benchmark]属性でマークします。

  1. Mainメソッドでベンチマークを実行

BenchmarkRunner.Run<T>()でベンチマークを開始します。

以下は簡単なサンプルコードです。

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class ParseBenchmarks
{
    private string validNumber = "12345";
    private string invalidNumber = "12a45";
    [Benchmark]
    public int Parse_Valid()
    {
        return int.Parse(validNumber);
    }
    [Benchmark]
    public bool TryParse_Valid()
    {
        return int.TryParse(validNumber, out _);
    }
    [Benchmark]
    public int Parse_Invalid()
    {
        try
        {
            return int.Parse(invalidNumber);
        }
        catch
        {
            return -1;
        }
    }
    [Benchmark]
    public bool TryParse_Invalid()
    {
        return int.TryParse(invalidNumber, out _);
    }
}
class Program
{
    static void Main()
    {
        var summary = BenchmarkRunner.Run<ParseBenchmarks>();
    }
}

このコードを実行すると、各メソッドの平均実行時間やメモリ使用量など詳細なレポートがコンソールに表示されます。

ParseとTryParseの速度比較

Parseは変換失敗時に例外を投げるため、例外処理のオーバーヘッドが発生します。

一方、TryParseは例外を投げずに失敗を返すため、失敗時のパフォーマンスが大幅に向上します。

  • 成功時の比較

成功時はParseTryParseの速度差はほとんどありません。

どちらも高速に変換を行います。

  • 失敗時の比較

失敗時はParseが例外を投げるため、例外オブジェクトの生成やスタックトレースの作成にコストがかかり、TryParseよりも数十倍遅くなることがあります。

BenchmarkDotNetの結果例(概算):

メソッド成功時平均時間 (ns)失敗時平均時間 (ns)
int.Parse202000
int.TryParse2550

このため、失敗が予想される入力を扱う場合はTryParseを使うことがパフォーマンス上のベストプラクティスです。

CultureInfo指定のオーバーヘッド

ParseTryParseにはCultureInfoを指定でき、カルチャ依存のフォーマットに対応可能です。

しかし、CultureInfoを指定すると内部でカルチャ情報の解釈やフォーマットの適用が行われるため、若干のオーバーヘッドが発生します。

以下はInvariantCultureを指定した場合と、指定しない場合の簡単な比較例です。

using System;
using System.Globalization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class CultureParseBenchmarks
{
    private string numberStr = "1,234.56";
    private CultureInfo invariant = CultureInfo.InvariantCulture;
    private CultureInfo jp = new CultureInfo("ja-JP");
    [Benchmark]
    public double Parse_DefaultCulture()
    {
        return double.Parse(numberStr);
    }
    [Benchmark]
    public double Parse_InvariantCulture()
    {
        return double.Parse(numberStr, invariant);
    }
    [Benchmark]
    public double Parse_JPCulture()
    {
        return double.Parse(numberStr, jp);
    }
}
class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<CultureParseBenchmarks>();
    }
}

このベンチマークでは、CultureInfoを指定しない場合が最も高速で、InvariantCultureや特定カルチャを指定すると数パーセント程度の遅延が発生することが多いです。

ただし、実際のアプリケーションではカルチャ指定の正確性が優先されるため、パフォーマンスと正確性のバランスを考慮して使い分ける必要があります。

パフォーマンス評価を通じて、ParseTryParseの使い分けやCultureInfo指定の影響を理解し、最適な変換処理を設計しましょう。

BenchmarkDotNetは詳細な計測とレポートを提供するため、パフォーマンスチューニングに非常に有用です。

サンプルコード集

実際の開発現場でよくあるシナリオに沿って、FormatExceptionを防ぎつつ安全に数値変換を行うサンプルコードを紹介します。

Web APIでのJSON受け取り、テキストファイルのバッチ処理、設定ファイル読み込み時の検証の3つのケースを取り上げます。

Web APIで受け取るJSONの数値変換

Web APIでクライアントからJSONデータを受け取る際、数値フィールドの形式が不正だとFormatExceptionJsonExceptionが発生することがあります。

安全に変換するために、DTO(データ転送オブジェクト)で文字列として受け取り、コントローラーやサービス層でTryParseを使って検証・変換する方法が有効です。

using System;
using Microsoft.AspNetCore.Mvc;
public class InputDto
{
    public string Quantity { get; set; } // 数値を文字列で受け取る
}
[ApiController]
[Route("api/[controller]")]
public class SampleController : ControllerBase
{
    [HttpPost("process")]
    public IActionResult ProcessData([FromBody] InputDto input)
    {
        if (!int.TryParse(input.Quantity, out int quantity))
        {
            return BadRequest("Quantityは有効な整数で入力してください。");
        }
        // 変換成功後の処理
        return Ok(new { Message = $"受け取った数量は {quantity} です。" });
    }
}

この例では、Quantityを文字列で受け取り、TryParseで安全に整数に変換しています。

変換失敗時はHTTP 400 Bad Requestを返し、クライアントにエラーを通知します。

テキストファイルのバッチ処理

大量のテキストファイルをバッチ処理する場合、ファイル内の数値データに不正な文字列が混入していることがあります。

TryParseを使って変換を試み、失敗した行はログに記録してスキップする例を示します。

using System;
using System.IO;
class BatchProcessor
{
    public void ProcessFile(string filePath)
    {
        int lineNumber = 0;
        foreach (var line in File.ReadLines(filePath))
        {
            lineNumber++;
            if (int.TryParse(line, out int value))
            {
                Console.WriteLine($"Line {lineNumber}: 数値 {value} を処理しました。");
                // ここに処理ロジックを記述
            }
            else
            {
                Console.WriteLine($"Line {lineNumber}: 不正な数値データ '{line}' をスキップしました。");
                // ログファイルに記録することも推奨
            }
        }
    }
}
class Program
{
    static void Main()
    {
        var processor = new BatchProcessor();
        processor.ProcessFile("numbers.txt");
    }
}
Line 1: 数値 100 を処理しました。
Line 2: 不正な数値データ 'abc' をスキップしました。
Line 3: 数値 200 を処理しました。

この方法で、例外を発生させずに不正データを検出しつつ処理を継続できます。

設定ファイル読み込み時の検証

アプリケーションの設定ファイル(例:JSONやXML)から数値や日付を読み込む際、フォーマット不正による例外を防ぐために、読み込み後にTryParseで検証することが重要です。

以下はJSON設定ファイルを読み込み、数値設定値を検証する例です。

using System;
using System.IO;
using System.Text.Json;
public class AppSettings
{
    public string TimeoutSeconds { get; set; }
}
class Program
{
    static void Main()
    {
        string json = File.ReadAllText("appsettings.json");
        var settings = JsonSerializer.Deserialize<AppSettings>(json);
        if (!int.TryParse(settings.TimeoutSeconds, out int timeout))
        {
            Console.WriteLine("設定ファイルのTimeoutSecondsが不正です。デフォルト値を使用します。");
            timeout = 30; // デフォルト値
        }
        Console.WriteLine($"タイムアウト設定: {timeout}秒");
    }
}

appsettings.jsonの例:

{
    "TimeoutSeconds": "60"
}

この例では、設定値を文字列として受け取り、TryParseで検証しています。

不正な値の場合はデフォルト値を使うことで、例外発生を防ぎつつ安全に設定を適用できます。

これらのサンプルコードは、実務でよく遭遇するシナリオに対応した安全な数値変換の実装例です。

TryParseを活用し、例外を未然に防ぐ設計を心がけましょう。

リファクタリング事例

既存コードでFormatExceptionが頻発している場合や、同様の変換処理が複数箇所に散在している場合は、TryParseへの置換や共通化を行うことでコードの安全性と保守性を大幅に向上できます。

ここでは、例外多発コードをTryParseに置換する事例と、変換処理の共通化による再利用性向上の具体例を紹介します。

例外多発コードをTryParseに置換

従来のコードでは、int.ParseDateTime.Parseを使い、変換失敗時にFormatExceptionをキャッチして処理しているケースが多く見られます。

例外処理はコストが高く、例外が頻発するとパフォーマンス低下やコードの可読性低下を招きます。

以下は例外多発コードの典型例です。

using System;
class Program
{
    static void Main()
    {
        string[] inputs = { "100", "abc", "200" };
        foreach (var input in inputs)
        {
            try
            {
                int value = int.Parse(input);
                Console.WriteLine($"変換成功: {value}");
            }
            catch (FormatException)
            {
                Console.WriteLine($"変換失敗: '{input}' は数値ではありません。");
            }
        }
    }
}

このコードは動作しますが、abcのような不正入力があるたびに例外が発生し、パフォーマンスに悪影響を与えます。

これをTryParseに置換すると以下のようになります。

using System;
class Program
{
    static void Main()
    {
        string[] inputs = { "100", "abc", "200" };
        foreach (var input in inputs)
        {
            if (int.TryParse(input, out int value))
            {
                Console.WriteLine($"変換成功: {value}");
            }
            else
            {
                Console.WriteLine($"変換失敗: '{input}' は数値ではありません。");
            }
        }
    }
}

TryParseは例外を投げずに変換の成否を返すため、例外処理のオーバーヘッドがなくなり、パフォーマンスが向上します。

また、コードがシンプルで読みやすくなります。

共通化による再利用性向上

複数箇所で同様の変換処理を行っている場合、共通メソッドや拡張メソッドにまとめることで再利用性が向上し、保守性も高まります。

以下は、文字列から整数への安全な変換を行う拡張メソッドの例です。

using System;
public static class StringExtensions
{
    public static int? ToNullableInt(this string s)
    {
        return int.TryParse(s, out int result) ? result : (int?)null;
    }
}
class Program
{
    static void Main()
    {
        string[] inputs = { "100", "abc", "200" };
        foreach (var input in inputs)
        {
            int? value = input.ToNullableInt();
            if (value.HasValue)
            {
                Console.WriteLine($"変換成功: {value.Value}");
            }
            else
            {
                Console.WriteLine($"変換失敗: '{input}' は数値ではありません。");
            }
        }
    }
}

この拡張メソッドを使うことで、変換処理が一箇所に集約され、コードの重複を防げます。

将来的に変換ロジックを変更したい場合も、このメソッドを修正するだけで済みます。

さらに、日付や他の型に対しても同様の共通化を行うことで、プロジェクト全体のコード品質が向上します。

このように、例外多発コードをTryParseに置換し、変換処理を共通化するリファクタリングは、パフォーマンス改善と保守性向上に直結します。

既存コードの見直し時には積極的に取り入れたい手法です。

まとめ

この記事では、C#のFormatExceptionの原因や対処法を中心に、TryParseの活用や例外処理パターン、カルチャ対応、単体テスト、パフォーマンス評価、リファクタリング事例まで幅広く解説しました。

特にTryParseを使った安全な型変換や共通化による保守性向上が重要です。

適切な例外処理とログ記録、正規表現による事前バリデーションを組み合わせることで、堅牢で効率的なコードが実現できます。

関連記事

Back to top button