エラー処理に関する覚え書き

cover

はじめに

いろいろな事情から12月からSwiftを勉強する必要性に迫られたので、プログラミング言語の勉強というものを久しぶりに始めた。言語の学習というものは、幸いにもいくつかの言語を習得済みな自分としては、この機能はXX言語でのアレね、という形で比較しながら大抵の機能の勉強を進められるのでそこまで苦労することはない。 しかし全く新しい特徴だったり言葉は同じものの仕様が似て非なる概念だったりすると、意外と理解するのに時間を使うことがある。そしてその一つに「エラー処理」がある(と思う)。 エラー処理や例外については言語ごとに思想が出やすいポイントではある。が、結局どの言語でも通用するようなベストプラクティスはあるだろうと思い立ちその備忘録。

エラー処理の歴史を振り返る

まずはC言語ではエラー処理をどのように行っていたかを振り返ってみる。以下のコード例ではatoi関数を呼び出している。Cに詳しくない方に向けて説明すると、atoiは文字列を入力にその整数型を返却するための関数である。

#include <stdlib.h>

int main() {
    // 数値の文字列リテラルではないのでエラーのはず
    atoi("abc");  // 0が返却される
}

不正な文字列を与えると 0 が返却される。つまり正常値とは異なる値を返却することで正常に処理を完了できなかった旨を間接的に報告している。この方法には思いつくだけでも以下のようなデメリットがある。

🤦‍♀️ 呼び出し側で値の意味を意識する必要がある

0や-1が返ってきた場合がエラーである、と呼び出し元が意識する必要がある。一種のワークアラウンドとして、返却する値に変数を利用して名前をつけることで、呼び出し元から理解しやすく手法が取られる。

🤦‍♀️ 返り値の型にエラーの型が依存する

先程の例だと変換に失敗したことをint型で知らせる必要がないのにも関わらず、返り値の型制限よりintにせざるを得ない。そのせいでエラーの詳細を呼び出し元に伝えることが困難になる。

🤦‍♀️ 戻り値の値を無視できてしまう

言語仕様として戻り値の受け取りを呼び出し側にて確認する必要がないため、本来エラーをハンドリングすべきタイミングであっても無視できてしまう。

🤦‍♀️ 呼び出し階層が深くなるとエラー処理が煩雑になる

エラーを伝播するために適切な呼び出し元まで何度も値を返し続けないといけないため、コードが複雑になりがちである。

エラー処理としての例外

そして、C言語のエラー処理へのアンチテーゼとして、例外(Exception)という機構がプログラミング言語に導入されるようになった。現代におけるメジャーな言語でもあるC++、Java、C#に組み込まれることとなる。

それでは実際に例外はどのようにエラー処理で利用されるのか?JavaのInteger.Parseというメソッドを取り上げる。やっていることはatoiとほぼ同じである。

// https://github.com/frohoff/jdk8u-jdk/blob/da0da73ab82ed714dc5be94acd2f0d00fbdfe2e9/src/share/classes/java/lang/Integer.java#L532-L554
public static int parseInt(String s, int radix) throws NumberFormatException
{
    // (筆者注釈) パース対象がnullだった場合、例外を呼び出し元に伝える
    if (s == null) {
        throw new NumberFormatException("null");
    }

    // (中略) エラー処理続き & 実際のパース処理
}

// 呼び出し側
try {
    Integer.parseInt("abc", 10)
} catch(NumberFormatException e){
    // エラー処理
}

atoiと同じく引数に数値ではない文字列を渡すと、NumberFormatExceptionが返却される。呼び出し側では、それをtry-catchにて補足することで適切なエラー処理を行う。

そして例外によって先ほど挙げたデメリットが解消されている。

呼び出し側で値の意味を意識する必要がある

発生した例外に関するクラスが返却されるためエラーの詳細が明らかになることで、呼び出し側が返り値の型によって解釈を変える必要がない。

返り値の型にエラーの型が依存する

正常時はint、エラー時はNumberFormatException型が返却されるため、この問題も発生しない。

戻り値の値を無視できてしまう

try-catchにて適切なエラーハンドリングを行わなければ、コンパイルエラーになるため呼び出し側はNumberFormatExceptionを無視出来ない。

呼び出し階層が深くなるとエラー処理が煩雑になる

例外は補足(catch)するまで、呼び出し元に伝播され続ける。つまり適切な呼び出し元で例外を補足すればよいため、大域脱出を実現できる。 C言語のデメリットが解消され、これにてめでたしめでたし……とはならないのが例外機構の難しさである。

これまで紹介したのはチェック例外と呼ばれ、またCompile-Time Exceptionと表現される。

しかし実行時にしか発生するか判断できない例外が一部存在する。例えば配列を使うプログラムでは境界値を超えるエラーが発生する可能性があり、コンパイル時に解決不可能で実際にプログラムを動かした結果として発生する。そしてJavaではこの類いの例外を無視することができる。 これらは非チェック例外と呼ばれ、Run-Time Exceptionとも言われる。

