第9章「コンディション」

概要

ANSI Common Lispの第9章「コンディション」を説明します。
Common Lispのコンディションシステムは非常に発達した例外処理機構として設計されています。一般的な言語に見られるthrow - catchのようなシンプルな例外処理だけでなく、複雑なフローコントロールも可能であり、デバッガなどのCommon Lispの基本システムともよく統合されています。

コンディションシステムの全貌を体系的に説明することはとても難しいので、このページではコンディションシステムの仕様と実践を様々な観点からサンプル中心に示します。

REPLとコンディション

Common Lispが極めて「動的な言語」として利用可能であるのは、REPL ( Read-Eval-Print Loop )とデバッガを備えている点です。実はこれらの機能の基盤としてコンディションシステムが使われており、安全にREPLを使うことができるようにしているのです。

試しに、Common Lisp処理系で以下のように打ち込んでみます。
(+ 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

この式では+という加算関数に1"2"という2つの引数を渡して評価しようとしていますが、"2"は数ではなく、加算できないため、エラーになっています。

関数の設計と例外への対応

「エラー」と述べましたが、これは仕様に定められた動作です。ANSI Common Lisp仕様の+関数の項には以下のように定められています。
Description:
Returns the sum of numbers, performing any necessary type conversions in the process. If no numbers are supplied, 0 is returned.
Exceptional Situations:
Might signal type-error if some argument is not a number. Might signal arithmetic-error.
Description には関数本体の仕様が、Exceptional Situations には例外的状況への対処方法が定められています。
+の場合は「引数が数でない場合にtype-errorが通知される可能性があること、arithmetic-errorの可能性もあること」が例外への対応として記述されています。つまり、"2"を引数として与えた時に発生したエラーは、+関数に組み込まれた例外処理だったのです。

このように関数は本来の目的に加え、例外的な状況に対処するような方法を備えていなければなりません。関数の内部で対処できればそれは本体仕様になりますが、関数本体で対処できず、関数の呼出し元に異常を知らせるだけでも十分な対応です。つまり、安全な関数は以下の機能を持つことが要求されます。
  • 関数本体の処理が記述されていること。想定される入力(引数やデータの読み込み)に対して、正しい出力(返り値やプリント)を行うことができるような計算のプロセス。
  • 例外的状況の発生を通知すること。想定されていない入力(型異常、ファイル不存在、通信切断など)が発生し、そのままでは処理を継続できない状態に陥った場合に、そのことを適切に関数の呼出し元に伝えること。
  • 例外的状況への対処方法を提示すること。単純に処理を中断してプログラムを終了するしか方法がない場合は「終了する」という対処を提示し、それ以外の回復方法がある場合は、それを呼出し元に提示して選んでもらうこと。

最後の砦: デバッガ

関数の内部で発生した異常事態(例外的状況)を関数の呼出し元に伝え、可能であれば対処方法も合わせて提示することが安全な関数の設計に重要であることを述べましたが、「呼出し元」とは何でしょうか。

例えば、以下の式の場合、外側の式が内側の式を「呼んでいる」ことになります。
(* (+ 1 2) 3)
; => 9

Common Lispのプログラムは関数呼び出しの連鎖として設計されます。そのため、関数の実行中には必ず呼出し元が存在します。関数の呼出し元は、呼出した関数の計算結果を待っている状態であり、計算結果(返り値)を得ると残りの計算を続けます。このように、ある関数を呼出し中(評価中)の時、残っている計算を「継続(continuation)」と言います。
しかし、関数には呼出し元が常に存在することと、その呼出し元の関数が通知された異常事態(例外的状況)にうまく対処できることは別の問題です。これから呼び出す関数において異常が発生する可能性があることを想定していない呼出し元は、結局自分では例外的状況に対処できず、その関数自身もまた異常事態に陥ることになります。つまり、例外は遡及します。

このように例外が呼出し元を遡及していく時、最後の砦になるのが「デバッガ」です。例外にうまく対処できない場合は、最終手段としてデバッガが立ち上がり、プログラムの予期せぬ終了や暴走を防ぎます。デバッガは最後の砦として異常事態の中身をユーザーにレポートし、対処方法が存在する場合はそれを提示します。存在しない場合はプログラムの終了(トップレベルへの復帰)を選択肢として提示します。

もう一度、加算関数のエラーの例を掲載します。
(+ 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

これは、+関数内部の異常を制御できる関数が存在しないため、デバッガが最後の砦として立ち上がったことを示します。(今回は+関数がトップレベルから呼ばれているため、そもそも呼出し元もREPL自身です。)

このメッセージは4行ですが、以下のようなことを説明しています。
  • 例外の内容の表示: +関数において、"2"は数ではない、という異常を知らせてくれます。
  • 対処方法の提示: 2つの対処が可能であることを提示してくれます。
    1. :R1: use-valueにより、代わりに使う値を入力するという対処方法
    2. :R2: abortにより、メインループ(トップレベル)に戻るという対処方法
+関数で単に型異常を発生させただけであるにも関わらず、Common Lispのデバッガは様々な働きをしてくれます。デバッガは例外をハンドリングする最後の砦であることを覚えておいてください。

回復: use-valueabort

次は、「対処方法の提示」で示されている2つの方法に注目してください。このような「対処方法」はANSI Common Lispで Restart と呼ばれ、標準化されています。再起動や回復などと訳される場合もありますが、訳語はどのようなものでも構いません。

ANSI Common Lispの標準仕様で定められている Restart は5種類ありますが、いずれも同名の関数としても定義されており、使いやすくなっています。ここでは+の例で提示されている2種類を説明します。
Restart: abort
トップレベルにまで戻るという対処方法です。マルチプロセスなどの場合は、プロセスをKILLすることなども含まれます。基本的に全てのコードがabort機能付で実行されるので、プログラムのどのような場所からでもabortで戻ることができます。
Restart: use-value
異常が発生した値を新しい値で置き換えて、異常が発生した場所に回復するという対処方法です。新しい値は1度しか使うことができません。デバッガで対話的にこの対処方法を選択すると新しい値を入力することができます。
せっかくなので、2つの対処方法を別々に試してみます。まずはuse-valueからです。
(+ 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

:r1                       ;; ここで :r1(use-value) を選択している
; Use instead> 2          ;; 代わりの新しい値として 2 を入力した
; => 3                    ;; 正しい値である 3 を得ることができた
use-valueが新しい値の入力を受け付け、その値を使って計算を続けていることが分かると思います。では次はabortです。
(+ 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

:r2                       ;; ここで :r2(abort) を選択している
;; トップレベルに戻る(返り値はない)
abortを使うと残りの計算をすっ飛ばしてトップレベルにまで戻っています。このように残りの計算をすっ飛ばすことを「継続の破棄」と言います。abortは継続の破棄を行う Restart です。

例外の捕捉(ハンドリング)

さて、最も単純なREPLでの処理においても様々な例外対処機構があることが分かったと思いますが、すでに説明した通り、デバッガは「最後の砦」ですから、そこに到るまでに異常事態へ対処できればベストです。

異常事態に対処するには、まずはその異常事態発生通知を受け取らなければなりません。部下から報告を受ける時、対処方法を考える前に部下の話を聞くのが先です。例外も同じように、どのような異常なのかを適切に聞き分けなればなりません。

このように、異常事態発生通知を聞くことを「ハンドリング」すると言います。ANSI Common Lispでは3つの「ハンドラー」(handler-case, handler-bind, ignore-errors)が定められており、このハンドラーで例外をハンドリングします。ハンドリングはthrowスペシャルオペレータに対するcatchスペシャルオペレータに似ていることから、「捕捉」するとも表現されます。

handler-caseマクロ

最も手軽なハンドラーはhandler-caseマクロです。ここでは、handler-caseを使って、型異常の際はエラーの通知ではなくnilを返すという関数を定義してみます。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error (condition)
      (declare (ignore condition))
      nil)))
; => MY-ADD

(my-add 1 2)
; => 3

(my-add 1 "2")
; => NIL

下の実行例ではエラーが発生せず、デバッガも起動していないことに注目してください。静かにNILを返すだけです。
handler-caseマクロはcaseマクロと同じように記述しますが、構文は以下のようになります。
(handler-case expression
  (typespec1 (var) declaration1* form1*)
  (typespec2 (var) declaration2* form2*)
  :
  :
  (:no-error (vars*) declaration-last form-last*))

expression の部分に評価する式を記述します。これは1つだけなので、複数式の場合はprognで括ります。

typespec は型指定子です。typecaseマクロと同様に型でマッチングされますが、マッチングされるのは expression の評価結果ではなく、その評価過程で発生した例外の型です。
var には発生した例外を束縛する変数名を記述します。

declaration には各種宣言を記述します。今回のように捕捉した例外を使わない場合はdeclareシンボルでignoreしなければコンパイル時に警告が表示されます。(ただし、省略形を使うこともできます。本節最後の参考を参照してください。)
form は例外発生時の処理です。今回はtype-errorという型でマッチングさせて、nilを返すので、nilform にしていますが、こちらは暗黙的にprognされるので複数式をそのまま記述することができます。

最後に:no-errorという型はcaseマクロのotherwiseシンボルと同様に、例外が発生しなかった場合の処理を記述します。
handler-caseマクロで例外を捕捉した場合、さらに上の関数には例外の発生通知が伝播しなくなります。つまり、handler-caseは例外の遡及を止めます。

「例外」とは何か: コンディション

ところで、これまで「例外」や「異常事態」と述べてきた状態は何者なのでしょうか。
ANSI Common Lispでは「例外」はクラスを用いて実装されます。つまり、「例外」とはconditionクラスを継承して定義されたサブクラスのインスタンスです。ANSI Common Lispでは「例外」という言葉は Exceptional Situation 、つまり例外的状況を指す言葉として用いられ、その状況において通知され、捕捉(ハンドリング)される実体は「コンディション」と呼びます。

前節のhandler-caseマクロは捕捉したコンディションを利用することができます。my-add関数を以下のように再定義すると、例外が発生した時にそのコンディションそのものを返すことができます。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error (condition)
      condition)))
