glider-gun's Blog

何か書きます

Debugging Lisp Part 4: リスタート

このエントリーは、著者の許可をいただいて http://malisper.me/category/debugging-common-lisp/ のCommon Lispのデバッグに関する連載を翻訳するものです。

目次: 第1回 第2回 第3回 第4回 第5回


このエントリーは連載Debbugging Lispの第4回です。これまでに再コンパイルインスペクトクラスの再定義について扱いました。

多くの言語において、エラーを扱う機能は2つに分かれています。いわゆる throw と catch です。 throw は何かが失敗したことを検知し、エラーを何らかの方法で通知します。このとき throw は発生した問題の情報を含んだ例外オブジェクトを作ります。一方 catch はこの例外オブジェクトを受け取って、エラーからの復帰を試みます。

throw / catch の問題は、 throw が catch への無条件の goto のように振る舞うことです。このため、 throw が呼ばれた状況について、例外オブジェクトに含められなかった情報の全ては失われてしまいます。これは catch する側のコードが、エラーからの復帰のためにエラー発生時のため欲しい情報があったとき問題になります。

たとえばあなたは、いくつかのファイルを取ってその中の数のリストをパースするようなライブラリを作っているとしましょう。その最初の関数 read-file は一つのファイルを読んで結果のリストを返します。2つ目の関数 read-files はファイルのリストを取って、各々のファイルの内容をさらにリストにして返します。以下はエラー処理を考えない場合のコードの例です:

1
2
3
4
5
6
7
8
9
(defun read-file (file)
  (with-open-file (in file :direction :input)
    (loop for line = (read-line in nil in)
          until (eq in line)
          collect (parse-integer line))))
 
(defun read-files (files)
  (loop for file in files
        collect (read-file file)))

ライブラリをテストするために、あなたは手元にファイルを 2 つ持っています。1つ目の内容は 5, 10, 15, 20, 25 で、2つ目は 5, 10, 15, 20, a, 30, 40 です。ライブラリがエラーを正しく処理することを確かめるために、2つ目のファイルには “a” とだけ書かれた行を含めることにしたのです。現状では、 parse-integer がこの行でエラーを通知します。ライブラリのテストを簡単にするため、あなたは2つのファイルへのパスネームのリストを変数 *files* に保存しました。このライブラリをこの2つのファイルに対して走らせるとこうなります:

1
2
3
(read-files *files*)
 
 => ERROR

2つ目のファイルの “a” によってエラーが発生しました。あなたはライブラリの設計者として、このような状況で何が起こるべきか、決めなければいけません。使っている言語が catch / throw しか提供しない場合に取れる選択肢を見ていきましょう。

1つ目の選択肢は、単にエラーを起こした項目を無視することです。これを行うためには、Common Lisp での catch 機能にあたる handler-case を使うことができます:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(defun read-file (file)
  (with-open-file (in file :direction :input)
    (loop for line = (read-line in nil in)
          until (eq in line)
          when (handler-case (parse-integer line)
                 ;; C is the name being used to
                 ;; refer to the exception object.
                 (error (c)
                   (declare (ignore c))
                   nil))
          collect it)))
 
(read-files *files*)
 
=> ((5 10 15 20 25) (5 10 15 20 30 40))

もう一つの選択肢は、ライブラリのユーザーが設定出来るようなスペシャル変数1を提供して、不正な値の代わりに用いられる値を設定出来るようにすることです:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(defvar *malformed-value* nil)
 
(defun read-file (file)
  (with-open-file (in file :direction :input)
    (loop for line = (read-line in nil in)
          until (eq in line)
          when (handler-case (parse-integer line)
                 (error (c)
                   (declare (ignore c))
                   *malformed-value*))
          collect it)))
 
(let ((*malformed-value* :malformed))
  (read-files *files*))
 
=> ((5 10 15 20 25) (5 10 15 20 :MALFORMED 30 40))

3つめの選択肢は、 read-files がエラーを catch して、不正な値の入ったファイル全体をスキップするようにすることです:

1
2
3
4
5
6
7
8
9
10
11
(defun read-files (files)
  (loop for file in files
        when (handler-case (read-file file)
               (error (c)
                 (declare (ignore c))
                 nil))
        collect it))
 
(read-files *files*)
 
=> ((5 10 15 20 25))

最後の選択肢は、ライブラリのユーザー自身に例外処理をまかせることです:

1
2
(handler-case (read-files *files*)
  (error (c) (do-something)))

ユーザーにとっては、最後の選択肢はいくらか有用です。エラーをどう処理するかの柔軟性があるからです。こうすることの問題は、上で述べたように、ユーザにとってはエラーから正しく復帰するようにすることが難しいことです。単にエラーが発生したファイル1つだけをスキップしたい場合、簡単な方法はありません。エラー処理のコードが走るのは関数 read-files を去ったあとだからです。これは残りの読むべきファイルなど、状態に関する全ての情報が catch の時には失われているということを意味します。

catch / throw についてのもう一つの問題は、上で上げた 4 つの選択肢のうち、あなたはたった 1 つしか選ぶことが出来ないということです。どの 1 つもほかの 4 つと競合してしまいます。これもやはり、 throw が goto のようにふるまうからです。一度どこへ飛ぶか決めたら、次にどうするかコントロールすることができません。エラーの処理をライブラリのユーザー自身にまかせることにしたなら、状態に関する情報が全て失われるために、ユーザーには簡単にエラーをうまく処理する方法がありません。

