Resignal-bind
あるいはよりよいエラーメッセージを求めて

Meta info

対象読者

エラーメッセージに対して「分かりにくい!」「で、どうしろと?」などと思った経験のあるCLer。

長文ですのでお暇な時にビールでも片手にどうぞ。

Introduction

エラーメッセージが分かりにくい大きな理由はスコープの狭さに起因している。 通常コーリー(呼びだされ側)よりコーラー(呼び出した側)の方が多くのコンテクストを保持しているが、それら多くの情報を利用せずコーリーだけにエラー処理をさせていると分かりにくいエラーメッセージが出来上がる。

具体例を見てみよう。 以下のようなコードを考える。

(defun something (arg)
  (let((temp(helper arg)))
    (etypecase temp
      (symbol (procedure-for-symbol temp))
      (string (procedure-for-string temp)))))

ここでは、コーラーをSOMETHING、コーリーをETYPECASEとする。 ETYPECASEのスコープからはエラーメッセージの作成にはTEMPしか利用できない。 例えばHELPERからの返り値が整数0だった場合、エラーメッセージは以下のようなものになるだろう。

0 is must be one of SYMBOL or STRING.

多くのスタイルガイド等ではTYPECASEよりETYPECASEが推奨されている。 それは半分正しいが半分正しくない。 ETYPECASEは使うべきだが使うべきでない。 どういうことかというと、CLAUSEが満たされなかった場合、暗黙裏にNILを返すよりはエラーを発するほうが良いが、多くの場合ETYPECASEの第一引数以上の情報(コンテクスト)が周囲に存在するはずなので自前でエラーを書く方が良い。 例えば以下のようになろうか。

(defun something(arg)
  (let((temp(helper arg)))
    (typecase temp
      (symbol (procedure-for-symbol temp))
      (string (procedure-for-string temp))
      (t (error "SOMETHING: (helper ~S) is evaluated to be ~S~%It must be one of symbol or string."
                arg temp)))))

エラーの原因は次の3つのどれかである。

いずれの場合であれ前者より後者の方が察しがつきやすい。

NOTE! - 上記のエラーメッセージではSOMETHINGというプリフィックスをつけてある。 このようなエラーメッセージを作るのは、CLtL2では非推奨とされている。 コンディションが何処から発せられたかの表示はデバッガが担うべき仕事であり、エラーメッセージには加えられるべきではないというのがその論拠である。 しかしながら、肝心のデバッガの振る舞いが処理系依存であり、表示する処理系もあれば表示しない処理系もあり、表示しない処理系に於いて表示させるためにはなんらかのコマンドを叩くというひと手間が必要になる上、その「何らかのコマンド」も各処理系によって異なるという現実に対応するには、たとえ重複して表示されることとなろうともプリフィックスがあったほうが便利だというのが筆者の考えである。 これは好みの問題なので、異論反論は大いにあろうと思われる。

さて、(一画面に収まるという)わかり易さのために、例ではETYPECASEというマクロを取り扱ったが、モノが関数になっても理屈は同じである。

例えばPROCEDURE-FOR-SYMBOLがエラーを投げるとしよう。 PROCEDURE-FOR-SYMBOLの中身は次のようなものとする。

(defun procedure-for-symbol(symbol)
  (char(symbol-name symbol)0))

本関数は引数にシンボルを期待している。 シンボル以外が引数でくるとエラーとなる。 だが、それはコーラーのSOMETHINGTYPECASEで場合分けしているので、通常問題ないと思われるかもしれない。 だが話はそんなに甘くない。 これは引数が||というシンボルだった場合エラーとなる。 想定されるエラーメッセージは以下のようなものである。

0 is invalid index for "".

これは分かりにくかろう。 頑張ってエラーハンドリングするなら以下のようなコードになろう。

(defun procedure-for-symbol(symbol)
  (handler-case(char(symbol-name symbol)0)
    (type-error(c)(error c))
    (error()(error "Empty name symbol is invalid. ~S"symbol))))

この場合もPROCEDURE-FOR-SYMBOLに分かるのは、引数SYMBOL||であったということだけである。 そのようなシンボルがどうして渡ってきたのかについては知るよしもない。 そのへんの情報まで取り扱いたいなら、コーラーの側でケアしてあげなければならない。

すると、例えばSOMETHINGのコードは以下のようなものとなろう。

(defun something(arg)
  (let((temp(helper arg)))
    (symbol (handler-case (procedure-for-symbol temp)
              (error()(error "SOMETHING: (helper ~S) is evaluated to be ~S.~%Empty name symbol is invalid."
                             arg temp))))
    (string (procedure-for-string temp))
    (t (error "SOMETHING: (helper ~S) is evaluated to be ~S.~%It must be one of symbol or string."
              arg temp))))

さて、仮にPROCEDURE-FOR-SYMBOLの中身が次のようなものだとする。

(defun procedure-for-symbol(symbol)
  (let((char(handler-case(char(symbol-name symbol)0)
              (type-error(c)(error c))
              (error()(error "Empty name symbol is invalid. ~S"symbol)))))
    (subroutine char)))

SUBROUTINEもまたなんらかの場合エラーを発するとする。 コーラーのSOMETHINGから見て、それらのエラーに区別がつかないのはいかにもまずい。 そこで状況に合わせて細かくコンディションを定義し、コーラーから区別がつくようにするのがセオリーである。