; => MY-ADD

(my-add 1 "2")
; => #<SIMPLE-TYPE-ERROR #x00000002003C0B91>

ここではsimple-type-errorクラスのインスタンスであることが分かります(処理系によって異なる可能性がありますが、type-errorかそのサブクラスであることは確実です)。
コンディションはインスタンスなので、アクセッサでスロットにアクセスすることができます。type-errorクラスは以下の2つのアクセッサを標準で用意しています。
Function: type-error-datum
datumスロットを読み取ります。このスロットには例外を発生させた問題のデータが入っています。
Function: type-error-expected-type
expected-typeスロットを読み取ります。このスロットには期待される正しい型の情報が入っています。
試しに、my-add関数を以下のように再定義してみます。例外が発生した時は、コンディションの中にある上の2つのスロットの値を多値として返します。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error (condition)
      (values (type-error-datum condition)
              (type-error-expected-type condition)))))
; => MY-ADD

(my-add 1 "2")
; => "2"
;    NUMBER
+関数を使用した場合のエラーメッセージは以下の通りでしたから、コンディションのスロットの値を使ってメッセージを表示していることになります。
(+ 1 "2")
; *** - +: "2" is not a number

simple-conditionのスロット

simple-type-errorクラスはtype-errorクラスとsimple-conditionクラスを継承しています。simple-conditionクラスには2つのスロットがあり、以下のアクセッサで読み取ることができます。
Function: simple-condition-format-control
format-controlスロットを読み取ります。このスロットにはメッセージを表示するためのフォーマット文字列が入っています。
Function: simple-condition-format-arguments
format-argumentsスロットを読み取ります。このスロットにはメッセージの表示に必要な引数が入っています。
現在試している処理系(GNU CLISP)でフォーマット文字列を確認したところ、"~S: ~S is not a number"でしたから、エラーメッセージは具体的に以下のように表示していることになります。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error (condition)
      (let ((args (simple-condition-format-arguments condition)))
        (format t (simple-condition-format-control condition)
                (first args)
                (second args))))))
; => MY-ADD

(my-add 1 "2")
; +: "2" is not a number
; => NIL
+は異常事態が発生した関数のシンボル、"2"は異常事態が発生したデータです。このように見ると、CLISPではexpected-typeのスロットに入っているNUMBERではなく、format-controlスロットの"is not a number"という文章がそのまま使われていることが分かります。
+関数は仕様ではtype-errorを通知することが求められていますが、そのサブクラスであるsimple-type-errorまでは求められていないので、このようなスロットが使えるかどうかは処理系依存ですので、参考程度に覚えておいてください。

参考: handler-caseの省略形

handler-caseマクロはとても便利なオペレータで、例外的状況が発生するとコンディションを捕捉し、適切な処理を施して脱出してくれます。上の例では、type-errorが発生した際にnilを返すという処理をしています。

前掲のサンプルではハンドラーの中で束縛したコンディションをignoreを使って破棄していますが、コンディションを使わないことは結構よくあります。また、例外発生時に返り値としてnilを返すこともよくあります。

そのため、handler-caseには2つの省略スタイルが認められています。
  • コンディションの束縛を記述しない省略形。
  • ハンドリング処理を記述しない省略形。
この2つの省略形は併用できるので、type-error発生時にnilを返すmy-add関数は以下のように定義することができます。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error ())))
; => MY-ADD

(my-add 1 "2")
; => NIL

こちらの方がシンプルですので、省略形を使える時は積極的に利用してください。

デバッガを超えて(Restartの起動)

さて、今までに見てきたことを一旦総括すると、以下のようになります。
  • Common Lispの関数には例外的状況の通知が組み込まれていて、コンディションシステムをそのまま使うことができる。
  • 例外の通知は伝播(遡及)する。最後まで遡及した場合、「最後の砦」であるデバッガが受け止める。
  • コンディションの実体はクラスのインスタンススロットにもアクセスできる。
  • handler-caseマクロを使うとデバッガよりも手前でコンディションを捕捉(ハンドリング)することができる。
  • handler-caseマクロは例外の遡及を止める。つまり、ハンドリングされるとそのハンドリング処理の結果が全体の結果となる。
