例外処理を怖がらない! 今日から使えるエラー対処の考え方

サーバールームで技術者が懐中電灯を照らし、エラー表示のあるサーバーラックを確認しているイラスト。 みなさんこんにちは、おさかなです。 みなさんは例外処理について意識してコードを記述していますか? ソフトウェア開発において、例外処理は決して避けて通れない重要なテーマです。どれほど丁寧に設計されたシステムであっても、予期せぬ状況や例外が発生する可能性は常に存在します。適切な例外処理は、単にクラッシュを防ぐだけでなく、ユーザー体験を向上させ、システムの信頼性を高め、トラブルシューティングを容易にします。本記事では、例外処理の基本的な考え方から、私が実践している例外処理の考え方を解説します。参考にしていただければ幸いです。

エラーと例外

エラーと例外の違い

エラーと例外は似た言葉として扱われますが、本来は別の意味を持っています。

  • エラー(Error):システム的な問題で、プログラムの継続が難しく、開発側でも対処しにくいもの
    例:メモリ不足、スタックオーバーフローなど)
  • 例外(Exception):特定の条件で起きる予測可能な問題で、適切に処理すれば回避・復旧が可能なもの
    例:ファイル未発見、ゼロ除算など)

つまり、例外はコードで対応できる問題、エラーは対応が難しい深刻な問題と考えると分かりやすいかと思います。

エラー/例外の種類

ソフトウェア開発では、「エラー」や「例外」と一口に言っても、その種類や発生するタイミング、原因はさまざまです。ここでは、よく耳にする代表的な5種類のエラーを交えて紹介します。なお、ここで言う「エラー」という言葉は、プログラム実行中に発生するあらゆる異常を指す広義の概念であり、先に挙げた「プログラムの継続が難しい致命的なエラー」とは意味が異なる点にご注意ください。

エラーと例外
エラーと例外

プログラムで発生する異常は、大きく 「コンパイルエラー」「ランタイムエラー」「ロジックエラー」 の3つに分類できます。 コンパイルエラー は、プログラムを実行する前の コンパイル段階で検出されるエラー です。文法の誤りや型の不整合などが原因で、プログラムは実行可能な状態になりません。コンパイルエラーには、さらにシンタックスエラーとセマンティックエラーがあります。シンタックスエラーは文法ミスによってコンパイルができない状態であり、セマンティックエラーは文法的には正しいものの、意味的にコンパイルできないエラーを指します。

ランタイムエラー は、プログラムの 実行中に発生するエラー の総称です。実行時の環境・入力データ・リソース不足などによって、プログラムが途中で異常終了したり例外が発生します。ランタイムエラーの中に先ほど紹介した例外やエラーが含まれます。以下にランタイムエラーとしてよく遭遇する代表的な例外を表に示します。

ランタイムエラーの種類
ランタイムエラーの種類

このように、ランタイムエラーは「文法上は正しいが、実行すると問題が起きる」タイプのエラーであり、プログラミングにおける多くの例外クラスがここに分類されます。

ロジックエラー は、コンパイルも実行も問題なく行えるものの、処理内容そのものに誤りがあり、意図した結果が得られない エラーです。設計や実装のミスによって起きるもので、動作はするが正しい結果が出ないタイプの不具合をすべて含みます。そのため開発におけるあらゆる段階でロジックエラーが入りこむ可能性があります。なお、ロジックエラーが原因で最終的にランタイムエラーを招くこともありますが、本説明では両者を別の分類として扱っています。このように、それぞれのエラーの違いを理解することは、デバッグ効率を高め、安定したアプリケーションを作るうえで非常に重要です。

エラー処理のすすめ

ここまでエラーや例外の概要を説明してきましたが、実際にこれらが発生したときに、どのように考え、どのように対処すればよいのかを、私が普段実践している方法を交えて紹介します。ここでは、システム運用中に特に多く発生する「ランタイムエラー」に焦点を当てて解説します。

エラーには、対応すれば処理を継続できる「回復可能なエラー」と、処理を続行しても正しい結果が保証できない「回復不能なエラー」があります。前者はリトライや入力値の補正で改善できますが、後者は設計ミスや重大なロジックエラーなどが原因で、システムの安全性を保つためにも即時停止が求められます。 エラーが発生した際には、まず原因を正しく把握し、システム特性に合った対応を選択することが重要です。私が開発現場でエラー対応を行う際に検討している流れを、下図にまとめています。

