k-tokitoh

エラーあれこれ

2021-09-25

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

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

エラー概論

2 つの”エラー”

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

  • 1.プログラミング言語が備えるエラーという機構
    • コールスタックをアンワインドする仕組み
  • 2.アプリケーションにおけるエラーという概念
    • “想定を逸脱する事態”を指す緩やかな概念で、以下に分類される
      • 2-1. サービス利用者に起因するもの
        • この中にも「善意のユーザーが十分遭遇しうるので親切に対応したいもの」から「悪意あるユーザーでなければ生じないはずなので不親切な対応でよさそうなもの」まで幅がある
          • ex1. post の body が条件を満たなさいため本処理が実行できない
          • ex2. 認可の条件を満たさないのでデータを返却できない
      • 2-2. サービス提供者に起因するもの

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

  • 2 に当たるけれど 1 を利用しない場合
    • 2-1 ex1 でバリデーションの結果得たメッセージをオブジェクトに詰めてユーザーに返すなど
  • 2 に当たらないけれど 1 を利用する場合
    • ストリームで EOF まで読んだときにエラーを投げる

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

エラーという機構の特徴

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

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

  • エラーという機構で表現する
  • 戻り値で表現する

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

  • O
    • バケツリレーをせずにコールスタックを一気にアンワインドできる
      • = 戻り値で表現する必要はないのでシグネチャはシンプルなままで、例外クラスとメッセージという情報は伝達できる
  • X
    • 投げたエラーをどこで拾うのかが見えにくく、可読性が落ちる
    • エラーを投げる場所で適当な後処理(transaction を rollback するなど)がされないと不適切な状態になるリスクがある
    • エラーを拾わないとプロセスが不適切に終了するリスクがある

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

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

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

  • a.どこでどう起きるのかが予見しにくい事態
  • b.それが起きたら元の文脈を離れて=コールスタックを大きく遡って処理をすればいい事態

たとえば変数に想定外に 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 の仕様を以下のとおりに決めたと仮定しよう。

  • /で 2 つの部分に分割されなかったら、その旨を出力して undefined を返す
  • 分割された部分が両方数値として解釈できなかったら、その旨を出力して undefined を返す
  • 分母が 0 だったら、その旨を出力して undefined を返す

ひとつめは明らかに_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の内側でチェックする

ログ出力はレイヤー的に_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の内側に、「引数がこれらの条件を満たすように呼び出し元でチェックしてね」と記述することが望ましい。

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

  • コメント
    • 実際に条件をみたさない値が入ってきたときに気づきにくい
  • exception
    • 以下の区別がつきにくい
      • 「呼び出し元でチェックして、それを throw する事態は予め防いでほしい、ルールとしての記述」
      • 「呼び出し元ではチェックしようがなく、呼び出し元がルールを守っていたとしても throw されうる、安全装置としての記述」
  • assertion
    • 以下であることが明らか
      • 「呼び出し元でチェックして、それを throw する事態は予め防いでほしい、ルールとしての記述」

ということで 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 つの方法を試してみたわけだが、両者を振り返って比較してみる。

  • 共通するところ
    • チェックに関する記述は(まともにやるなら)いずれにせよ呼び出される関数と呼び出し元の両方に書く必要がある
    • 呼び出し元は、呼び出される関数の中身をみる必要がある
      • 投げる可能性がある=拾わなくてはいけないエラーは何なのか or 予め引数が満たさねばならない条件は何なのか
  • 「内側でチェック」のいいところ
    • 拾うエラーの範囲とエラーオブジェクトに対する処理さえ変わらなければ、条件の詳細に依存しなくていい
      • ⇔ 「外側でチェック」だと呼び出し元のコードが引数の条件の詳細に依存してしまい、条件が変更されたら各所のコードを書き直す必要が生じがち
  • 「外側でチェック」のいいところ
    • シグネチャをシンプルに保てて、エラー機構の先述した一般的なデメリットも生じない
    • 呼び出し元に対して求める条件が明確である
      • ⇔ 「内側でチェック」だと、以下の区別がつきにくい
        • 「呼び出し元でチェックして、それを throw する事態は予め防いでほしい、ルールとしての記述」
        • 「呼び出し元ではチェックしようがなく、呼び出し元がルールを守っていたとしても throw されうる、安全装置としての記述」

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

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

その他

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

  • 1
    • 自分が触れてきたテストというものはテストケースと期待される振る舞いがセットで記述されているものだった
      • 自動テストにせよ、手動テストにせよ
    • assertion の場合、“条件を埋め込んだコード”と “一定の網羅性をもつテストケース”がばらばらに存在し、双方があって初めて機能する
      • assertion を書いていても、いろんな呼び出しのケースを出荷前に実行してみなければ assertion の意義はごく限定的なものになってしまう
  • 2
    • ある関数内での exception は、その関数が自身の責務としてチェックを実行するものだが、
    • ある関数内での(事前条件の)assertion は、その関数の責務ではなく、その関数の呼び出し元に対して果たすべき責務を伝達/表明するもの

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

  • もし網羅的なテストケースによって「いかなる場合にも呼び出し元は事前条件をみたす」ことが担保されているのであれば本番では無効化してよい
  • しかし実際には上記を 100%担保することは非現実的であり、本番環境で呼び出し元が事前条件を満たさずに関数を呼んでしまう可能性が多少残りがち
  • 万が一そうなった場合に assertion を無効にしていると奥まった場所まで進行してから不具合が生じることもありうる
    • そうなるとデバッグやリカバリにコストがかかる
  • assertion を有効にしていれば、(出荷前の検査項目という本来の意義からは外れるが)ともかくもそこで処理を中断してややこしい事態を避けることができる
  • パフォーマンスを考慮に入れる必要はあるが、個人的には多くの場合で assertion を本番でも有効にしておくことがベターな選択であるように思われる