クリーンコード【エラー処理編】

はじめに

本記事では、Robert C. Martinの名著『Clean Code』の第7章「エラー処理」に関して、自分が大切だと感じた箇所を、具体的なコードを追加しながらまとめました。本書にはさらに詳細なベストプラクティスが含まれていますので、興味がある方はぜひお読みください。

イントロダクション

エラー処理はとても重要ですが、本来のロジックが不明瞭になることは避けねばなりません。

リターンコードではなく、例外を使用する

以下のコードは、戻り値でエラーコードを用いた例です。

public class EngineController {

    public enum ErrorCode {
        SUCCESS,
        CONFIG_LOAD_ERROR,
        SCHEDULER_INIT_ERROR,
        DOWNLOADER_INIT_ERROR,
        ENGINE_RUN_ERROR,
        UNKNOWN_ERROR
    }

    public static void startEngine(String configName) {
        ErrorCode result = tryToStartEngine(configName);
        if (result != ErrorCode.SUCCESS) {
            handleErrorCode(result);
        }
    }

    private static ErrorCode tryToStartEngine(String configName) {
        Engine engine = new Engine();
        ErrorCode initResult = initializeEngine(configName, engine);
        if (initResult != ErrorCode.SUCCESS) {
            return initResult; // エンジンの初期化失敗
        }
        ErrorCode runResult = engine.run();
        if (runResult != ErrorCode.SUCCESS) {
            return runResult; // エンジンの実行失敗
        }
        return ErrorCode.SUCCESS; // 成功
    }

    private static ErrorCode initializeEngine(String configName, Engine engine) {
        Config config = ConfigLoader.load(configName);
        if (config == null) {
            return ErrorCode.CONFIG_LOAD_ERROR; // コンフィグ読み込み失敗
        }
        Scheduler scheduler = Container.buildScheduler(config.scheduler);
        if (scheduler == null) {
            return ErrorCode.SCHEDULER_INIT_ERROR; // スケジューラの初期化失敗
        }
        Downloader downloader = Container.buildDownloader(config.downloader);
        if (downloader == null) {
            return ErrorCode.DOWNLOADER_INIT_ERROR; // ダウンローダの初期化失敗
        }
        engine.setComponents(scheduler, downloader);
        return ErrorCode.SUCCESS; // 成功
    }

    private static void handleErrorCode(ErrorCode errorCode) {
        switch (errorCode) {
            case CONFIG_LOAD_ERROR:
                System.out.println("Failed to load configuration.");
                break;
            case SCHEDULER_INIT_ERROR:
                System.out.println("Failed to initialize scheduler.");
                break;
            case DOWNLOADER_INIT_ERROR:
                System.out.println("Failed to initialize downloader.");
                break;
            case ENGINE_RUN_ERROR:
                System.out.println("Failed to run engine.");
                break;
            case UNKNOWN_ERROR:
            default:
                System.out.println("An unknown error occurred.");
        }
    }
}

このコードでは、呼び出し側のコードが雑然としてしまい、読む気が起きません。エラーコードを返す場合は、呼び出した後にすぐエラーチェックしなければならず、コードが冗長になります。

次に、例外を使用した例です。

public class EngineController{
    ...
    public static void startEngine(String configName) {
        try {
            tryToStartEngine(configName);
        } catch (InitializeEngineException e) {
            handlerException(e);
        }
    }

    private static tryToStartEngine(String configName) throw InitializeEngineException{
        Engine engine = initializeEngine(configName);
        engine.run();
    }

    private static Engine initializeEngine(String configName) throw InitializeEngineException{
        Config config = ConfigLoader.load(configName);
        Scheduler scheduler = Container.buildScheduler(config.scheduler);
        Downloader downloader = Container.buildDownloader(config.downloader);
        return new Engine(scheduler, downloader);
    }
    ...
}

この例では、メインアルゴリズムとエラー処理が分離されており、それぞれの処理を調べやすくなっています。

最初にtry-catch-finally文を書く

try-catch-finally ブロックを使用することで、特定のコードの実行範囲(スコープ)を明確にし、その範囲内で発生する可能性のあるエラーや例外を管理できます。例外を投げる可能性のあるコードを書く際には、最初に try-catch-finally 文から書き始めるのが良い習慣です。

例として、ファイル名からアプリケーションの設定ファイルをロードするコードを考えます。

ステップ1: 失敗するテストを書く

