リードマクロ入門、の10分の1歩ぐらい後か前

Common Lispで遅延評価を作って遊ぶ(1) - eshamster’s diaryシリーズ付録

今回の目的

遅延評価で遊ぶシリーズでリードマクロの話に入ろうとしたのですが、リードマクロ自体について学んだことを書くだけで長くなりそうなので記事を分けました。この辺りが分かっていれば今回は苦労しなかっただろうというあたりを書いていきます。難しく考えたら負け≒おまじないなんてない、ぐらいが得られた教訓でした。

※浅く使っただけなので色々嘘を書きそうです。

参考にしたサイト

ご存知On Lispと「シンタックスが無ければ作ればいいじゃない[PDF]」が参考になりました。ただ、前者は基本的なところだけで終わっていて、後者は前者+αの基本の次にはマクロを使ったエレガントな書き方に進んでしまうので、まずはちょっと自作してみる、という第一段階に至るまで割りと苦労しました。なお、On Lispのset-macro-characterやset-dispatch-macro-characterの解説は既知として話を進めます。

例:list関数の代用

今回使ってみて理解した内容がおおむね入ったサンプルコード(Roswellスクリプト)を書いてみました。「シンタックスが無ければ作ればいいじゃない」を参考にしている部分が多いです。

; これを…
CL-USER> (list 1 (list 2 3 (list 4 'test 5)) 6)

; こう書けるリードマクロを作ってみる
CL-USER> #[1 [2 3 [4 'test 5]] 6]

出力は以下です。途中結果をREADED=と出しているので、きちんとネストして動いていることや、リストの頭に"LIST"というキーワードを付与している様子が分かるかと思います。

(READED = (4 'TEST 5))
(READED = (2 3 (LIST 4 'TEST 5)))
(READED = (1 (LIST 2 3 (LIST 4 'TEST 5)) 6))
(2 3 (4 TEST 5)) 6)

さて、set-macro-characterやset-dispatch-macro-characterが結局のところ何をしているのか、というところを中々つかめずに苦労していたように記憶しています。単に区切り文字を設定している、という点が最初に理解すべき部分だと思いました。例えば、(read-delimited-list #\] stream t)のように終端に指定する#\](ややこしいので以下"]")は事前にset-macro-character(set-simple-delimiter #\])で設定しておく必要があるか、というとそんなことはありません。この指定がなくとも、"#[1_2_[3_4_]_]"のように前後に逐一スペース(アンダーバーで表記)を入れてやれば問題なく動きます。つまり、リーダが"]"というものを認識できればなんでも良いのであって、"(set-macro-character #\] #'do-nothing)" は "3]" のように空白(やその他のデリミタ)なしでも"]"を区切り文字として認識できるようにしているに過ぎません。

冒頭で書いた「おまじないなんてない」というのは例えばこのことで、「read-delimited-listに指定するデリミタには事前にset-macro-characterが必要」…なんていうおまじないはないということです。この辺りの「単純なルールの組み合わせがあるだけ」というところはリードマクロを理解する上で鍵になると感じた次第です。次の段落の内容が直接的ですが、「常にリーダの動作を想像することが大事」とも言い換えられそうです。

他におまじないと勘違いした部分は、指定した終端文字までreadを行うread-delimited-listと開始文字の関係性です。read-delimited-listを使う場合は二文字で始まるset-dispatch-macro-characterの中で使う必要がある…わけではありませんでした。ここでリーダの動作を考えてみます。リーダは"#[1 2 3]"という文字列(ストリーム)を受け取ると、set-dispatch-macro-characterの第一・第二引数で指定された"#["を見つけ、残った文字列"1 2 3]"を第三引数で指定された関数make-list-readerに引き渡します。中で呼ばれているread-delimited-listは引数で指定された"]"が見つかるまでこれを読み込んで、途中で見つけた1, 2, 3を(1 2 3)というリストとして返却します。見ての通り、開始文字"#["を指定する部分とread-delimited-listは独立した処理になっています。そのため、一文字だけを指定するset-macro-characterとの組み合わせでも問題なく使うことができますし、サンプルコードにもそのようなコードを入れています→(set-macro-character #\[ #'make-list-reader)。こうすることで、"#[...]"という環境の中では"#"なしの"[...]"を使うだけでネストができるようにしています*1

ただし、各set関数の引数となる関数は第一引数こそストリームで共通ですが、残りの引数は数も含め違いがあります。今回の範囲では第一引数だけが必要なため、残りはrest引数として受け取ってignoreしています。これも「シンタックスが無ければ作ればいいじゃない」で知ったテクニックです。Lispの柔軟性が感じられて素敵でした。

より細かい部分の話ですが、set-...-macro-characterによる設定は(引数なしの場合)*readtable*というグローバルな構造に書き込まれます。このため、"#"なしの"["をset-macro-characterする部分では*readtable*をコピーし、let環境内のみで影響を持つようにしています。

リードと評価の順序

勉強し始めにREPLで色々試そうと思ってハマったこと。

CL-USER> (defun do-nothing (&rest rest) (declare (ignore rest)))
DO-NOTHING
CL-USER> (let ((*readtable* (copy-readtable *readtable*)))
           (set-macro-character #\& #'do-nothing)
           '(a&b))
(A&B)
CL-USER> (set-macro-character #\& #'do-nothing)
T
CL-USER> '(a&b)
(A NIL B)

REPL環境にゴミを残したくなかったのでlet環境内で*readtable*をコピーすれば…と思ったのですが、上記の通り意図通り動きませんでした。let内の式は一通りreadされてから評価しているということでしょうか。言われてみるとそりゃそうかという気もしますが、一応試してみます。

出力は次の通りで、予想通りコード上の位置はset-deliterの後ろにある"+"のリードマクロが先に動作しており("test"の出力)、set-delimiterの評価はその後で行われていることが分かります("set delimiter"の出力)。この辺りもリーダの動作がイメージできていればすぐに分かったのでしょうが、だいぶハマりました。

"test"
"set delimiter"
(1 "test" |2-3|)

ちなみに、Roswellを知った今となっては、今回のようにRoswellスクリプトを書き起こせばREPLのゴミを気にせずに試行錯誤ができたのにと思います。

追記:結局解決策は…

g000001さんにコメントで、REPL環境内でローカルに試すための方法を教えていただきました。ありがとうございます。(表示が崩れいていたので整形して転載)。read-from-stringを使うことで、letのリード時にはいったんただの文字列として評価させ、一通り設定が終わった段階で満を持して(?)read-from-stringから"@[1 2 3]"のプログラムとしてのリードを依頼するわけですね。なるほど。

※言われてようやく「REPL環境内でローカルに試す」という課題を置いたまま終わらせていることに気づきました…。

(let ((*readtable* (copy-readtable nil)))
  (set-syntax-from-char #\] #\")
  (make-dispatch-macro-character #\@)
  (set-dispatch-macro-character #\@ #\[
                                (lambda (s c a)
                                    (declare (ignore c a))
                                      (read-delimited-list #\] s T)))
  (read-from-string "@[1 2 3]"))
;=> (1 2 3) , 8

シリーズリンク

*1:逆に"#[...]"のままでもネストできることを示すために、出力部分では"[...]"によるネストと混ぜて使っています