ここでリスタートが登場します。Common Lisp では、 catch は2つの異なる部分に分かれています: ハンドラー handler とリスタート restart です。ハンドラーはライブラリのユーザーが束縛し、例外が発生したときに何が起こるべきかを決めるためのものです。リスタートはエラーから復帰する選択肢を提供するため、ライブラリ側で定義します。 もしリスタートに対応した言語を使っていれば、先ほどの選択肢の最初の3つをリスタートとして実装することが出来ます。そしてユーザーがライブラリを使うときには、エラーの処理にそのうちどれを使いたいかを選ぶことが出来ます。以下が先ほどのライブラリを、先ほどの選択肢にそれぞれ対応する3つのリスタートが使えるよう再実装したものです:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(defun ask (string)
  (princ string *query-io*)
  (read *query-io*))
 
(defun read-file (file)
  (with-open-file (in file :direction :input)
    (loop for line = (read-line in nil in)
          until (eq in line)
          when (restart-case (parse-integer line)
                 (use-value (value)
                   :report "Use a new value."
                   :interactive (lambda ()
                                  (list (ask "Value: ")))
                   value)
                 (skip-entry ()
                   :report "Skip the entry."
                   nil))
          collect it)))
 
(defun read-files (files)
  (loop for file in files
        when (restart-case (read-file file)
               (skip-file ()
                 :report "Skip the entire file."
                 nil))
        collect it))
 
;;; The three functions below are predefined
;;; handlers for the most common ways the user
;;; will interact with the restarts.
(defun skip-entry (c)
  (declare (ignore c))
  (invoke-restart 'skip-entry))
 
(defun skip-file  (c)
  (declare (ignore c))
  (invoke-restart 'skip-file))
 
(defun use-value-handler (value)
  (lambda (c)
    (declare (ignore c))
    (invoke-restart 'use-value value)))

リスタートはマクロ restart-case で定義され、関数 invoke-restart によって呼び出されます。いくらか簡略化して言うと、リスタートを呼び出すとのは、エラーが通知されたところからリスタートの本体(body)にジャンプするのと実際上同じことです。これは、リスタートが確立された時点でスタックに保存されていた全ての状態が、リスタートが呼び出された時点でまだ使えるということを意味します。これにより、ライブラリのユーザーに、エラー発生時にどうするかについてずっと粒度の細かいコントロールを与えることができます。

エラー時に何が起こるべきかユーザーが決めるには、マクロ handler-bind を使うだけです。 handler-bind はエラーの種類と、その種類のエラーが通知されたときに呼び出すべきハンドラー(これは関数のはずです)を引数に取ります。ハンドラーはそのとき invoke-restart を呼ぶことで、ライブラリが持っているリスタートのうち一つを呼び出します。上のライブラリのコードには、リスタートひとつにつきひとつのハンドラー関数が用意してありますが、これはこれらが最もよくある種類のハンドラーだからです。以下はそれぞれのハンドラーが先ほどの2つのファイルに対して使われたときの例です:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(handler-bind ((error #'skip-entry))
  (read-files files*))
 
=> ((5 10 15 20 25) (5 10 15 20 30 40))
 
(handler-bind ((error #'skip-file))
  (read-files files*))
 
=> ((5 10 15 20 25))
 
(handler-bind ((error (use-value-handler 0)))
  (read-files files*))
 
=> ((5 10 15 20 25) (5 10 15 20 0 30 40))

リスタートが本当にクールなのは、ユーザーがハンドラーを設定していなかったときの振る舞いです。この場合ユーザーはSlimeデバッガに入ることになります。可能なリスタートのリストが提示されて、まるで最初からエラーがハンドリングされていたかのようにエラーを処理することができます!以下はユーザーがエラーをハンドルせず、そして実行中に (デバッガから) skip-entry リスタートを選んだ時の様子です:

さらにクールなこととして、この"対話的リスタート"はブレークポイントを実装するのに使えます!第1回で書いたように、 Common Lisp はブレークポイントをエディタの機能としてではなく関数 “break” として提供しています。例えば次のような実装ができるでしょう:

(defun break (&optional (format-control "Break")
              &rest format-arguments)
   (with-simple-restart (continue "Return from BREAK.")
     (let ((*debugger-hook* nil))
       (invoke-debugger
         (make-condition 'simple-condition
           :format-control   format-control
           :format-arguments format-arguments))))
   nil)

このコードは “continue” というリスタートを用意してからエラーを通知することで動作します。つまり、breakが呼ばれた直後、実行を続行するという選択肢つきのデバッガに入るわけです。この動作はブレークポイントそのものです。

リスタートは Common Lisp のデバッギングにおける素晴らしい機能のひとつです。エラーが発生したとき、よりよい制御を可能にしてくれます。しかも、自分でそれをハンドルしなかったときには対話的にリスタートを選んでコードの実行を続けられるのです。

原文: http://malisper.me/2015/08/05/debugging-lisp-part-4-restarts/


  1. ダイナミック変数とは、要するにシャドウ(shadow、覆い隠す)可能なグローバル変数です。ダイナミック変数がシャドウされると、変数に対するすべての参照は新しい値を指すようになります。変数をシャドウしたフォームから抜けた時点で、ダイナミック変数は以前の値に戻ります。

Comments