まず、設定ファイルが見つからない場合に ConfigException が投げられることを確認するテストを書きます。

public class ConfigLoaderTest {
    @Test(expected = ConfigException.class)
    public void loadShouldThrowOnInvalidFileName() {
        ConfigLoader.load("invalidFile.properties");
    }
}

ステップ2: 初期のスタブを作成する

次に、テストを確認するためのスタブを作成します。このスタブはまだ例外を投げません。

public class ConfigLoader {
    public static Config load(String filePath) {
        return new Config();
    }
}

ステップ3: 実装を追加して例外を投げる

次に、無効なファイル名を処理するための実装を追加します。try-catch-finally ブロックを使用して、エラーハンドリングのスコープを定義します。

public class ConfigLoader {
    public static Config load(String filePath) {
        try (InputStream inputStream = new FileInputStream(filePath)) {
            // ファイル読み込み処理
        } catch (Exception e) {
            throw new ConfigException("読み込みエラー:" + filePath, e);
        }
        return new Config();
    }
}

ステップ4: より具体的な例外をキャッチする

ステップ3でテストが成功するようになったので、リファクタできます。キャッチする例外をより具体的な FileNotFoundException に変更します。

public class ConfigLoader {
    public static Config load(String filePath) {
        try (InputStream inputStream = new FileInputStream(filePath)) {
            // ファイル読み込み処理
        } catch (FileNotFoundException e) {
            throw new ConfigException("次の設定ファイルが見つかりません:" + filePath, e);
        }
        return new Config();
    }
}

ステップ5: 残りのロジックを実装する

最後に、ファイルの読み込みロジックを try ブロックに追加し、残りの実装を完成させます。

public class ConfigLoader {
    public static Config load(String filePath) {
        Properties properties = new Properties();
        try (InputStream inputStream = new FileInputStream(filePath)) {
            properties.load(inputStream);
            return Config.loadFromProperties(properties);
        } catch (FileNotFoundException e) {
            throw new ConfigException("次の設定ファイルが見つかりません:" + filePath, e);
        } catch (IOException e) {
            throw new ConfigException("設定ファイルの読み込みに失敗しました:" + filePath, e);
        }
    }
}

例外処理を最初に書くと、エラーが発生する可能性のある処理の範囲を明確に定義できます。この範囲内で発生するエラーや例外を適切に管理しやすくなります。例えば、設定ファイルを読み込むコードでは、try ブロックを使ってファイル読み込み操作を一つのトランザクションスコープとして定義できます。このスコープ内でエラーが発生した場合は、catch ブロックで適切に処理し、必要に応じてリソースを解放することができます。これにより、try ブロック内で発生する可能性のあるエラーに対して、利用者がどのような問題に備える必要があるかを明確にできます。

非チェック例外を使用する

Javaにはチェック例外があります。チェック例外をスローし、さらに上位の呼び出し元でキャッチしてエラー処理を行う場合、その間にあるすべてのメソッドに例外の throws 節を追加しなければなりません。もし、関数の一つがチェック例外をスローするように変更された場合、その関数を呼び出すすべての関数でも新たに例外に対応するか、throws 節を追加する必要があります。一般的なアプリケーションでは、この依存関係の管理コストが利益を上回ることが多いため、非チェック例外を使用した方が良い場合が多いです。

例外に情報を持たせる

例外には、エラーの発生場所と原因を判断できるコンテキストを持たせる必要があります。Javaでは、例外からスタックトレースを取得できますが、スタックトレースだけでは失敗した処理の意図は分かりません。そのため、十分な情報を持ったエラーメッセージを作成し、例外に含めることが重要です。アプリケーションにロギングの仕組みがある場合は、キャッチした場所でロギングを行うために十分な情報を渡します。

呼び出し元が必要とするカスタム例外クラスを定義する

エラーの分類方法は多岐にわたりますが、アプリケーションの中で例外クラスを定義する際には、例外がどのようにキャッチされるかが最も重要です。ここでは、架空のサードパーティライブラリ「SuperPortLib」を使用したポート通信のコードをリファクタリングし、エラーハンドリングを一元化する方法を紹介します。リファクタリングの目的は、サードパーティの例外をキャッチし、カスタム例外を使用することでコードの可読性とメンテナンス性を向上させることです。

SuperPortLibは以下のような open メソッドを持ちます。

