この記事は関西Lispユーザ会アドベントカレンダー2017の23日目です。
CommonLispを触っていて、例外処理まわりが面白いと感じました。
CommonLispには"Condition System"という例外処理機構があります。Condition Systemの基本は、Conditionの通知とハンドリングです。
基本
Conditionというのはエラーや警告、何らかの通知を表すものです。実際にはcondition
クラスを継承したクラス(CLOSのクラス)のことを指します。condition
クラスのサブクラスとしてerror
, warning
, simple-condition
クラスなどがあり、通知の重要度や内容によって使い分けます。もちろん、これらを継承して独自のConditionクラスを定義することもできます。
Conditionクラスの定義にはdefine-condition
マクロを用います。以下の例ではsimple-error
クラスを継承したbad-instruction-error
クラスを定義しています。:report
でメッセージの表示を制御しています。
(define-condition bad-instruction-error (simple-error)
((inst :initarg :inst
:initform nil
:accessor bad-inst))
(:report (lambda (condition stream)
(format stream "Bad instruction: ~A" (bad-inst condition)))))
Conditionの通知のための関数には以下のようなものがあります。いずれもConditionの通知を行いますが、ハンドリングされなかった場合の挙動に違いがあります。
(error (make-condition 'bad-instruction-error :inst foo)) ;ハンドリングされなければ、デバッガを起動
(warn "file exists") ;ハンドリングされなければ、警告メッセージを出力して実行を継続
(signal "end of file") ;ハンドリングされなければ、実行を継続
Conditionをハンドリングするためにはhandler-bindマクロを用います。以下の例では、error
クラスとwarning
クラスのハンドラを用意しています。(foo)
内で通知されたConditionがハンドラのクラス(もしくはサブクラス)であれば、そのハンドラが呼ばれます。
該当するハンドラが複数ある場合は、上のものから順番に実行されます。また、全てのハンドラの実行が終わったあとは、さらに上位のhandler-bind
へハンドリングを委ねます。次のハンドラへ制御が移って欲しくない場合はgo
やreturn-from
でハンドラから脱出する必要があります。(脱出することで"ハンドリングした"ことになります。脱出しないと"ハンドリングを辞退した"ことになり次のハンドラへ移ります)。いずれかのハンドラの実行後必ず脱出して欲しい時のために、handler-case
マクロがあります。
(handler-bind ((error #'(lambda (cond) ...))
(warning #'(lambda (cond) ...)))
(foo))
再開
「これってよくある、throw/catchによる例外処理機構では??」と思われたかもしれません。
throw/catchによる例外処理機構では、例外が発生すると処理が中断され、その後の処理はハンドラに委ねられてしまいます。ですがConditon Systemでは先程述べたとおり、warn
関数やsignal
関数を使うことで、通知だけしてその後の処理は継続するといったこともできます。いや、それだけでなく中断された処理をハンドラ側から再開することもできるんです。
Condition System における再開機能はrestartと呼ばれます。再開できるようにするためにはまず、restart-case
で再開地点を用意しておく必要があります。以下のコードでは、(foo)
の実行中にConditionが通知されハンドラへ移されても、ここまで戻ってきて処理を再開できます。
処理の再開にはinvoke-restart
関数を使います。これは普通、ハンドラ内で呼ばれます。第一引数にrestart名を指定します。これは、restart-caseで定義されているrestart-1
やrestart-2
に当たります。ここで指定したrestart名のところから再開されます。また、第二引数以降はrestartの際に渡す引数となります。
(restart-case
(foo)
(restart-1 (value)
...)
(restart-2 (value)
...))
(handler-bind ((error #'(lambda (cond)
(invoke-restart 'restart1 bar))))
...)
デバッガが起動された際、restart候補があれば教えてくれます。もちろんデバッガからのrestartも可能です。以下はSBCLでの表示です。
debugger invoked on a SIMPLE-ERROR in thread
#<THREAD "main thread" RUNNING {100399C4C3}>:
invalid value
Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.
restarts (invokable by number or by possibly-abbreviated name):
0: [RESTART-1] RESTART-1
1: [RESTART-2] RESTART-2
2: [ABORT ] Exit debugger, returning to top level.
例外には、対処すれば再開可能であるようなものがあります。例えば、メモリ不足のときに不要なメモリを解放すれば再開できます。ある値が範囲内に収まっていなければ、中断し、ユーザから再度入力を受けるか値を補正するかして再開することができます。例外発生時のコンテキストで再開できれば、このような場面で非常に役に立ちます。
私は以前CommonLispでPrologの処理系を作った1のですが、その時はrestart機能を例外処理というよりは特殊な制御構造として利用しました。Prolog処理系は対話的に使用でき、質問を入力すると解を探して見つかればそれを表示します。そして、次の解を探すかどうかをユーザに尋ねます。ユーザの選択に応じて解の探索を継続するか、中断します。
作成した処理系はPrologプログラムをWAM命令列へコンパイルし、それを仮想マシンで実行します。解が見つかると仮想マシンからREPLに一旦制御を移しますが、次の解を要求されたら続きから再開させる必要があります。単純に解をリターンしてしまうのであれば、再開のために仮想マシンの状態を保存することが必要になります。
そこで、Condition Systemのrestartの出番です。仮想マシンは解が見つかるとConditionを通知します。REPLはそれを受け取って解を表示したのち、次の解が必要ならばinvoke-restart
します。必要ないならばそのままreturn-from
でその回の探索は打ちきります。
次のコードは、PrologのREPL(read-eval-print-loop)を提供するrepl関数です。
prolog-found-solution
は解が見つかったときに通知されます。caseでno/yesの分岐をしている箇所があります。noの場合は次の解を要求されているのでrestart名next-solution
から再開します。yesの時はreturn-from
で次の式のreadへ移ります。
なお、このコードでは通常のエラー処理にもCondition Systemを使用しています。prolog-syntax-error
やprolog-compile-error
はそれぞれ構文エラーやコンパイルエラーを表すConditionです。
(defun repl (...)
(loop
(block eval-once
...
(handler-bind
((prolog-query-failed (lambda (c)
(declare (ignore c))
(format *query-io* "~%no.~%")
(return-from eval-once)))
(prolog-found-solution (lambda (c)
(show-solution (vars c))
(if (or (null (vars c))
(not (can-backtrack? c)))
(progn (format *query-io* "~%yes.~%")
(return-from eval-once))
...
(case (next-solution-prompt)
(no (invoke-restart 'next-solution))
(yes (format *query-io* "~%yes.~%")
(return-from eval-once)))))))
(prolog-syntax-error (lambda (c)
...
(return-from eval-once)))
(prolog-compile-error (lambda (c)
...
(return-from eval-once))
... ))
...
...
< 入力を読み、パース、コンパイルし、最後にsend-queryを呼ぶ >
...
... )))
以下のsend-query
関数はREPLから呼ばれ、Prologコードをコンパイルした結果のWAM命令列の実行を行います。解が見つかった際の処理を抜粋しています。解が見つかると、prolog-found-solution
をsignal
関数で通知します。prolog-found-solution
クラスのスロットに解をセットしておくことでREPLに解を渡します。そして、next-solution
から再開できるようになっています(再開されるとbacktrack
関数を呼んでバックトラックを起こす)。
(defun send-query (query-code)
...
(case (car inst)
(notify-solution
(restart-case
(signal
(make-conditon 'prolog-found-solution
:vars (make-solution-result (second inst))
:can-backtrack? (/= *B* bottom-of-stack)))
(next-solution () (backtrack))))
...
... ))
終わりに
Condition Systemは非常に柔軟な例外処理機構を提供しています。そして、ANSI標準が定められているので安心して使えます。
Condition Systemの大きな特徴としてrestartがあげられます。例外処理機構を備えた言語は多いですが、restartができる言語はほとんどありません(継続が使える言語では同様のことが実現できると思いますが)。言語の例外処理機構を再開可能(resumable)にするかどうかは昔から議論があったそうです。1970年代Xeroxで開発されたMesaという言語はrestart相当の機能をもっていましたが、Mesaで書かれた50万行のシステムで最終的に1箇所しかrestartが使われていなかったそうです。1991年のC++標準化会議でXeroxの人がその話をしたこともあって、C++はresumableになりませんでした。(最近のC++だとどうなのだろう??)
確かに頻繁に使うものではないとは思いますが、役立つ場面もあるはず...。
Condition Systemを使いこなして、よい例外処理を!
参考文献
[1] Common Lispにおける例外処理 -Condition Systemの活用-, 株式会社数理システム 知識工学部
http://cl-www.msi.co.jp/solutions/knowledge/lisp-world/tutorial/condition-system.pdf
[2] Common Lisp Hyperspec 9.Conditions
http://www.lispworks.com/documentation/HyperSpec/Body/09_.htm
[3] Exception handling (Wikipedia)
https://en.wikipedia.org/wiki/Exception_handling
[4] Exception Handling with Resumption:Design and Implementation in Java, Alexander Gruler, Christian Heinlein
https://pdfs.semanticscholar.org/5d86/c175cdc03377b4bcc24df6e67753a940f97f.pdf