非チェック例外の導入

戻り値の値を無視できてしまう

という性質は呼び出し元にtry-catchを強制させることで達成されるのだが、非チェック例外においては発生した例外を無視できてしまう。引数の不正、配列の境界値エラー、Null Pointer Exceptionなどが非チェック例外に該当する。

非チェック例外は、プログラマのミスによって生じる例外であり、呼び出し元で回復することは本来は不可能なはずである。例えば配列の境界値が超えた場合、そのエラーを呼び出し側で適切に回復させるよりも、エラーを発生させないようにコードを修正すべきである。

なるべくチェック例外にすればいいと思うかもしれないが、チェック例外にも多くの問題をはらんでおり、Java以降の言語ではほぼ採用されていない。 話が混み合ってしまったが、コンパイル時例外(チェック例外)と実行時例外(非チェック例外)のような分類をすることでエラーをどう処理すべきかの方向性にヒントを与えてくれた。そこで視点を変えて呼び出し元でどのようにエラーを処理すべきかを基準とした分類を考えてみることにしたい。

エラーをどのように処理をしたいのか?

Swiftのドキュメントの一つに、「エラーをどのように処理させたいか」を基準としたエラーの分類がなされている。そこで分類されている4種類のエラーを紹介する。

Simple Domain Error

A simple domain error is something like calling String.toInt() on a string that isn’t an integer. The operation has an obvious precondition about its arguments, but it’s useful to be able to pass other values to test whether they’re okay. The client will often handle the error immediately.

呼び出し側の呼び出し規則違反によって発生する類いエラー。これまで登場した文字列を整数に変換するためのロジックString.toInt()もココに該当する。すぐに適切な回復処理を実施する必要が呼び出し元にある。

Recoverable error

Recoverable errors include file-not-found, network timeouts, and similar conditions. The operation has a variety of possible error conditions. The client should be encouraged to recognize the possibility of the error condition and consider the right way to handle it. Often, this will be by aborting the current operation, cleaning up after itself if needed, and propagating the error to a point where it can more sensibly be handled, e.g. by reporting it to the user.

呼び出し側で適切な回復処理を行う必要がある類いのエラー。ファイルが見つからない場合、タイムアウトなどが該当する。エラーの種類にもよって、呼び出し元でリトライ処理を行うなど回復処理を行う必要がある。

Universal Error

The difference between universal errors and ordinary recoverable errors is less the kind of error condition and more the potential sources of the error in the language. An error is universal if it could arise from such a wealth of different circumstances that it becomes nearly impracticable for the programmer to directly deal with all the sources of the error.

プログラムが復帰が出来ないことを表すエラーで、メモリ枯渇などが該当する。プログラムとしてはどうしようもない場合に発生する。自身のプログラムのメモリリークなどが遠因で発生する場合がある。

Logic Failure

The final category is logic failures, including out of bounds array accesses, forced unwrap of nil optionals, and other kinds of assertions. The programmer has made a mistake, and the failure should be handled by fixing the code, not by attempting to recover dynamically.

ソースコード内のロジックに誤りが原因で発生する類いのエラー。配列の境界値エラー、nilのunwrap時などに発生する。この類いのエラーはソースコードのロジックを修正することで直すべき。

当然この原則によって、あらゆるケースが対処できるわけではないもののどのケースに該当するかを考えることで、適切なエラー処理ができそうである。

しかしながら、具体的なエラーを直接当てはめることは、本質的に難しい問題だったりする。先程から何度も登場するtoInt()のエラーであれば、パースする目的の文字列がユーザから入力を受け付ける可能性があれば、文字列以外の値が入る可能性があるためSimple Domain Errorとして、回復可能な例外とも言えそうだ。いやしかし、入力値は事前にバリデーションで弾くべき処理なので、パース自体を呼び出すタイミングでは絶対に「数値が文字列であること」を保証しておくべきであってLogic Failureと言えるのではないか?

このようにエラーの分類自体の難易度が高いが、こういうものだと割り切ってしまうべきかもしれない。銀の弾丸のような指針はなく、その状況に従って参考にしながら適切なエラー処理を行うべきと言えそう。

※ 余談だがこの資料にはSurveyとして、いくつかのプログラミング言語を分析した結果がまとめられており、英語ながらも一読する価値があるのでぜひ。

まとめ

とりとめのない感じでまとめに入るが、適切なエラー処理についての知見を深める事ができた。いずれにしても絶対的な指針と呼べる基準は存在しないものの、呼び出し元で期待する動作によってエラーを分類する、という方法は、プログラミングにおける指針の一つと言えそうだ。今回は最終的にSwiftを取り上げたが、他の言語でも指針のような物があればぜひ紹介してもらいたい。