クリーンコード【エラー処理編】
はじめに
本記事では、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 節を追加しなければならず、関数の変更が多くの影響を及ぼします。この依存関係の管理コストが高いため、一般的なアプリケーションでは非チェック例外を使用する方が適しています。
例外に情報を持たせる
例外にはエラーの発生場所や原因を判断できる情報を持たせる必要があります。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 を返す場合は、代わりに例外を投げるか、スペシャルケースオブジェクトを返すことを検討してください。サードパーティ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 をメソッドに渡すのはさらに良くないです。呼び出し先のメソッドで 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 を渡すこと自体を原則禁止にするのが良いです。
結論
コードは単に読みやすいだけでなく堅牢でなければなりません。エラー処理は関心ごとの分離であり、本流のロジックとエラーハンドリングを独立して見ることが可能であれば、堅牢で読みやすく保守性の高いコードを書くことができます。
エラー処理のポイントを以下にまとめます。
- リターンコードではなく、例外を使用する:
エラーコードを使用する代わりに例外を使用することで、メインアルゴリズムとエラー処理を分離し、コードの可読性を向上させます。 - 最初にtry-catch-finally文を書く:
例外を投げる可能性のあるコードを書く際には、まず try-catch-finally ブロックを作成することで、エラーハンドリングのスコープを明確に定義できます。 - 非チェック例外を使用する:
チェック例外の代わりに非チェック例外を使用することで、依存関係の管理コストを削減し、コードの保守性を向上させることができます。 - 例外に情報を持たせる:
例外にはエラーの発生場所と原因を特定できる情報を含めることで、問題解決を容易にします。 - 呼び出し元が必要とするカスタム例外クラスを定義する:
サードパーティの例外をラップしてカスタム例外に変換することで、コードの可読性とメンテナンス性を向上させます。 - 正常ケースのフローを定義する:
スペシャルケースパターンを用いて、例外的なケースを特別なクラスで処理し、例外によるロジックの分断を避けます。 - nullを返さない:
メソッドから null を返すのではなく、例外を投げるかスペシャルケースオブジェクトを返すことで、呼び出し元のコードの安全性を向上させます。 - nullを渡さない:
null をメソッドに渡すことを禁止にすることでコードの安全性と可読性を向上させます。
Discussion