public class SuperPortLib {
    ...
    public void open() throws PortNotFoundException, PortInUseException, InvalidPortConfigurationException {
        // ポートを開く
    }
}

SuperPortLibの open メソッドを呼び出す側では、各例外を個別に処理しています。

public void performOpenOperation(String portNumber) {
    SuperPortLib port = new SuperPortLib(portNumber);
    try {
        port.open();
    } catch (PortNotFoundException e) {
        reportPortError(e);
        logger.log("Failed to open the port. Port not found: " + portNumber, e);
    } catch (PortInUseException e) {
        reportPortError(e);
        logger.log("Failed to open the port. Port is in use: " + portNumber, e);
    } catch (InvalidPortConfigurationException e) {
        reportPortError(e);
        logger.log("Failed to open the port. Invalid port configuration: " + portNumber, e);
    }
}

このコードには多くの重複があり、例外によらず同じ処理を行う必要があるため、可読性が低下しています。

今回は、呼び出し元のコードをシンプルにするために、カスタム例外を導入します。これにより、呼び出し側のコードをかなり簡単なものにできます。

public void performOpenOperation(String portNumber) {
    PortService service = new PortService(portNumber);
    try {
        service.openPort();
    } catch (PortOperationException e) {
        reportError(e);
        logger.log(e.getMessage(), e);
    } finally {
        ...
    }
}

PortServiceはSuperPortLibの例外をキャッチし、カスタム例外に変換するラッパークラスです。

public class PortService {
    private SuperPortLib innerPort;

    public PortService(int portNumber) {
        this.innerPort = new SuperPortLib(portNumber);
    }

    public void openPort() {
        try {
            innerPort.open();
        } catch (PortNotFoundException e) {
            throw new PortOperationException(e);
        } catch (PortInUseException e) {
            throw new PortOperationException(e);
        } catch (InvalidPortConfigurationException e) {
            throw new PortOperationException(e);
        }
    }
}

このようなサードパーティAPIをラップするのはベストプラクティスの一つです。ラップすることで、依存性を最小限に抑え、別のライブラリに乗り換える際の手間を減らすことができます。また、コードのテストを行う際に、サードパーティライブラリのモックを簡単に作成できます。そして最大の利点は、アプリケーションを特定のベンダーのAPI設計に依存させず、自分にとって最適なAPIを定義できることです。

多くの場合、特定の領域のコードでは一つの例外クラスを使用することが適しています。例外とともに送られる情報によってエラーを判別可能だからです。特定の例外のみキャッチしたい(もしくはしたくない)場合に限り、別のクラスを使うことを検討しましょう。

正常ケースのフローを定義する

これまでの節に従えば、最終的にビジネスロジックとエラー処理をうまく分離できます。しかし、この方法ではエラー処理が片隅に追いやられることになります。外部APIをラップして独自の例外を定義し、ハンドラを設定することで処理の中断にうまく対応できます。

しかし、処理の中断を望まない場合はどうでしょうか?以下のコードは会費を計算する関数です。名簿に載っていない場合は、デフォルトの費用を足しています。

public class MembershipFeeCalculator {
    ...
    public int calculateTotalFee(String[] members) {
        int totalFee = 0;
        for (String member : members) {
            try {
                MembershipFee membershipFee = membershipFeeDAO.getMembershipFee(member);
                totalFee += membershipFee.getFee();
            } catch (MemberNotFoundException e) {
                totalFee += DEFAULT_FEE; // 名簿に載っていない場合のデフォルト費用
            }
        }
        return totalFee;
    }
}

上記のコードでは、例外がロジックを分断してしまっています。スペシャルケースパターンを用いることで、例外的なケース(名簿に載っていない会員)を特別なクラスで処理し、例外を明示的に扱わずに済むようにできます。

以下は名簿に載っていない会員用のスペシャルケースクラスです。

// 名簿に載っていない会員用のスペシャルケースクラス
class DefaultMembershipFee extends MembershipFee {
    @Override
    public int getFee() {
        return 2000; // デフォルトの費用
    }
}

名簿に載っていない場合は、上記の DefaultMembershipFee クラスを返すようにします。

class MembershipFeeDAO {
    ...
    public MembershipFee getMembershipFee(String name) {
        return feeMap.containsKey(name) 
            ? new RegularMembershipFee(name, feeMap.get(name)) 
            : new DefaultMembershipFee();
    }
}