以下のコードではSOBROUTINE-ERROREMPTY-NAME-SYMBOLというコンディションが定義済みであるとする。

(defun something(arg)
  (let((temp(helper arg)))
    (typecase temp
      (symbol (handler-case(procedure-for-symbol temp)
                (subroutine-error()(error "Blah blah"))
                (empty-name-symbol()(error "Hoge hoge"))))
      (string (procedure-for-string temp))
      (t (error "Fuga fuga")))))

Issues

前節で見てきたように細かくエラーハンドリングしようとする場合、あるコンディションを受け取って別なコンディションにして投げ直すという処理を書くことが多くなる。 その場合、時にスロットの値を受け継ぎたい事がある。

以下のようなコンディションが定義されているとしよう。

(define-condition low-level (simple-type-error)())
(define-condition top-level (low-level)())

LOW-LEVELコンディションが発せられた場合、それを補足してTOP-LEVELコンディションに変えて投げ直したいとする。 これは以下のような恐ろしく冗長なコードとなる。

(handler-case(something ...)
  (low-level(c)
    (error 'top-level
           :format-control (simple-condition-format-control c)
           :format-arguments (simple-condition-format-arguments c)
           :expected-type (type-error-expected-type c)
           :datum (type-error-datum c))))

そこで、この苦痛を少しでも和らげるべく開発されたのが、本記事で紹介する拙作RESIGNAL-BINDである。

Proposal

前節末尾のコードはRESIGNAL-BINDを使用すると以下のように書ける。

(resignal-bind((low-lovel()'top-level))
  (something ...))

これで「LOW-LEVELコンディションが投げられたら補足して、TOP-LEVELコンディションに変えて投げ直してくれ。 なお、共通するスロットがあったらいい具合に引き継いどいて。」とLispに指示することを意味する。

シンタックスは以下の通り。

(resignal-bind (bind*) &body body)

bind := (condition-type-specifier (var?) make-condition-arguments+)

condition-type-specifier := [condition-name | compound-condition-type-specifier]
condition-name := symbol
compound-condition-type-specifier := [(and condition-type-specifier+)
                                      | (or condition-type-specifier+)
                                      | (not condition-type-specifier)]

var := symbol

make-condition-arguments := condition-name-form arguments
condition-name-form := form ; which evaluated to be condition-name
argumetns := {initarg value}*
initarg := keyword
value := T

body := implicit-progn

より詳細な仕様についてはSpecファイルか、同内容のGithub-wikiを参照されたし。

Conclusion

読者諸兄の中には「そこまで神経質なエラーハンドリングする?」とお疑いの方もいらっしゃる事と思う。 筆者自身からして「ここまで神経質なエラーハンドリングは書かないよねぇ。。?」と思ってもいる。

しかしながら自分が作っているシステムの、自分で書いたエラーメッセージに対して、自分で「わっかんねぇよ!」「で、どうしろと?」「お前どこだよ?」などと思ってしまった時は諦めて神経質なくらい書くことにしている。

これにはメリットもあり、自分の書いたエラーメッセージのおかげでエラー箇所が容易に特定できスムーズにデバッグ等対応出来た場合、「俺スゲェェェ!」と自画自賛でき脳内麻薬がじゅるじゅる出てモチベーションの維持に絶大な効力を発揮することとなる。

Appendix

Tips

エラーハンドリングのコードはアルゴリズムそのものとは、極論すれば無関係なものであり、そのようなコードで溢れ返ればコードの見通しが著しく悪くなる。 そのような場合にはMACROLETが有用である。

例えば以下のように書けば、少しはスッキリすることだろう。

(macrolet((!(form)
            `(RESIGNAL-BIND((SUBROUTINE()'ERROR "Blah blah")
                            (EMPTY-NAME-SYMBOL()'ERROR "Hoge hoge"))
               ,form)))
  (defun something(arg)
    (let((temp(helper arg)))
      (typecase temp
        (symbol (!(procedure-for-symbol temp)))
        (string (procedure-for-string temp))
        (t (error "Fuga fuga"))))))

なお、筆者の「自分ルール」に於いて、「コンディションを受けてコンディションを投げる」は‘!’、「NILならコンディションを投げる」は‘?!’、「コンディションを受けたらRETURN-FROMする」は'!?'となっている。

また、エラーハンドリングコードをMACROLETを利用してメインロジックの外側に出してしまうというアプローチはSPLIT-SEQUENCEのソースで初めて出会って以降、好んで真似させてもらっている方法である。

Behavior of SIGNAL

SIGNALはコンディション指定子を受け取り、ハンドラがあるか探し、ハンドラが有ればコールし、なければ黙ってNILを返すというものである。

(signal 'error) => NIL

多くの処理系では上記のようにトップレベルでSIGNALを呼べばNILが返る。 ただし、そうでない処理系もある。 具体的には(僕の知る限りでは)ECLがそうである。 ECLで上記フォームを評価するとデバッガに入る。

ではECLは仕様に反しているのか? そうとは言えない。 というのも仕様は「ハンドラが無ければNILを返す」と言っているだけであり、「トップレベルにはけしてハンドラはない」とはどこにも書かれていないからである。