k-tokitoh

2021-09-25

エラーあれこれ

エラーについて自分なりの理解を整理する。

一般的な用語法にそぐわない部分があるかもしれないが、本記事の中での定義に即して記述する。

エラー概論

2 つの”エラー”

エラーという言葉には 2 つの意味がある。

1 と 2 は本来別ものなので以下のようにずれる場合もある。

つまり、2 の概念に当てはまるか否かに関わらず、1 の機構がはまるところには 1 をつかうし、そうでなければつかわないというだけの話。

エラーという機構の特徴

そもそも 1 の機構はどういう特徴をもっているのか。

ある関数において本筋として想定されていない事態が起きた場合に、それを関数の外側に伝えるにあたっては一般に以下の選択肢がありうる。

両者を対比すると、エラーという機構の pros/cons は以下のとおり。

イメージとしては、「バトン(戻り値)を次の走者(呼び出し元)にちゃんとわたす」と「バトン(エラー)をぶん投げて(アンワインド)誰かが拾う(エラーハンドリング)」。

エラーという機構をいつ利用するか

これを踏まえて、エラーという機構が適しているのは次の場面。

たとえば変数に想定外に null が入ってしまいそのプロパティを参照できない場合。(2-2 に該当する。)

動的型付け言語においてはこういう事態はいつどこで起こるかわからない。いつどこで起こるかわからないことに関して、 「この関数の内部でそれが起きた場合にはそのことを表現する戻り値をつくっておこう」とあらゆる関数のシグネチャに含めるのはナンセンスなので a に該当する。

また、特に予期しない箇所においてこうした事態が想定外に発生してしまった場合は、その場に応じたきめ細かい対応はしようがないので、 エラーを投げて大元で拾い、web サービスなら 500 を返すなどすればよいので、b に該当する。

よって、例えば js であればこの場合組み込みのTypeError: Cannot read properties of nullというエラーが生じるが、このエラーをそのまま(関数内では拾わずに)関数の外側に対して投げるのが現実的な対応となる。

言語によるバリエーション

ここまで言語を指定せずにエラーという機構について述べたが、当然ながら言語によってエラー周りの仕様は異なる。

Golang では処理結果とエラーを多値で返すようになっているため、シグネチャをシンプルに保ちつつ、一気にアンワインドするデメリットを回避するという選択肢があるようだ。

assertion error / exception

エラーという機構には、2 つの使われ方がある。assertion error と exception である。

両者はプログラミング上の処理としては基本的に同一であり、その利用の目的/方法において区別される。

棒切れを杖としてつかうか、竿としてつかうかみたいなものである。

assertion error は出荷前の検査項目であり、コードに埋め込まれたテストである。それに対して、exception は出荷後の安全装置である。

仕様をつめる前

例えば、以下のコードを考える。

type Divide = (input: string) => number;
const divide: Divide = (input) => {
	const [numerator, denominator] = input.split('/').map(Number);
	return _core(numerator, denominator);
};

type Core = (numerator: number, denominator: number) => number;
const _core: Core = (numerator, denominator) => {
	return numerator / denominator;
};

試しに色々な引数で呼んでみた。

console.log(divide('')); // NaN
console.log(divide('1')); // NaN
console.log(divide('1/2')); // 0.5
console.log(divide('1/2/3')); // 0.5
console.log(divide('hoge')); // NaN
console.log(divide('hoge/fuga')); // NaN
console.log(divide('hoge/fuga/piyo')); // NaN
console.log(divide('0/2')); // 0
console.log(divide('2/0')); // Infinity

よくわからん値を与えても、よくわからん値を返してくるのでいまいちだ。

仕様をつめる

divide の仕様を以下のとおりに決めたと仮定しよう。

ひとつめは明らかに_coreの外側でやるべきなので、とりあえずdivideを以下のように変更する。

type Divide = (input: string) => number | undefined;
const divide: Divide = (input) => {
	const strs = input.split('/');
	if (strs.length !== 2) {
		console.log('Argument format is invalid.');
		return;
	}
	const [numerator, denominator] = strs.map(Number);
	return _core(numerator, denominator);
};

そのうえで、2,3 つめのチェックについては以下の方針がありうる。

以下でそれぞれの場合を試してみる。

_coreの内側でチェックする

ログ出力はレイヤー的に_coreの外側でやりたいとする。

その場合、_coreが外側に返すべき情報はどのチェックに引っかかっているかという情報である。

これを戻り値として返すこともできるものの、シグネチャをシンプル(戻り値は number のみ)に保つために、ここではエラー機構を利用する。