例外処理の考え方
例外処理の考え方

  1. 発生するエラーが回復できるものか?
    エラーが発生すると、まずはそのエラーが回復可能なものなのか、回復できないものなのかを考えます。
    回復可能なエラーと回復不能なエラーの例としては下記が挙げられます。

    • 回復可能なエラー
      • 適切に処理すればプログラムを正常実行できるエラー
        • ファイルが見つからない(FileNotFoundException)
          ユーザーがファイルを選択していない、または移動した
          → 再選択させる、デフォルト値で代替する
        • 通信に失敗した(HttpRequestException など)
          ネットワーク不安定、サーバーにアクセスできない、URL 間違い
          → 再試行、接続先変更、オフラインモードへの切替
        • ユーザーの入力ミス
          誤った形式で入力した、必須項目が空欄
          → 入力チェック後に再入力を促す
    • 回復不能なエラー
      • プログラム内部状態が壊れているなど再試行しても解決しない
        • OutOfMemoryException(メモリ枯渇)
          → 基本的に復旧不能
        • StackOverflowException
          → 深すぎる再帰など構造的欠陥
        • ファイルシステム壊損・データ破損
          → 設定ファイル破損・DB 破壊などは復旧困難

    実装しているシステム環境にも依存しますが、 一般的にユーザー入力ミスは警告で対応可能で、 メモリ枯渇やファイルシステム破損などはプログラム停止が望ましい場合が多いです。

  2. 回復すべきエラーか?
    回復できるとしても回復することでシステムに不都合が生じる場合があります。回復すべきでないエラーの内容としては以下が挙げられます。

    • データ不整合が生じるエラー
    • セキュリティ関連のエラー など

    例えば、データ不整合を起こしたまま動かし続けると後続のデータにも影響を及ぼしますし、セキュリティが担保できないまま動かすと脆弱性につながります。そのため、これらの場合はログを出力してシステムを停止させることを推奨します。

  3. エラー発生時に処理が必要か?
    回復すると決めたときに、どのように例外処理を実装するか考えます。エラーが発生したときの対応方法としては大きく2つの実装が考えられますので、ファイル読み込みのコードを例に説明します。以下のコードはC#で書かれたコードで、Main関数の中でファイル読み込みを行うReadFile関数を呼び出しますが、ReadFile関数はファイルが存在しない場合にFileNotFoundExceptionをスローする可能性があります。

①例外発生側の関数では何も行わずにその上位側の関数が例外を取得する方法

using System;
using System.IO;

class Program
{
    static void Main()
    {
        try
        {
            ReadFile("nonexistent.txt");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine("上位で例外を捕捉: " + ex.Message);
        }
    }

    // このメソッドでは例外を捕まえず、そのまま呼び出し元に伝える
    static void ReadFile(string filePath)
    {
        // ファイルが存在しなければ FileNotFoundException がスローされる
        string text = File.ReadAllText(filePath);
        Console.WriteLine(text);
    }
}

ReadFile関数内ではtry-catchをせずに上位のMain関数で例外処理を行っています。この手法では以下のメリット・デメリットがあります。

  • メリット
    • 下位の関数(ReadFile)は処理の詳細のみに集中できる
    • 例外処理の方針を上位で統一できる
  • デメリット
    • ReadFile 関数がどのようなエラーが発生しうるのか呼び出し側が分かりにくい

②例外発生側とその上位側の両方の関数で例外取得する方法

using System;
using System.IO;

class Program
{
    static void Main()
    {
        try
        {
            ReadFile("nonexistent.txt");
        }
        catch (Exception ex)
        {
            Console.WriteLine("上位で例外を捕捉: " + ex.Message);
        }
    }

    static void ReadFile(string filePath)
    {
        try
        {
            string text = File.ReadAllText(filePath);
        }
        catch (FileNotFoundException ex)
        {
            // ログなどの処理はできるが、例外は上位に伝える
            Console.WriteLine("ファイル処理で問題発生: " + ex.Message);
            throw; // 再スロー
        }
    }
}

ReadFile関数内で例外をtry-catchして取得し、その内部でログ出力した後に改めて上位にエラーを再スローしています。また、Main関数でも例外処理を行っています。この手法では以下のメリット・デメリットがあります。

  • メリット
    • 例外が発生したタイミングでログ出力や追加の終了処理が可能
    • 例外処理の方針を上位で統一できる
  • デメリット
    • 再スローの方法によってはデバッグがやりづらくなる
      • throw exで再スローするとスタックトレースが書き換わってしまう
    • Main関数とReadFile関数の両方で例外処理を行っているためコードが冗長になる

どちらを選択するかですが、私は基本的には1の方法で実装を検討し、例外発生時に追加の処理が必要であれば2の手法を選ぶようにしています。1の手法の方がコードはシンプルになり、例外処理を上位と下位の関数で分離することができるので構造が分かりやすくなると感じています。

一方で、どの手法を用いて例外処理を行うかはチームの方針や使用している言語にも大きく依存するのでその場に適した手法を選択することが重要です。また、1の方法を選択する場合は下位の関数がどのようなエラーを発生するか使用する側が判断しづらいので、関数のXMLコメント部分に例外を通知するコメントを追加すると分かりやすくなり有効です。

/// <summary>
/// ファイルを読み込む
/// </summary>
/// <exception cref="FileNotFoundException">ファイルが存在しない場合にスローされます</exception>
static void ReadFile(string filePath)

例外処理で避けるべきパターン