このように、例外の振る舞いをスペシャルケースオブジェクトにカプセル化することで、呼び出し側のコードでは例外によってロジックが分断されず、コードの可読性と保守性が向上します。

以下は、スペシャルケースパターンを適用した MembershipFeeCalculator クラスです。

public class MembershipFeeCalculator {
    ...
    public int calculateTotalFee(String[] members) {
        int totalFee = 0;
        for (String member : members) {
            MembershipFee membershipFee = membershipFeeDAO.getMembershipFee(member);
            totalFee += membershipFee.getFee();
        }
        return totalFee;
    }
}

nullを返さない

メソッドから null を返すのは、呼び出し元に面倒を押し付けています。null チェックを1つ忘れれば、アプリケーションは制御不能になります。アプリケーションの深いところから送出された NullPointerException を制御するのは難しいです。単なる null チェック忘れと片付けるのは簡単ですが、そもそもすべての呼び出し元で null チェックを記述しなければならないのも問題です。

メソッドから null を返す場合は、代わりに例外を投げるか、スペシャルケースオブジェクトを返すことを検討してください。サードパーティAPIが null を返す場合も、そのメソッドをラップして例外を返すかスペシャルケースオブジェクトを返すことを検討してください。

以下の例を見てください。

List<Employee> employees = getEmployees();
if (employees != null) {
    for (Employee e : employees){
        totalPay += e.getPay();
    }
}

getEmployees が従業員が存在しない場合に null を返す場合、上記のように null チェックが必要です。しかし、代わりに空リストを返すようにすればコードは綺麗になります。

List<Employee> employees = getEmployees();
for (Employee e : employees){
    totalPay += e.getPay();
}

nullを渡さない

null をメソッドに渡すのはさらに良くないです。まず、null を渡すことで呼び出し先のメソッドで NullPointerException が発生する可能性があります。

NullPointerException を避けるために、バリデーションで新たな例外をスローすることも可能です。

public class UserService {
    public void updateUserInfo(User user) {
        if (user == null) {
            throw new IllegalArgumentException("User cannot be null");
        }
        // 更新処理
    }
}

NullPointerException よりはマシかもしれませんが、IllegalArgumentException への対処が必要になります。別の選択肢としては、アサーションがあります。

public void updateUserInfo(User user) {
    assert user != null;
    ...
}

文書化としてはいいですが、問題を直接解決はしていません。null を渡すと実行時エラーになります。

このように、null が渡された場合にうまく対処する方法がありません。そのため、null を渡すことを原則禁止にするのが良いです。

結論

クリーンコードは、単に読みやすいだけでなく、堅牢でなければなりません。エラー処理は関心ごとの分離であり、本流のロジックと独立して見ることが可能であれば、堅牢かつ読みやすいコードを書くことができます。また、分離することでそれぞれを独立して考えることができるので、保守性を大きく改善できます。

エラー処理のポイントを以下にまとめます。

  1. リターンコードではなく、例外を使用する:
    エラーコードを使用する代わりに例外を使用することで、メインアルゴリズムとエラー処理を分離し、コードの可読性を向上させます。
  2. 最初にtry-catch-finally文を書く:
    例外を投げる可能性のあるコードを書く際には、まず try-catch-finally ブロックを作成することで、エラーハンドリングのスコープを明確に定義します。
  3. 非チェック例外を使用する:
    チェック例外の代わりに非チェック例外を使用することで、依存関係の管理コストを削減し、コードの保守性を向上させます。
  4. 例外に情報を持たせる:
    例外にはエラーの発生場所と原因を特定できる情報を含めることで、問題解決を容易にします。
  5. 呼び出し元が必要とするカスタム例外クラスを定義する:
    サードパーティの例外をキャッチし、カスタム例外に変換することで、コードの可読性とメンテナンス性を向上させます。
  6. 正常ケースのフローを定義する:
    スペシャルケースパターンを用いて、例外的なケースを特別なクラスで処理し、例外によるロジックの分断を避けます。
  7. nullを返さない:
    メソッドから null を返すのではなく、例外を投げるかスペシャルケースオブジェクトを返すことで、呼び出し元のコードの安全性を向上させます。
  8. nullを渡さない:
    null をメソッドに渡すことを禁止にすることでコードの安全性と可読性を向上させます。

関連記事

カテゴリー:

ブログ

情シス求人

  1. チームメンバーで作字やってみた#1

ページ上部へ戻る