type Divide = (input: string) => number | undefined;
const divide: Divide = (input) => {
	const strs = input.split('/');
	if (strs.length !== 2) {
		console.log('Argument format is invalid.');
		return;
	}
	const [numerator, denominator] = strs.map(Number);
	try {
		return _core(numerator, denominator);
	} catch (e) {
		if (e instanceof CoreError) {
			console.log(e.message);
		} else {
			throw e;
		}
	}
};

type Core = (numerator: number, denominator: number) => number;
const _core: Core = (numerator, denominator) => {
	if ([numerator, denominator].some(isNaN))
		throw new CoreError('Arguments must represent numbers.');
	if (denominator === 0) throw new CoreError('Denominator cannot be zero.');
	return numerator / denominator;
};

class CoreError extends Error {
	public name = 'CoreError';
	constructor(message: string) {
		super(message);
		Object.setPrototypeOf(this, new.target.prototype);
		if (Error.captureStackTrace) Error.captureStackTrace(this, CoreError);
	}
}
console.log(divide('')); // Argument format is invalid.
console.log(divide('1')); // Argument format is invalid.
console.log(divide('1/2')); // 0.5
console.log(divide('1/2/3')); // Argument format is invalid.
console.log(divide('hoge')); // Argument format is invalid.
console.log(divide('hoge/fuga')); // Arguments must represent numbers.
console.log(divide('hoge/fuga/piyo')); // Argument format is invalid.
console.log(divide('0/2')); // 0
console.log(divide('2/0')); // Denominator cannot be zero.

これで仕様を実現することができた。

_coreの外側でチェックする

チェックを外側でやるとしても、内側に何も記述がないと別のところから_coreを利用するときに「どういう引数は渡さないよう予め弾いておくべきなのか」が分からない。

そこで_coreの内側に、「引数がこれらの条件を満たすように呼び出し元でチェックしてね」と記述することが望ましい。

この方法には以下がありうる。

ということで assertion をつかって記述してみる。

いったん assert を仕込む。

import assert from 'assert';

type Core = (numerator: number, denominator: number) => number;
const _core: Core = (numerator, denominator) => {
	assert.ok(!isNaN(numerator) && !isNaN(denominator));
	assert.notStrictEqual(denominator, 0);
	return numerator / denominator;
};

当然このままだとばんばん AssertionError が発生する。

console.log(divide('')); // The expression evaluated to a falsy value: assert.notStrictEqual(denominator, 0)
console.log(divide('1')); // The expression evaluated to a falsy value: assert.notStrictEqual(denominator, 0)
console.log(divide('1/2')); // 0.5
console.log(divide('1/2/3')); // 0.5
console.log(divide('hoge')); // The expression evaluated to a falsy value: isNaN(denominator)
console.log(divide('hoge/fuga')); // The expression evaluated to a falsy value: isNaN(denominator)
console.log(divide('hoge/fuga/piyo')); // The expression evaluated to a falsy value: isNaN(denominator)
console.log(divide('0/2')); // 0
console.log(divide('2/0')); // Expected "actual" to be strictly unequal to: 0

引数が指定の条件を満たすように呼び出し元を変更する。

type Divide = (input: string) => number | undefined;
const divide: Divide = (input) => {
	const strs = input.split('/');
	if (strs.length !== 2) {
		console.log('Argument format is invalid.');
		return;
	}
	const nums = strs.map(Number);
	if (nums.some((num) => isNaN(num))) {
		console.log('Argument must represent numbers.');
		return;
	}
	const [numerator, denominator] = nums;
	if (denominator === 0) {
		console.log('Denominator cannot be zero.');
		return;
	}
	return _core(numerator, denominator);
};
console.log(divide('')); // Argument format is invalid.
console.log(divide('1')); // Argument format is invalid.
console.log(divide('1/2')); // 0.5
console.log(divide('1/2/3')); // Argument format is invalid.
console.log(divide('hoge')); // Argument format is invalid.
console.log(divide('hoge/fuga')); // Argument must represent numbers.
console.log(divide('hoge/fuga/piyo')); // Argument format is invalid.
console.log(divide('0/2')); // 0
console.log(divide('2/0')); // Denominator cannot be zero.

これで(少なくともテストしたパターンにおいては)常に引数が条件を満たし、AssertionError が発生しない状態となった。

振り返り

上記のとおり、divideの仕様を実現するために 2 つの方法を試してみたわけだが、両者を振り返って比較してみる。

これを踏まえると、たとえばそのモジュールの境界が組織の境界に一致している場合には「assert を用いて外側でチェック」という選択肢の「呼び出し元に求める条件が明確」というメリットが効いてくるのかなあ、などと思った。

その場合、責務の境界が不明確だとコーディング局面以上にコミュニケーション上のコストが発生しそうなので。

その他

assertion という発想が少し馴染みにくかったなと感じており、それは以下の特殊性に因るのではないかと思った。

あと、assertion を本番で無効にするかどうかという論点について。