handler-caseマクロは非常に便利なのですが、デバッガよりも手前でハンドリングしたいという要求以上に多様なフロー制御をしたい場合は使うことができません。それはどのような場合かというと、一番最初に示した Restart を使う場合です。

デバッガはエラーの表示と対処方法の提示を行い、use-valueabortなどを選択すれば対処方法を対話的に変更することができます。特にuse-valueのような処理は、例外が発生した箇所に制御を戻すという処理ですが、handler-caseマクロにおけるハンドリング処理の結果は最終的な評価結果として返り値になってしまうため、元の箇所に戻して処理を継続することはできません。つまり、handler-caseマクロは、「例外が発生する直前時点での継続」を破棄して、選択された「ハンドリング処理」という新しい継続を構築して計算するということを意味します。

Common Lispは人工衛星や軍事戦略システム、航空機運行管理システムなどの「ミッションクリティカル」な分野で使われているため、エラー(異常事態)が発生したからといって、「もう、さようなら」とプログラムを終了するわけにはいかないことがあります。何らかの対処方法を提示して、何とか困難を切り抜けようとするための手段が Restart です。handler-caseマクロはそのような目的で使うことができないのです。

この節では Restart の起動について、デバッガで選択するという処理を超えた部分を説明します。

invoke-debugger関数

Restart (対処方法)の利用は3つのステップに分けられます。
  1. Restart そのものを確立し、アクティブな状態にしておく。
  2. 異常事態が発生した時に、利用可能な Restart を提示する。
  3. 選択された Restart を評価し、異常事態に対処する。
最初に見たREPLでのデバッガの起動は2番目に該当します。デバッガはエラーが発生すると自動的に起動しますが、関数を使って明示的に起動することもできます。その関数がinvoke-debuggerです。

例えば、今までに何度も使ってきたmy-add関数で、+関数のエラー通知(コンディション)をhandler-caseマクロで捕捉しつつ、そのコンディションに基づいてデバッガを起動するには以下のように定義します。
(defun my-add (x y)
  (handler-case (+ x y)
    (type-error (condition)
      (invoke-debugger condition))))
; => MY-ADD

(my-add 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; ABORT          :R1      Abort main loop

明示的にデバッガを起動することができました。

継続を破棄しないhandler-bindマクロ

しかし、Restart の選択肢が少ないことに注目してください。REPLで直接エラーを発生させた時はuse-valueという Restart が提示され、それを選択すると実際に処理を継続することができたのですが、今回は単に処理を終えるabortという Restart しか提示されていません。

これが、前節で述べた「例外が発生する直前時点での継続」が破棄されているという状況です。エラーの原因である"2"に到る直前の継続とは、以下のような計算であると考えられます。
(lambda (x) (+ 1 x))
"2"の部分がxに変わっていますが、このxに正しい型(すなわち数)のデータが来ればこの継続が適切に計算されるはずでした。use-valueという Restart"2"に変わる新しい値をこの継続の引数として適用する、という対処方法であると言えます。

このように、例外が発生する直前時点での継続を破棄せず、Restart の処理においてその継続を使いたいという場合はhandler-bindマクロを使います。

実はhandler-caseマクロはhandler-bindマクロをベースに実装されているのですが、ハンドリングの処理を行いやすいようにフローの制御も自動で行うようになっています。フローの制御とは何度も説明している通り、以下のような流れです。
  1. 例外が発生した場合、その時点での継続は破棄する。
  2. 型がマッチするハンドリング処理にジャンプ。
  3. ハンドリング処理の結果をhandler-case全体の返り値としてreturn-fromする。
handler-bindマクロはもっとシンプルで、継続を破棄したり、自動でreturn-fromによる脱出を行ったりすることはありません。必要なフロー制御は自分で行う必要がある反面、「動的環境」を伴って処理を継続することができます。動的環境を伴うということは、例外が発生した時の状況がハンドリング処理においても続けられている(参照可能である)、ということを意味します。ハンドリング処理においては Restart を起動することで、残っていた継続を再び計算することができるのです。
handler-bindマクロでデバッガを明示的に起動するようなmy-add関数は以下のような定義になります。
(defun my-add (x y)
  (handler-bind
      ((type-error
        #'(lambda (condition)
            (invoke-debugger condition))))
    (+ x y)))
; => MY-ADD

(my-add 1 "2")
; *** - +: "2" is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

:r1
; Use instead> 2
; => 3

REPLに直接打ち込んだ時と同じように、use-valueという Restart が使用できることが分かります。

ここでは明示的にデバッガを起動し、その中で Restart を使用していますが、Restart を使う場合はhandler-caseではなくhandler-bindを使うようにしましょう。逆に Restart を使わない場合は、handler-caseの方が簡潔に記述できます。

なお、handler-bindマクロの構文は極めてシンプルです。
(handler-bind
    ((type1 handler-function1)
     (type2 handler-function2)
               :
               :
     (type-last handler-function-last))
  forms)

forms は暗黙的にprognされるため、複数の式をそのまま記述します。handler-functiondefunで定義された関数か、lambdaによる無名関数で、いずれも関数の実体が必要です。handler-bindには「例外が発生した場合にはハンドリング処理関数を評価する」ということ以上のフロー制御が組み込まれていないため、通常は handler-function の中でフロー制御を行います。そして、その代表的な制御はこの節で使ったinvoke-debugger関数ではなく、invoke-restart関数です。

invoke-restart関数

