Status Code 303 - See Other

サーバサイド、iOS・アンドロイドアプリ、インフラレベルの話まで幅広くやってます。情報の誤りや指摘・意見などは自由にどうぞ。

例外仕様設計

概要

プログラムが正しく実行できない状態に陥ったときに、それを処理側に通知するための機構として例外がある。
これによって、もしユーザからの入力が想定外のものだったりしても、安全にプログラムを終了させることができる。

しかし、仕様を考慮もせず適当に例外を発生させたりすれば、そのクラスはユーザによって使いづらいだろうし、
そうかといって、例外発生に対して適切な処理をしなければ、バグが発生する原因をユーザに調査させることになる。

このような可能性から、クラス設計において例外は、本質的な内容ではないことが多いが、
仕様の一部であるべきと考えている。今回は、この例外仕様について個人的な意見を記述する。

事前チェック VS 例外

コマンドライン引数から数値変換・計算・表示するプログラムを例として説明する。
前提として、これは「2つの整数を除算した結果(整数)を表示するプログラム」という仕様ということにする。

public class Main {
	public static void main(String[] args) {
		System.out.println(Integer.parseInt(args[0])/Integer.parseInt(args[1]));
	}
}

このプログラムは、単純な処理にも関わらず、以下の3種類の実行時例外が起こる可能性がある。

  • ArrayIndexOutOfBoundsException
  • NumberFormatException
  • ArithmeticException

それぞれの例外に対して、どのように対処すべきかの個人的な見解を述べる。

ArrayIndexOutOfBoundsException

これが発生するのは、コマンドライン引数が2個未満のときになる。
仕様が「2つの整数を除算した結果」なのだから、これは致命的な例外なので処理続行は不可能である。
つまり、例外を発生させるべきだと考える。

しかし、単純に異常終了させるべきではない。なぜなら、この問題の発生原因を考えれば想像がつく。
この例外が発生する原因は、入力項目が足りないことにある。
しかし、「ArrayIndexOutOfBoundsException」は配列外にアクセスしようとした例外である。

【ユーザ入力漏れ】→【配列外アクセス異常】というのは、プログラムの内部構造を意識して初めて理解できる。
プログラムを利用するユーザはこの例外を見て、ユーザが何の操作を間違っていたか分かるだろうか。
これを考慮すれば、違う例外を発生させるべきだろう。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要"); //コマンドライン引数が2個未満のときは引数異常で終了
		System.out.println(Integer.parseInt(args[0])/Integer.parseInt(args[1]));
	}
}

NumberFormatException

これが発生するのは、入力が整数値じゃなかったとき。この例外に対しては何も対処しない。

まず、数値に変換できない時点で除算ができない。つまり、処理続行が不可能なため例外を発生させる。
また、そのときに「値が整数形式ではない」という例外が発生し、これはプログラム仕様からマッチした内容なのでユーザも理解しやすい。

もし異常終了が問題になる場合は、try-catch で例外をキャッチして異常系処理を記述する。
正常なロジックとして記述しないのは、この動作は正常動作の範囲内ではないという判断からだ。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要");
		try {
			System.out.println(Integer.parseInt(args[0]) / Integer.parseInt(args[1]));
		} catch (NumberFormatException e) {
			System.out.println(e.getMessage());
		}
	}
}

ArithmeticException

これが発生するのは2番目の引数が 0 によって、0 除算が発生するとき。
0 は本仕様の整数値に含まれる入力なので、例外による異常終了はさせないで、正常系動作として実装すべき内容だ。
つまり、分岐によって事前チェックを行い対応する。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要です");
		int arg0 = Integer.parseInt(args[0]);
		int arg1 = Integer.parseInt(args[1]);
		if (arg1 != 0) {
			System.out.println(arg0/arg1);
		}else {
			System.out.println("∞");
		}
	}
}

今回は、無限という結果(-∞は面倒臭いので考慮してない)にしているが、「計算できません」でも良いだろう。
大事なことは正常系の処理として記述することである。

異常終了 VS 例外キャッチ

例外をキャッチするか、そのまま異常終了させるかどうかの判断基準を記述する。

仕様上発生しない例外

さきほどの例でもあったが、仕様に対して明らかにおかしい入力値に対する処理は基本しなくて良い。
そのような例外にいちいち対処すると、本質ではないロジックのためにコードが読み辛くなり、保守性もおそらく落ちる。
また、そのような対処による品質向上効果は大抵薄い。

エラーアトミック性

エラーアトミック性とは、異常(例外)が起こってもシステム内部の状態を正常動作時に戻せる性質である。
この性質を持たせることができるならば、例外をキャッチして行うべきだろう。

しかし、このような仕組みはコストがかかりやすい。例えば、ある値を変更したものを元に戻す場合は、
そのための領域を確保しなければならないし、例外のタイミングで元に戻す処理内容が変わったりする。

また、例外の発生要因とは、多岐に渡るため全ての要因を塞ぐことは基本的にできないため、
影響度合や発生頻度が低いと判断できる場合は、例外によって処理を中断させることを検討した方が良い。

異常検知

発生する例外に全てロジックで対処する必要はないものの、それによってシステム内で整合性が取れない状態になってしまう可能性はある。
だから、どのような例外であっても、発生時には一度はキャッチしてその内容を調べるべき。

ただし、これを複数の箇所で記述すると複雑になりやすいので、大きな塊のエントリーポイントで行った方が良い。
この箇所では、実行ログ取得、運用チームへの通知など異常時に行うべき処理を記述する。
なお、例外によって中断させる意図を含むものもあるので、一般的にはその後異常終了させた方が良い。

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import org.slf4j.LoggerFactory;

public class Main {

	static void main(String[] args) {
		try(BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("~/data.txt")))) {
			bos.write(args[0].getBytes());
		} catch (IOException e) {
			LoggerFactory.getLogger(Main.class).debug(e.getMessage()); //エラーログ取得
			throw e; //異常なので結局スローする
		}
	}
}

例外をコントロールする

例外とは、そもそも異常状態になったことをプログラム実行側やユーザに伝える目的で行う。
ならば適切にその原因は伝えるべきだろう。

public class Main {

	static void main(String[] args) {
		try {
			System.out.println(args[0]);
		}catch(ArrayIndexOutOfBoundsException e) {
			throw new IllegalArgumentException("入力値不正"); //例外翻訳
		}
	}
}

なお、例外を無視するために使ってはならない。

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

public class Main {

	static void main(String[] args) {
		try(BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("~/data.txt")))) {
			bos.write(args[0].getBytes());
		} catch (IOException e) { // ダメ!ゼッタイ!
		}
	}
}

上記のコードは、 IOException が起こっても正常終了するため、ユーザは正しい操作が行われていないにも関わらず気づかない。
また、このような内部状態の不整合は、後々にバグを生み出す原因となり、いつ発生するか予測がつかない。

参考文献

EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)