本当は怖いHPC

HPC屋の趣味&実益ブログ

Practical Common Lisp の once-onlyマクロを解読

Practical Common Lisp 第8章 の最後に書かれている once-only マクロは、一見すると初心者には全く理解不能に思える。これは相当知識をつけないと理解できないんじゃないか?という気分になるけれど、コツをつかめば意外と素早く理解できるので、その説明を書いてみた。

(注:以下、「gensym関数によって生成されるシンボル」のことを指して単にgensymと言っているので、読み替えてください)

まず、once-onlyマクロを説明しておこう。

マクロ定義では、意図せぬ複数回の評価から引数を守ることが必要になる場合がある(もちろん、意図的に複数回評価する時もある)。このとき、基本的には gensym でシンボルを生成し、各引数の値を let で gensym にバインドして使う。こうすることで、それぞれの引数がただ一回評価されるようにできる。

(defmacro my-macro (foo bar)
  (let ((foo-value (gensym))
        (bar-value (gensym)))
    `(let ((,foo-value ,foo)
           (,bar-value ,bar))
       (...
                         ...))))

のような感じ。

once-onlyマクロは、defmacroによるマクロ定義中で使うマクロ、つまりマクロ定義のためのマクロだ。このonce-onlyマクロを使うと、マクロ定義中で呼び出すだけで引数のちょうど一回の評価が保証される上に、引数の名前をそのまま使うことが可能になる。

(defmacro my-macro (foo bar baz)
  (once-only (foo bar baz)
    (...
      ;;; それぞれの評価の結果を保持したシンボルとして、,foo / ,bar / ,baz がそのまま使える
          ...)))

こんな具合だ。魔法みたいだ。

そして、once-only の定義を Practical Common Lisp の100ページから引用してみると、

(defmacro once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
       `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
          ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
                ,@body)))))

let が4つもあって、そこら中にコンマやバックスラッシュが散りばめられている。連続で並んでいるところすらある。
さて、これをどう攻略していこうか。とりあえず macroexpand-1 をかけてみた。

CL-USER> (macroexpand-1 '(once-only (foo)))
(LET ((#:G2211 (GENSYM)))
  `(LET ((,#:G2211 ,FOO))
     ,(LET ((FOO #:G2211))
        )))
T

お、意外と簡単になった。冷静に読んでみると、動作としては、

  1. とりあえず引数を一回評価して、gensymにバインドする
  2. その値をfooという名前に再度バインドする

という動作のよう。これがマクロの中で使われるマクロだということを忘れなければ、展開結果にbackquoteが含まれるのも意外ではない。なるほど、once-onlyの定義に2重のbackquoteが含まれるのは、展開時に1重のbackquoteにするためか。ちょっと敵の正体がわかった。

ここまでわかれば、自分で書いて理解してみることができそうだ。セオリー通り、まず展開の結果を作成しよう。引数2個バージョンを作成してみた。

;; once-onlyマクロの使用法
(defmacro my-macro ((foo bar) &body body)
  (once-only (foo bar)
    `(...
       ,@body)))

;; 展開結果の目標
(defmacro my-macro ((foo bar) &body body)

  ; (1) my-macro展開後のコードで、foo/barの評価結果をバインドするgensymを作る
  (let ((foo-tmp-bind (gensym)) (bar-tmp-bind (gensym))

    ; (2) my-macro展開後のコードで、それぞれのgensymにfooとbarの評価結果が格納されている
    `(let ((,foo-tmp-bind ,foo) (,bar-tmp-bind ,bar))

       ; (3) ここで、一度backquoteから抜ける。マクロ展開時に評価されるコードに戻り、
       ; それぞれのgensymをfooとbarという変数に再度バインドする。よって、マクロの本体の
       ; backquote内で,fooとすれば(1)のgensym、つまりfooの評価結果が取れることになる。
       ,(let ((foo foo-tmp-bind) (bar bar-tmp-bind))

          `( ... マクロ本体 ... ,foo ... ,bar ... ))))) ; (4) foo と barを使ってマクロの中身を書く

そして、この使用法と展開結果から、マクロを作成してみる。once-onlyマクロの定義を見るとloopを使っているので、
ここで引数がfooとbarの2個に固定されているものが任意個になるためにloopを使っているのだろうと容易に想像できるが、とりあえず、引数であるシンボルの数は上と同じ2個に固定して書いてみる。ちょっと試行錯誤した結果、こうなった。普通のマクロと同じように、gensym するために let が一階層増えた。

(defmacro my-once-only ((foo bar) &body body)
  (let ((foo-tmp-bind (gensym)) (bar-tmp-bind (gensym)))
    `(let ((,foo-tmp-bind (gensym)) (,bar-tmp-bind (gensym))) ; (1)
       `(let ((,,foo-tmp-bind ,,foo) (,,bar-tmp-bind ,,bar))  ; (2)
          ,(let ((foo ,foo-tmp-bind) (bar ,bar-tmp-bind))     ; (3)
                ,@body)))))                                   ; (4)

さて、2引数で動作するものができたので、これを多引数で動作するように改良しよう。foo/barになっている部分を
本物のonce-onlyマクロの形を目指して、リストとloopマクロで書き換える。

(defmacro my-once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for n in names collect `(,n (gensym))))
       `(let (,,@(loop for n in names for g in gensyms collect ``(,,n ,,g)))
          ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
                ,@body)))))

これも少し試行錯誤が必要だったが、何度か手習いをするうちに理解できるようになった。大事なとは、loopの結果として何段階にbackquoteされたリストを返すか、という点に注意すること。そうすれば、``(,,n ,,g) のようなややこしいところもちゃんと記述できるはず(というか、これについてはもっとちゃんとした変換のコツや法則があるのかも…。自分もPCLで修行中の身なのでこれ以上はよく知らない…)

というわけで once-only マクロの中身へのチャレンジでした


実践Common Lisp
実践Common Lisp佐野匡俊 水丸淳 園城雅之

オーム社 2008-07-26
売り上げランキング : 7266

おすすめ平均 star
star実作業でLispを使う

Amazonで詳しく見る
by G-Tools

【広告】