Common Lispにおいてデバッガは最後の砦であり、invoke-debugger関数を使わなくても自動的に起動します。そのため、デバッガそのものをオリジナルに実装する場合を除き、あまり使われることはありません。
handler-bindとの組み合わせで威力を発揮するのは、invoke-restart関数です。この関数を使えば、デバッガを起動せずに Restart を直接起動することができます。
先にサンプルを示しましょう。
(defun my-add (x y)
  (handler-bind
      ((type-error
        #'(lambda (condition)
            (let ((v (type-error-datum condition)))
              (if (typep v 'string)
                  (invoke-restart 'use-value (read-from-string v))
                  (invoke-debugger condition))))))
    (+ x y)))
; => MY-ADD

(my-add 1 2)          ;; 数の場合は普通に計算(例外処理は無関係)
; => 3

(my-add 1 "2")        ;; 文字列の場合は数に変換して Restart
; => 3

(my-add 1 'two)       ;; 数でも文字列でもない場合はデバッガを起動
; *** - +: TWO is not a number
; The following restarts are available:
; USE-VALUE      :R1      Input a value to be used instead.
; ABORT          :R2      Abort main loop

:r1                   ;; use-value を選択して Restart
; Use instead> 2      ;; 'two の代わりに 2 を使うので入力
; => 3                ;; 正しく計算できた
invoke-restart関数は第1引数にアクティブな Restart を識別できるシンボルを取ります。すでに説明した通り、handler-bindマクロは例外が発生した時の動的環境をハンドリング処理関数でも使うことができるので、use-valueという Restart を指定しています。

今までの流れで"2"がエラーとなることを想定していたので、エラーとなったデータが文字列の場合は、一度read-from-string関数で数に変換してからuse-value( Restart )を起動しています。より一層安全性を確保するなら、read-from-stringしたデータをそのままuse-valueに渡すのではなく、そのデータが数であることをtypepで確認してから渡した方がいいかもしれません。(例えば"two"というデータを想定してみてください。)

もちろん、エラーとなる原因のデータは文字列であるとは限らないので、ifスペシャルオペレータの else 部分ではデバッガを起動しています。すると今まで通りデバッガが起動し、利用可能な再起動を提示してくれるので、use-valueを選んで新しい値を入力すれば正しい計算結果を得ることができます。

しかし、実はANSI Common Lispのコンディションシステムはもう1歩自動化できます。どこだと思いますか。

invoke-restart-interactively関数

答えはエラーの原因が文字列ではない場合の「起動したデバッガでuse-valueを選択する」という動作です。

このmy-add関数は、何としても加算処理を完遂させたいと考えています。計算が最後まで終わっていないのに途中でabortするという選択肢は極力避けたいとします。そのため、以下のような仕様を想定します。
  1. 引数が数の場合: 普通に加算する
  2. 引数が文字列の場合:
    • 文字列をread-from-stringすると数になる場合: 自動的にread-from-stringして加算する
  3. 上記以外の場合: 代わりの値を入力してもらい、加算する
仕様3において、「代わりの値を入力してもらう」というのが今まで使ってきた Restart であるuse-valueであることは分かると思いますが、デバッガの中でuse-valueを選択することをユーザーに期待するのではなく、例外のハンドリングの中で自動的に選択するようにするのです。これがもう1歩の自動化です。
invoke-restart関数は Restart に引数を与えてそのまま起動する関数ですが、Restart に与える引数を対話的に入力してもらうことができる関数がinvoke-restart-interactivelyです。
この仕様に合わせたサンプルを以下に示します。
(defun my-add (x y)
  (handler-bind
      ((type-error
        #'(lambda (condition)
            (let ((v (type-error-datum condition)))
              (when (typep v 'string)
                (setq v (read-from-string v)))
              (if (typep v 'number)
                  (invoke-restart 'use-value v)
                  (invoke-restart-interactively 'use-value))))))
    (+ x y)))
; => MY-ADD

(my-add 1 2)           ;; 引数が数の場合: そのまま加算
; => 3

(my-add 1 "2")         ;; 引数が文字列で数に変換できる場合: 変換して加算
; => 3

(my-add 1 "two")  ;; 引数が文字列だが数に変換できない場合:
; Use instead> 2       ;; 代わりの値を入力
; => 3                 ;; 代わりの値で加算

(my-add 1 'two)        ;; その他のデータ型の場合も同じ
; Use instead> 2
; => 3
invoke-restart-interactively関数を使うことで、デバッガを起動させずに対話的な入力環境を利用することができることが分かると思います。ここでは Use instead としか表示されないので多少不親切ですが、それでもかなり「例外的状況に強い」関数になったと思います。
invoke-restart関数やinvoke-restart-interactiveluy関数を用いて Restart を自在に操ることで、デバッガに依存せずに例外的状況へ対処することができます。ANSI Common Lispのコンディションシステムが他言語の例外処理機構と根本的に異なるのも、この Restart が独立して操作可能になっているからです。ANSI Common Lispのミッションクリティカルな分野への執念とも言えるような粘り強さを感じることができるのではないかと思います。

ちなみに、ANSI Common Lisp標準の+関数は可変長引数ですが、ここで定義したオリジナルのmy-add関数も簡単に可変長に対応することができます。reduceという高階関数を使い、my-addをラップするようなmy+関数を定義します。すると、すぐに可変長引数にも対応します。
(defun my+ (&rest args)
  (reduce #'my-add args))
; => MY+

(my+ 1 "2" 3 "4" 5 "6" 7 "8" 9)
; => 45

例外の通知(シグナリング)

ここまでの節で扱ったトピックは、大きくまとめると以下の4つです。
  • コンディションの概要( Condition
  • 例外の補足( Handling
  • 回復方法の起動( Invoke Restarts
  • デバッガの利用( Debugging
さらにコンディションシステムを利用するには、以下の3つのトピックが必要です。
  • 例外の通知( Signaling
  • 回復方法の確立( Establish Restarts
  • 例外の定義( Define Condition
この節では、例外を通知する方法について説明します。

様々な通知関数

ANSI Common Lispでは例外を通知する関数が4つ定められています。
Function: error
エラーを通知します。ハンドリングされない場合は、自動的にinvoke-debugger関数を使ってデバッガを起動するため、処理が中断します。通知されるコンディションは標準ではsimple-errorクラスのインスタンスです。
Function: warn
警告を通知します。ハンドリングされない場合は、コンディションの内容を表示します。返り値はnilです。通知されるコンディションは標準ではsimple-warningクラスのインスタンスです。
Function: signal
シグナルを通知します。ハンドリングされない場合でも、特に特別な介入動作は行いませんので、ハンドリングすることを前提に使われます。返り値はnilです。通知されるコンディションは標準ではsimple-conditionクラスのインスタンスです。
Function: cerror
continue(Restart)付でエラーを通知します。continueは標準のRestartの一つで、エラーが発生した場所に単純に復帰します。
最初の3つは、通知するコンディションに応じて使い分けますが、ポイントは動作が異なる点です。単純にまとめると、以下のようになります。

動作 error warn signal cerror
デバッガの起動 Yes No No Yes
内容の表示 Yes Yes No Yes
返り値 None nil nil nil
continueの有無No No No Yes

標準のcondition
simple-errorsimple-warningsimple-conditionsimple-error
errorには明示的な返り値がありませんが、cerrornilが返り値となります。

通知関数で通知できるもの

通知関数は前節で説明したような違いがありますが、構文はどれも同じで、第1引数に通知するデータ、第2引数以降は可変長引数で引数に対する引数になります(cerrorのみ異なりますので、次節で説明します)。これらのパターンは3種類あります。
  1. データがシンボルの場合
    • シンボルに対応するコンディションを作成し、可変長引数は作成時の引数として適用されます。
  2. データが文字列の場合
    • 標準のコンディションを作成し、format-controlスロットに第1引数を、format-argumentsスロットに可変長引数をセットします。
  3. データがコンディションの場合
    • 新しいコンディションは作成せず、コンディションそのものを通知します。この場合は可変長引数(第2引数以降)を指定することはできません。
それぞれサンプルで示します。まずはシンボルの場合です。内容としてはどれも引数が'double-float型ではない場合に警告を表示します。
(defun check-double-float (v)
  (unless (typep v 'double-float)
    (warn 'simple-warning
          :format-control "~a is not the type of double-float."
          :format-arguments (list v))))
; => CHECK-DOUBLE-FLOAT

(check-double-float 1.0)
; WARNING: 1.0 is not the type of double-float.
; => NIL
'simple-warningというシンボルで同名のクラスを基礎とするコンディションのインスタンスを作ることができます。残りの部分はwarn関数のキーワード引数ではなく、可変長引数としてまとめてリストにされ、コンディション作成時に使われます。

次は文字列です。エラーや警告のメッセージを作成するなら、文字列が便利です。
(defun check-double-float (v)
  (unless (typep v 'double-float)
    (warn "~a is not the type of double-float." v)))
; => CHECK-DOUBLE-FLOAT

(check-double-float 1.0)
; WARNING: 1.0 is not the type of double-float.
; => NIL

第2引数以降は可変長引数の機能で自動的にリストになりますので、リストとして渡さず引数として渡します。

最後はコンディションそのものです。
(defun check-double-float (v)
  (unless (typep v 'double-float)
    (warn (make-condition
           'simple-warning
           :format-control "~a is not the type of double-float."
           :format-arguments (list v)))))
; => CHECK-DOUBLE-FLOAT

(check-double-float 1.0)
; WARNING: 1.0 is not the type of double-float.
; => NIL
make-condition関数はコンディションを作成します。CLOS (Common Lisp Object System)におけるmake-instance総称関数と同じ構文ですが、こちらはコンディションの作成専用です。シンボルや文字列の場合は、自動的にmake-condition関数を適用していることになります。

ということで、この節の冒頭では「通知するデータは3種類」と述べたのですが、実は通知されるものは全てコンディションなのです。シンボルや文字列はコンディションの作り方が違うだけで、通知される実体はコンディションです。以下のように使い分けてください。
  1. 通知したいコンディションが標準のものでいい場合は、文字列を使用してください。
  2. 通知したいコンディションが標準のものではないが、スロットの初期化は必要ない場合は、シンボルを使用してください。
  3. 通知したいコンディションが標準のものではなく、スロットの初期化も行う場合は、make-condition関数を使用してコンディションそのものを渡してください。

cerror関数とcontinue

error, warn, signalの各通知関数はデバッガの起動やメッセージの表示が違うので分かりやすいと思いますが、cerror関数に付いているcontinueは説明が必要でしょう。
continueは標準で用意されている Restart の一つで、単純にエラー発生場所にフロー制御を戻すというものです。
Restart: continue
コンディションが通知された場所に制御を戻すという対処方法です。コンディションが通知されたのに何もせずに制御を戻すことはあまりないので、このRestartを使う場合は制御を戻す前に何らかの副作用的処理を行うことが一般的です。
continueを使うと関数の処理本体とエラーハンドリングの間を行き来することができるので、例えばFizzBuzz問題を型とコンディションシステムで解くこともできます。
;;;; サンプル: FizzBuzz Problem Solving with Type and Condition System
(defun mod3p (v) (zerop (mod v 3)))
(defun mod5p (v) (zerop (mod v 5)))
(deftype mod3 () `(and integer (satisfies mod3p)))
(deftype mod5 () `(and integer (satisfies mod5p)))
(deftype mod15 () `(and (satisfies mod3p) (satisfies mod5p)))

(defun check-fizzbuzz (v)
  (typecase v
    (mod15 (cerror "" "FizzBuzz"))
    (mod3 (cerror "" "Fizz"))
    (mod5 (cerror "" "Buzz"))
    (otherwise (cerror "" (write-to-string v)))))

(defun fizzbuzz (n)
  (handler-bind
      ((simple-condition
        #'(lambda (c)
            (format t "~a "
                    (simple-condition-format-control c))
            (invoke-restart 'continue))))
    (loop for i from 1 to n
      do (check-fizzbuzz i))))

最初のブロックでは型の定義をしています。mod3pは3で割り切れるかどうかを判定する述語関数で、mod5pは5の場合の述語関数です。これらの述語関数を満たすことを条件にmod3mod5という型を定義しています。mod15は両方の型を同時に満たすような場合として定義できます。
check-fizzbuzz関数は引数の型を調べ、対応するエラーを発生させます。このページのここまでの範囲ではまだ独自のコンディションを定義する方法と Restart を確立する方法を示していないので、cerror関数を使って「continue付のsimple-errorクラスのコンディション」を通知しています。どのように区別するかというと、format-controlスロットの値で使い分けています。
cerror関数は残りの3つのコンディション通知関数よりも必須引数が一つ多いです。最初の引数はcontinueの説明に使うフォーマット文字列ですが、今回はデバッガでcontinueを選択することはないので使いません。また、第2引数は前節で説明したようにシンボル・文字列・コンディションのいずれかのデータになるので、数をそのまま渡すことはできず、write-to-string関数を使って文字列に変換してから渡しています。

実際のFizzBuzz関数はfizzbuzzです。この関数は1から引数の値まで繰り返しを行いながらcheck-fizzbuzz関数を呼び出しますが、handler-bindマクロで包んでいるので、check-fizzbuzzを評価している最中に発生したコンディションをハンドリングすることができます。cerror関数で通知しているので渡されてくるコンディションはsimple-errorクラスのインスタンスですが、デバッガの起動やメッセージの表示は行わないので、本来的にはどのようなコンディションでも構いません。ただ、format-controlスロットを使っているので、simple-condition型でマッチさせています。(simple-errorsimple-warningsimple-conditionのサブクラスとして定義されているので、format-controlスロットやformat-argumentsスロットにアクセスすることができます。)
continueを起動しているのはinvoke-restart関数です。ここで、制御フローが元に戻り、処理を継続します。

回復方法の確立

Restart はとても便利で堅牢性の高い例外処理を可能にしますが、これまでに使ってきた Restart は全て既存のものでした。既存のものとは、ANSI Common Lispに定められており、可能であれば回復手段を提供すべき、となっているようなもので、abort, use-value, continueの3種類を使いました。

標準の Restart は他に2種類定められています。
Restart: muffle-warning
警告の内容を無視するという対処方法です。警告を通知するwarn関数を発した場所に対して、警告の内容はすでに処理済みであり、追加の対応は必要ないことを知らせることができます。例えば、警告の内容をリアルタイムで表示する必要はなくても、その時のコンディションを保存しておき、後からそのコンディションを使いたい場合などに使用します。(ANSI Common Lispに掲載されているサンプルは大まかにこのような動作を想定しているようです。)
Restart: store-value
異常が発生した値を新しい値で置き換えて、異常が発生した場所に回復するという対処方法です。use-valueとは異なり、新しい値は変数に保存することを想定しているので、再利用可能です。そのため、返り値を得る場合に限らず、副作用ベースの動作でも用いられます。デバッガで対話的にこの対処方法を選択すると新しい値を入力することができます。
これらの Restart はANSI Common Lisp標準のため、様々な場面で使うことができる可能性がありますが、必ずしも常に提供されるわけではありません。当然ながら、abortしか対処方法が存在しない場合も多いですし、独自の対処方法を提供したい場合もあります。

この節では、restart-caseマクロを使って対処方法(例外的状況からの回復手段)を確立する仕組みを示します。この節で説明するトピックはrestart-caseマクロに限られますので、以下のサンプルを複数の小節に分けて説明します。
(defun input-for-restart ()
  (format t "Enter a new value: ")
  (list (eval (read))))
; => INPUT-FOR-RESTART

(defun check-double-float (v)
  (restart-case
      (if (typep v 'double-float)
          v
          (error "~a is not the type of double-float." v))
    (coerce ()
      :report
      (lambda (stream)
        (format stream "Coerce the value ~a to the type of double-float." v))
      (coerce v 'double-float))
    (store-value (x)
      :report "Input a value to be stored instead."
      :interactive input-for-restart
      (setq v x)
      (format t "New value is ~a.~%" v)
      (check-double-float v))))
; => CHECK-DOUBLE-FLOAT

restart-caseマクロ

restart-caseマクロは Restart を確立するためのマクロです。restart-bindというマクロでも確立することができますが、restart-caseの方がフロー制御が自動化されており、便利なので、restart-bindはあまり使われません。
restart-caseマクロはhandler-caseマクロと同様の構文を持っており、第1引数の式を評価中に例外的状況が発生してコンディションが通知されるような場合、第2引数以降の Restart を確立してアクティブな状態にして提供します。「最後の砦」であるデバッガによってコンディションがハンドリングされる場合はアクティブな状態にある Restart を回復方法の選択肢として提示することができますし、デバッガの手前でハンドリングされた場合はinvoke-restart関数やinvoke-restart-interactively関数で Restart を使うことができます。

全く独自のものを提供することもできますし、ANSI Common Lispの標準で定められた5つの Restart のいずれかを提示することもできます。restart-caseで標準の Restart を提示する場合も独自のものと同じように定義する必要がありますが、回復方法の意図が標準のものと同じであれば同じ名前を用いて提示すべきです。その方がプログラムの利用者が分かりやすくなります。

ちなみに、関数の設計としては、具体的な処理や計算を担う関数においては
  • 例外が発生した場合に備え、コンディションを通知する機能を含めておく
  • コンディションが実際に通知された場合に備え、回復方法を含めておく
という「通知」( Signaling ))と「回復」( Restart )の2つの機能を含んでおくべきですが、その2つの機能の間にある「捕捉」( Handling )は含めるべきではありません。関数内部の異常を知るべきなのはその上司に当たる呼出し元であり、残りの計算である「継続」をどうすべきかは処理している関数の上位関数(最後の砦はデバッガ)に委ねるべきです。上位関数は「どうにかして処理を継続したい」と考えるかもしれませんし、「いっそのこと処理を終了したい」と考えるかもしれません。つまり、ハンドリングは本質的にかなりアドホックなものであり、正しいハンドリングの仕方があるわけではないのです。他方、アドホックなハンドリングをできるだけ正しく進めることができるように、コンディションを正しく通知し、多様な対処方法を提供しておくことが処理関数に求められる役割です。

前節で掲載したサンプルは2つの関数を含みますが、上のinput-for-restart関数は補助関数です。標準入力からデータを読み取り、評価してリストにして返します。下のcheck-double-float関数はすでに簡易版を示した'double-float型かどうかを確かめる関数ですが、述語ではありません。'double-float型ではない場合はエラーを通知し、2つの回復方法coercestore-valueを提供します。最初から型が一致していた場合や、回復方法の選択により型が一致した場合はその値を返します。つまり、非常に多くの副作用を伴っていますが、返り値を使うことも想定した関数です。
restart-caseマクロの使い方はサンプルの通りなのですが、2つのオプションについて個別に説明します。

:reportによる表示

restart-caseマクロで確立できる Restart には「レポーター」を含めることができます。レポーターとはデバッガのような対話的な回復方法の提示において、その回復方法の動作や意図を簡潔に説明するための表示手段です。

「レポーター」は:reportオプションで指定します。指定の方法はいくつかあります。
  • lambda式: 出力用ストリームを引数に取る無名関数。
  • シンボル: defunflet等で定義された関数名。関数の引数はlambda式の場合と同様。
  • 文字列: 単に文字列を表示するだけの場合に使われる文字列。
coerceという Restart は値を'double-float型に変換することを想定していますから、現在の値が何なのかを明示的に説明した方が分かりやすいでしょう。そのため、表示はフォーマット文字列を使いたいので、lambda式を使っています。

他方、store-valueという Restart はANSI Common Lispでも定められているもので、新しく値を得て、何らかの変数に保存するという動作を想定しています。ここでは既存の値は破棄され、結局使われませんので、表示内容の中に現在の値を含める必要はないと判断しました。そこで、単なる文字列を渡しています。
:reportオプションによるレポーターが存在しないと回復方法の名前だけが表示され、デバッガでもとても分かりにくくなってしまいますから、:reportオプションはほぼ必ず含めるようにしてください。

:interactiveによる対話機能

独自に定義している2つの回復方法の意図については前節で示しましたが、store-valueは新しい値を使うため、その値を何らかの方法で取得する必要があります。
invoke-restart関数でこの回復方法が選択された場合、その新しい値は引数として回復方法名の次のラムダリスト(ここでは(x)の部分)で変数に束縛されます。この変数束縛はデバッガやinvoke-restart-interactively関数のような対話的な方法で取得された場合も同様で、この部分で新しい値を得ることになります。coerceは新しい値が必要ないので、ラムダリストが空リストになっているのが分かると思います。
invoke-restartの場合は問題がないのですが、対話的な方法で新しい値を得る場合、その方法が問題となります。どのように入力を促し、入力されたデータをどのように「新しい値」として使うかが問題となるでしょう。そこで、新しい値を使って処理を継続するような回復方法の場合に、対話的な値の取得に備えてその場合の関数を用意しておく必要があります。それを指定するのが:interactiveオプションです。
:interactiveオプションはlambda式かシンボルを取りますが、どちらも(function ...)スペシャルフォームの形式で評価されるため、最終的には関数の実体として評価されるべきものである必要があります。新しい値を入力してもらって返す、というのは多少汎用性のある動作なので、input-for-restart関数を別途定義して、そのシンボルを:interactiveオプションで指定しています。

ポイントは以下の2点かと思います。
  1. 新しい値はリストの形式にしておく必要があります。
  2. 入力データをevalするかどうかは適宜判断してください。
1つ目については、選択された Restart の実行が実際には関数の適用として、
(apply #'store-value x)
のように行われるため、引数はリストの形式にしておかなければなりません。

2つ目については、例えば3(+ 1 2)の違いをどう考えるか、というような問題です。普段はあまり意識しないかもしれませんが、Common Lispのコード読み取り器にとってはこの2つは別物です。
(read-from-string "3")
; => 3; 1

(read-from-string "(+ 1 2)")
; => (+ 1 2); 7

これらのコードを別物として扱いたいならevalは必要ありません。しかし、同じ値として扱いたいなら、一度「評価 (evaluation)」をする必要があります。
(eval (read-from-string "3"))
; => 3

(eval (read-from-string "(+ 1 2)"))
; => 3

ANSI Common Lispの仕様に掲載されたサンプルでもevalしていますが、自己評価値のみを受け付けることを前提にすれば、evalは外しておいてもいいと思います。また、evalをおいた途端にセキュリティが脆くなるため、そのリスクは十分に考慮してください。もし Restart による回復の最中の入力で悪質なコードを打ち込まれた場合、そのままevalするとユーザーは何でもできてしまいます。((loop)と打ち込むだけで無限ループに陥ります。)
Common Lisp以外では Restart という機能がほどんど実用化できていないので、回復手段の理想の設計などについてはあまり研究が進んでいないのではないかと思います。私自身も確固たるポリシーがあるわけではありませんので、各々研究してみてください。

独自コンディションの定義

コンディションに関する最後の主要なトピックは、コンディションの定義です。

コンディションは実体としてはconditionクラスを継承するサブクラスのインスタンスです。conditionクラスそのものからコンディションを生成することはほとんどなく、整備された継承関係に基づいてerror, warn, signalという通知関数に対応するようなコンディションをサブクラスから生成することになります。

この節ではコンディションの定義に関するトピックを扱うため、クラスとコンディションの違い、:reportオプション、標準のコンディションについて説明します。

クラスとコンディションの違い

クラスとコンディションの違いはほとんどなく、コンディションはクラスをベースに、Common Lisp Object Systemを基盤として構築されています。

しかし、コンディションがクラスと異なる点が4つあります。(この4点はANSI Common Lispのcondition型の説明に記述されています。)
  1. 定義オペレータ: defclassマクロではなく、define-conditionマクロを用いる。
  2. 生成オペレータ: make-instance総称関数ではなく、make-condition関数を用いる。
  3. 表示オペレータ: print-object総称関数ではなく、define-condition:reportオプションを用いる。
  4. アクセスオペレータ: slot-value, slot-boundp, slot-makunbound, with-slotsのようなオペレータを使ってスロットにアクセスするのではなく、define-conditionで定める:reader, :writer, :accessorのようなアクセス関数でアクセスする。
この4点を見ればわかる通り、コンディションに特有な追加的な機能は3番目の:reportオプションだけです。そこで、次節ではこのオプションについて説明します。

:reportオプション

コンディションはインスタンスの一種なので、普通にprintすると Unreadable Object の形式で表示されます。

しかし、コンディションを表示するケースというのは、エラーに遭遇してデバッガが起動した場合がほとんどで、コンディションの実体そのものを表示するよりは、コンディションがどのような状態を示すかというエラーメッセージを表示した方がユーザーフレンドリーです。

そのため、コンディションはクラスと異なり、:reportというオプションが設けられています。この:reportオプションにはレポート用の無名関数を設定することができ、print-object総称関数のように振る舞うことができます。

例えば、このページで示した「'double-float型でない場合にエラーを通知する」という場合に、それに適した独自のコンディションを定義すると、以下のようになります。
(define-condition type-error-double-float (simple-type-error)
  ()
  (:report (lambda (condition stream)
             (format stream "Error: ~s is not the type of double-float."
                     (type-error-datum condition)))))
; => TYPE-ERROR-DOUBLE-FLOAT

(make-condition 'type-error-double-float :datum 10)
; => #<TYPE-ERROR-DOUBLE-FLOAT #x0000000200427159>

(error (make-condition 'type-error-double-float :datum 10))
; *** - Error: 10 is not the type of double-float.
; The following restarts are available:
; ABORT          :R1      Abort main loop
define-conditionマクロがコンディション定義オペレータです。第1引数はコンディション名、第2引数はスーパークラスのリスト、第3引数はスロット関連の定義、第4引数が全体のオプションです。:reportオプションはdefine-conditionマクロの最後に記述します。
:reportオプションに渡す無名関数はコンディションとストリームの2引数を取る関数でなければなりませんが、これはprint-object総称関数の構文と同じです。

特徴的なのは、表示形式の違いです。
  1. 普通にコンディションの実体を表示する場合: Unreadable Object のスタイルで表示される。
  2. コンディションを通知してデバッガなどでハンドリングした場合: :reportオプションのレポーター関数で表示される。
コンディションは通知されるものであり、通知されたものは常にハンドリングされるとは限らないので、独自のコンディション定義にはほぼレポーター関数の設定が不可欠となります。

標準のコンディション

独自コンディションの定義をあえてこのページの最後に持ってきたように、ANSI Common Lispでは様々なコンディションがあらかじめ定められており、仕様に準拠した処理系であれば違いを意識せずに利用することができます。そのため、独自のコンディションを定義しなくても十分な例外処理を実現することが可能でしょう。

しかし、ある程度の大きさのプログラム(「アプリケーション」と呼ぶレベルのもの)になれば、アプリケーション独自のベースととなるコンディションを定義して、そのサブクラスとして細かいコンディションを定義するようにして、システム標準のコンディションとアプリケーションレベルのコンディションを分けて使った方が良いでしょう。コンディションの型はエラーが発生した場合の状態を見分ける最も有力な情報であり、システムがエラーを発しているのか、アプリケーションがエラーを発しているのかを見分けられることは重要な情報です。

そのため、合理的なコンディションは、2つの種類のコンディションをミックスインしていくことで 定義されます。
  • 例外的状況に最も近いANSI Common Lisp標準のコンディションクラス
  • 独自に定義して継承関係が構築されているアプリケーションレベルのコンディションクラス
例えば、例外的状況が「型が異なる」というものであれば、システムレベルでもアプリケーションレベルでも共通してtype-errorコンディションを継承しておくべきです。ファイルに関するエラーであれば同様にfile-errorコンディションを継承すべきですし、エラーではなく警告であればsimple-warningコンディションを継承しておくと何かと便利かもしれません。

このように、独自のコンディションを定義する場合は、標準のコンディションを知っておかなければなりません。そのため、ここで標準のコンディションに関する情報を網羅的に示しておきます。

分類は基本的には継承関係を基に行なっていますが、実際に使う場合は必ずそのコンディションがどのコンディションを継承しているかを確認してください。Function として示しているのはコンディションのスロットアクセス関数で、そのスロットに格納されることが期待されるデータの意味を示しています。
  • Condition: condition = 全てのコンディションのスーパークラスです。コンディションとして定義する場合は、必ずこの型を継承しなければなりません。
  • Condition: simple-condition = signal関数のデフォルトコンディションです。
    • Function: simple-condition-format-control = コンディションの表示に用いるフォーマット文字列です。
    • Function: simple-condition-format-arguments = フォーマット文字列の中で用いる引数です。
    • Condition: simple-error = simple-conditionerrorを共に満たすコンディションです。error関数のデフォルトコンディションです。
      • Condition: simple-type-error = simple-errortype-errorを共に満たすコンディションです。type-errorのスロットであるdatumexpected-typeを使うことができるので便利です。
    • Condition: simple-warning = simple-conditionwarningを共に満たすコンディションです。warn関数のデフォルトコンディションです。
  • Condition: warning = 警告を示すコンディションです。全ての警告はこのコンディションを継承すべきです。
    • Condition: style-warning = エラーとするほどではないにせよ、間違いの可能性がある場合に発せられる警告のコンディションです。「未使用の変数」であることを警告する場合に使われますが、このことはANSI Common Lispでも定められています。
    • Condition: simple-warning = simple-conditionの項で示した通りです。
  • Condition: serious-condition = 重大な例外的状況を意味するコンディションです。通常はerror関数で通知されます。また、errorコンディションはこのコンディションのサブクラスです。
    • Condition: strage-condition = メモリー管理に関するコンディションです。serious-conditionを継承していますが、errorを継承していないという数少ないコンディションの一つです。これは、メモリーの管理はプログラムの責任ではなく処理系の責任であるため、「エラー」という表現が適さないためです。どのような場合にこのコンディションが通知されるべきかは事前には定められておらず、処理系の判断で通知されます。
    • Condition: error = いわゆる「エラー」です。他の多くのコンディションのスーパークラスとして利用されています。
      • Condition: type-error = 型に関するエラーです。
        • Function: type-error-datum = 型の不正が発生したデータを格納しています。
        • Function: type-error-expected-type = 期待される正しい型のシンボルを格納しています。
        • Condition: simple-type-error = simple-errorの項で示した通りです。
      • Condition: file-error = ファイルに関するエラーです。
        • Function: file-error-pathname = エラーが発生したファイルやディレクトリのパスを格納しています。
      • Condition: stream-error = ストリームに関するエラーです。
        • Function: stream-error-stream = エラーが発生したストリームを格納しています。
        • Condition: end-of-file = ファイルの末尾であることを示すコンディションです。
      • Condition: control-error = フロー制御に関するエラーです。throwgoスペシャルオペレータにおいて使われます。
      • Condition: program-error = 構文に関するエラーです。goタグやblockタグに関して通知されます。
      • Condition: parse-error = 構文解析に関するエラーです。
        • Function: reader-error = Lispリーダーでの区切りの不正や構文解析上のエラーを示します。
      • Condition: cell-error = アクセス(配列へのアクセスやsetfによる place の操作)に関するエラーです。
        • Function: cell-error-name = エラーが発生したアクセスを示す名前(配列名や place )を格納しています。
        • Condition: unbound-slot = インスタンスに未束縛のスロットが存在することを示すエラーです。
          • Function: unbound-slot-instance = インスタンスの名前を格納しています。
        • Condition: unbound-variable = 未束縛の変数であることを示すエラーです。
        • Condition: undefined-function = 未定義の関数であることを示すエラーです。
      • Condition: package-error = パッケージに関するエラーです。
        • Function: package-error-package = エラーの原因となったパッケージ名を示すシンボルを格納しています。
      • Condition: print-not-readable = *print-readably*スペシャル変数がtの時に、Lispリーダーでは読み取れないようなオブジェクトを書き込もうとした時に通知されるコンディションです。
        • Function: print-not-readable-object = 書き込もうとしたオブジェクトを格納しています。
      • Condition: arithmetic-error = 計算上のエラーを示すコンディションです。
        • Function: arithmetic-error-operands = 計算に用いたオペランドのリストを格納しています。オペランドとは計算される数です。
        • Function: arithmetic-error-operation = 計算に用いた演算子(関数)を格納しています。
        • Condition: division-by-zero = ゼロで除算しようとした際に使われれるコンディションです。
        • Condition: floating-point-inexact = 浮動小数点数の計算上で発生したエラーに使われるコンディションです。明確な意味論は定められていません。
        • Condition: floating-point-invalid-operation = 浮動小数点数の計算上で発生したエラーに使われるコンディションです。明確な意味論は定められていません。
        • Condition: floating-point-overflow = 浮動小数点数の計算において桁あふれ(オーバーフロー)が発生した場合のエラーです。
        • Condition: floating-point-underflow = 浮動小数点数の計算において下位桁あふれ(アンダーフロー)が発生した場合のエラーです。

補論: 移動ゲーム

第5章「データと制御フロー」のcatch, throwの項で示した「4方向移動ゲーム」のコンディションシステム版を示します。

内容としてはcatch, throw版と同じですが、このページで説明した以下の項目を含んでいます。
  • コンディションの定義と継承
  • コンディションの通知関数
  • 例外的状況のハンドリング
  • 回復方法の確立と対話的な起動
以下がそのサンプルコードです。
(defparameter *commands* '())
(defparameter *first-flag* t)

(define-condition quit-game (condition) ())
(define-condition unknown (error)
  ((command :initarg :command
            :reader unknown-command)))

(defun move-to (v)
  (restart-case 
      (case v
        ((N S E W) (push v *commands*))
        ((Q) (signal 'quit-game))
        (t (error 'unknown :command v)))
    (use-value (v)
      :interactive (lambda ()
                     (format t "Enter a new command instead: ")
                     (list (read)))
      (push v *commands*))))

(defun get-command ()
  (when *first-flag*
    (format t "Select the direction:~%")
    (format t "  [N] = North, [S] = South, [E] = East, [W] = West~%")
    (format t "  [Q] = Quit.~%")
    (setq *first-flag* nil))
  (format t "(Current commands = [~{~a~^,~}])~%" (reverse *commands*))
  (format t "Enter your command: ")
  (read))

(defun start ()
  (psetq *commands* '()
         *first-flag* t)
  (handler-bind ((quit-game
                  #'(lambda (c)
                      (declare (ignore c))
                      (format t "The End...~%")
                      (format t "  Commands = [~{~a~^,~}]~%"
                              (reverse *commands*))
                      (format t "Bye!!~%")
                      (return-from start 'game-end)))
                 (unknown
                  #'(lambda (c)
                      (format t "*** Error: You entered [~a]. It's wrong!~%"
                              (unknown-command c))
                      (invoke-restart-interactively 'use-value))))
    (loop (move-to (get-command)))))

以下が実行例です。
> (start)
Select the direction:
  [N] = North, [S] = South, [E] = East, [W] = West
  [Q] = Quit.
(Current commands = [])
Enter your command: e
(Current commands = [E])
Enter your command: s
(Current commands = [E,S])
Enter your command: w
(Current commands = [E,S,W])
Enter your command: n
(Current commands = [E,S,W,N])
Enter your command: d
*** Error: You entered [D]. It's wrong!
Enter a new command instead: s
(Current commands = [E,S,W,N,S])
Enter your command: m
*** Error: You entered [M]. It's wrong!
Enter a new command instead: n
(Current commands = [E,S,W,N,S,N])
Enter your command: q
The End...
  Commands = [E,S,W,N,S,N]
Bye!!
GAME-END
>

途中で2回エラーが発生していますが、ハンドラーを使ってデバッガを起動させることなく Restart を使って新しいコマンドを入力しています。また、終了コマンドはシンプルなsignal関数で通知して、ハンドラーのあるstart関数まで戻ってくるという仕様です。
コンディションシステムは通知、捕捉、回復という手順を明確にすることができるため、保守性が高まるでしょう。この例で言えば以下の2つの関数がコンディションシステムを担っており、このページで説明してきた下位関数による「通知と回復」の提供と上位関数による「捕捉」という原則に沿って実装しています。
  • move-to関数: 実際の移動コマンドを扱うので、異常があれば通知する。合わせて回復方法も確立しておく。
  • start関数: ゲームの開始関数であり、プログラムの最上位関数であるので、例外的状況のハンドリングを行う。今回はゲームの終了と未知のコマンド入力についてハンドリングしている。
コンディションシステムを使うと様々なフローコントロールが可能ですので、色々と試してみてください。

0 件のコメント :

コメントを投稿