例外処理の考え方や実装について紹介しましたが、バグの原因になる「良くない例外処理」も存在します。ここではよく開発現場でも見られる2つの良くない例をコードとともに紹介します。

例外を握りつぶしてしまう

try-catchで例外を取得した場合に例外をスローせずに何も処理を行わない場合がこのケースになります。

 static void ReadFile(string filePath)
    {
        try
        {
            string text = File.ReadAllText(filePath);
        }
        catch (FileNotFoundException ex)
        {
        //FileNotFoundExceptionを取得しても何も行わない
        }
    }

例外処理のコードを書く必要がなくなるので実装は楽ですが、エラーを発生させなければ使用者は処理に成功したと勘違いする原因になります。こうなるとコードの誤りに気づけず予期せぬバグの温床になります。 最低限の処理としてログの出力処理を記述することをおすすめします。

 static void ReadFile(string filePath)
    {
        try
        {
            string text = File.ReadAllText(filePath);
        }
        catch (FileNotFoundException ex)
        {
            // エラー発生
            Console.WriteLine($"ファイル処理で問題発生: {ex.Message}");
        }
    }

処理結果のステータスをマジックバリューなどで返す

処理結果に数字を割り当て、それを結果としてreturnする実装がこれに当たります。

    static int ReadFile(string filePath)
    {
        try
        {
            string text = File.ReadAllText(filePath);
            return 0;   // 0 = 成功
        }
        catch (FileNotFoundException ex)
        {
            return -1;  // -1 = ファイルが存在しないエラー
        }
        catch (TimeoutException ex)
        {
              return -2;  // -2 = タイムアウト
        }
        catch (Exception ex)
        {
            return -999; // -999 = 不明なエラー
        }
    }

マジックナンバーを使用しているため可読性が低下し、タイプミスによるバグの原因になるほか、保守性を損なう可能性もあります。 可読性や保守性を向上させるために、マジックナンバーではなく列挙体などを使用して実装することをおすすめします。

public enum FileReadResult
{
    Success,
    FileNotFound,
    Timeout,
    UnknownError
}

static FileReadResult ReadFile(string filePath)
{
    try
    {
        string text = File.ReadAllText(filePath);
        return FileReadResult.Success;
    }
    catch (FileNotFoundException)
    {
        return FileReadResult.FileNotFound;
    }
    catch (TimeoutException)
    {
        return FileReadResult.Timeout;
    }
    catch
    {
        return FileReadResult.UnknownError;
    }
}

例外処理実装のステップアップ

一部の言語では「検査例外(Checked Exception)」という、コンパイル時に必ず例外の対処を求める記述方法をサポートしています。ファイル操作やネットワーク通信など外部リソースを扱う処理では環境によって失敗する可能性があるので、開発者に対して例外対処を意識して実装させることができます。

例えば、ファイルを読み込む際には、ファイルが存在しない・アクセス権がないといった問題が起こり得ます。このような状況はプログラム側では完全に防ぎきれないため、それらを検査例外として扱い、try-catch で処理するか、メソッドに throws を付けて呼び出し元へ伝える必要があります。以下は、検査例外をサポートしているJavaでの IOException を扱う例です。検査例外を使用すると、これらの例外を捕捉しないとコンパイルが通らないという特徴があります。

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class FileLoader {

    // 検査例外 IOException を throws で宣言
    static String readFile(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        try {
            return reader.readLine();
        } finally {
            reader.close();
        }
    }

    public static void main(String[] args) {
        try {
            String text = readFile("data.txt");
            System.out.println("内容: " + text);
        } catch (IOException e) {
            // 呼び出し元は必ず例外処理を書く必要がある
            System.out.println("ファイル読み込みに失敗しました: " + e.getMessage());
        }
    }
}

このように検査例外は、外部リソースを扱う処理で発生し得る不確実性を明確にし、使用者に適切な対処を求めることで安全性を高める役割を果たしています。 なお、C#、C++には検査例外が存在しないため、これまでに紹介したようなエラー内容をドキュメントや関数コメントで明示する、メソッド名やクラス名で示唆するなどの工夫をすることが重要です。

本稿での紹介は割愛しますが、検査例外以外にも言語によっては例外を使わずに成功・失敗を表現できるResult型をサポートしているものもあり、Result型を使用した実装も有効な手段です。

まとめ

本記事では、例外処理の基礎から、実際のコードで役立つ具体的な考え方まで幅広く紹介してきました。例外は「いつか起きるかもしれないもの」ではなく、「必ずどこかで発生するもの」と捉えることで、コードの質は大きく変わります。例外処理の考え方、try-catch の活用方法、検査例外の意義、外部リソースを扱う際の注意点など、日々の開発で直面しやすいポイントを押さえておくことで、予期せぬトラブルにも落ち着いて対応することができます。しっかりとした例外処理は、ユーザー体験の向上にもつながり、システム全体の信頼性を高める重要な要素です。これを機に、普段のコードに「例外をどう扱うか」という視点を取り入れていただければ幸いです。 それでは次の記事でお会いしましょう!