【マクロ小ネタ】Common Lisp で defer してみる

時には実用を考えずにマクロを書いて遊んでいると楽しいというだけの記事です。

Go言語の defer が簡単に実装できそうな気がしたので書いてみました。と思っていたら、途中で記事後半の事実に気付いて思ったよりも長くなりました。



defer の単純な模倣

Go言語における defer の動作

Go言語において、defer を利用することで、関数スコープを抜けるときに実行する動作を記述することができます。リソースのクローズなどに利用しますが、ここではそうした実践的な例は捨ておき、次のような例のための例を見ます(以下は適宜抜粋したもの。動作するコードは https://play.golang.org/p/EWOSf5oa3_5

func test(x bool) {
    fmt.Println("before")
    defer fmt.Println("after")

    if !x {
        return
    }

    defer func() {
        fmt.Println("1")
        fmt.Println("2")
    }()
    fmt.Println("3")
}

この defer には次のような特徴があります。

  1. 複数の defer がある場合、通ったのとは逆順で実行される
  2. 実行時に通った defer のみが動作する

まず特徴1を見るため、test(true)として実行すると次のような出力になります。後ろにある "1", "2" を出力する defer の方が、"after" を出力する defer よりも先に実行されています。

before
3
1
2
after

次に特徴2を見るため、test(false)として実行すると次のような出力になります。途中で return して通過しなかった "1", "2" を出力する defer は実行されません。

before
after

マクロを書いて模倣する: with-defer

上で見たような特徴を持つ defer を利用することができるマクロ with-defer を作ってみます。

実装は後で見ますが、これを利用することで先程の test 関数と同じ動作の関数を次のように書けます。ただし、defer が実行されるのは関数スコープを抜けるときではなく、with-defer を抜けるときになります*1

(defun test (x)
  (with-defer
    (print 'before)
    (defer (print 'after))
    (unless x
      (return-from test))
    (defer (print 1) (print 2))
    (print 3)))

動かしてみます。まずは (test t) です。下記の通り、後に出てくる defer ("1", "2" を出力)が最初の defer("AFTER" を出力)よりも先に実行されます。

BEFORE
3
1
2
AFTER

次に (test nil) です。下記の通り、到達しなかった2つ目の defer は実行されません。

BEFORE
AFTER

さて肝心の実装ですが、下記の通り10行程度のマクロで実現できます。

(defmacro with-defer (&body body)
  (let ((g-defer-list (gensym "DEFER-LIST"))
        (g-body (gensym "BODY")))
    `(let (,g-defer-list)
       (macrolet ((defer (&body ,g-body)
                    `(push (lambda () ,@,g-body)
                           ,',g-defer-list)))
         (unwind-protect
              (progn ,@body)
           (dolist (func ,g-defer-list)
             (funcall func)))))))

with-defer の中でのみ動作するローカルマクロ defer は、渡された g-body 部で無名関数を組み上げ、リスト g-defer-list に追加します。このリストに詰め込まれた無名関数は unwind-protect を抜けるとき(いわゆる try-finally の fianlly 部)に、リストに入れたのとは逆の順序で取り出されて実行されます。

という感じで、容易に動作を模倣することができました。

ちなみに、複数行に渡る動作を defer に渡す場合、Go言語では次のように明示的に無名関数で包んであげる必要があります。

   defer func() {
        fmt.Println("1")
        fmt.Println("2")
    }()

一方で、上で実装した defer は暗黙的に無名関数で包んでいるため、複数行に渡る動作も単に並べるだけで良いです。

(defer
  (print 1)
  (print 2))

deferの直接呼び出し形式における引数の先行評価

Go言語の場合

実は、上記で実装した defer では1つ模倣できていないことがあります。それは、直接呼び出し形式における引数の先行評価です...というと分かりにくいですが、例を見てみます( https://play.golang.org/p/HwOBMnxEe33 )。

func test() {
    for i := 0; i < 3; i++ {
        defer fmt.Printf("direct:  %d\n", i) // 直接呼び出す
    }
    for i := 0; i < 3; i++ {
        defer func() {
            fmt.Printf("closure: %d\n", i)   // クロージャに包んで呼び出す
        }()
    }
}

この test 関数を実行すると次のような結果になります*2。見ての通り、クロージャに包んだ方は、ループが回りきった後の i の値である3を出力し、直接呼び出した方は defer が評価された時点の i の値を出力します。

closure: 3
closure: 3
closure: 3
direct:  2
direct:  1
direct:  0

これは、直接呼び出しの場合に限り、引数をその場で評価することから来る動作のようです。次のようにすると、その動作がよりはっきりします( https://play.golang.org/p/z_m-Kr-pxsM )。

func f(s string) string {
    fmt.Println(s)
    return "inner"
}

func test() {
    defer f(f("outer"))
    fmt.Println("normal")
}

この test 関数を実行すると次のような結果になります。defer に渡した f(f("outer")) の「引数」である f("outer")defer を通過した時点で評価されます。このため、"normal" よりも先に "outer" の出力が来ることになります。

outer
normal
inner

引数の先行評価を模倣する: with-defer2

先程作成した with-defer マクロ内の defer は単にクロージャで包んでいるだけであるため、上で見たような先行評価を模倣することができていません。

(with-defer
  (dotimes (i 3)
    (defer (print i))))

上記を実行すると、ループ後の i の値3が取り出されています。

3
3
3

さて、引数の先行評価を模倣するにあたり、まずは実現方式を考えてみます。現在の with-defer を手で展開すると次のようになります。

(let (lst)
  (unwind-protect
       (dotimes (i 3)
         (push (lambda ()
                 (print i)) ; ← この i は...
               lst))
    (dolist (fn lst)
      (funcall fn))))       ; ← この funcall 時点まで評価されない

次のようにすることで、defer = push 時点で i を評価させることができます。

(let (lst)
  (unwind-protect
       (dotimes (i 3)
         (push (let ((x i)) ; ← この時点で i が評価される
                 (lambda ()
                    (print x)))
               lst))
    (dolist (fn lst)
      (funcall fn)))) ; ← x の評価はここだが、評価済みの i が入っている

上記の出力は目的通りの形になります。

2
1
0

後はこれをマクロとして実装するだけです。その前の準備として、ネストしたバッククォートを扱い続けるのはしんどいので、補助関数を切り出します。先程の with-defer と等価な実装は下記の通りです。

(defun defer% (defer-list body)
  `(push (lambda () ,@body)
         ,defer-list))

(defmacro with-defer (&body body)
  (let ((g-defer-list (gensym "DEFER-LIST"))
        (g-body (gensym "BODY")))
    `(let (,g-defer-list)
       (macrolet ((defer (&body ,g-body)
                    (defer% ',g-defer-list ,g-body))) ; ← ここを切り出した
         (unwind-protect
              (progn ,@body)
           (dolist (func ,g-defer-list)
             (funcall func)))))))

できあがったものがこちらです。行数としては30行程度ですが、だいぶしんどい実装になっています。しんどい主な原因はGo言語の defer における「直接呼び出しで書けるのは関数呼び出しに限る」という制約がないためです。with-defer の場合、アトム, 関数, マクロ, スペシャルフォームのいずれも許容していますが、このうち単純に引数を評価してよいのは関数だけです。この辺りの振り分けをしているのが true-function-p です(たぶんバグがあります)。

(defun true-function-p (head env)
  (if (listp head)
      (functionp head)    ; ← (lambda (x) x) 形式を捕捉するため
      (and (fboundp head)
           (not (special-operator-p head))
           (not (macro-function head env)))))

(defun defer2% (defer-list body env)
  `(push ,(if (and (= (length body) 1) ; ← body部が1つのときだけ先行評価をする
                   (listp (car body))
                   (true-function-p (caar body) env))
              ;; ↓の部分は (fn x y z) を次のように展開する操作
              ;; (let ((a0 x)
              ;;       (a1 y)
              ;;       (a2 z))
              ;;   (fn a0 a1 a2))
              (let ((args (loop :for i :from 0 :below (length (cdar body))
                             :collect (intern (format nil "A~D" i)))))
                `(let ,(loop :for arg :in args
                             :for exp :in (cdar body)
                          :collect (list arg exp))
                   (lambda () (,(caar body) ,@args))))
              ;; ↓(いわゆる)else部は元の defer% と同じ
              `(lambda () ,@body))
         ,defer-list))

(defmacro with-defer2 (&body body &environment env)
  (let ((g-defer-list (gensym "DEFER-LIST"))
        (g-body (gensym "BODY")))
    `(let (,g-defer-list)
       (macrolet ((defer (&body ,g-body)
                    ;; ↓ここで defer2% を呼ぶことと、
                    ;;   引数に &environment を取っている以外は with-defer と同じ
                    (defer2% ',g-defer-list ,g-body ,env)))
         (unwind-protect
              (progn ,@body)
           (dolist (func ,g-defer-list)
             (funcall func)))))))

ということで、下記のように呼び出してみると...

(with-defer2
  (dotimes (i 3)
    (defer (print i))))

所望の通り、先行評価できていることが分かります。

2
1
0

しかし、せっかく関数以外も受け入れられたり、複数行(= body部が2つ以上)を受け入れられたりするのに、次の例に見るように色々な制約があって今いちしっくりきません。

;; これは先行評価にならない(スペシャルフォーム let で包んだので)
(with-defer2
  (dotimes (i 3)
    (defer (let ()
             (print i)))))

;; これも先行評価にならない(body部が2つ以上あるので)
(with-defer2
  (dotimes (i 3)
    (defer (print i) (print i))))

関数以外の場合に、必要な部分だけ正しく先行評価をするのは結構大変です。複数行に渡って先行評価を行うようにすることはまだ容易ですが、関数以外への対応をしないまま導入しても、式によって先行評価されたりされなかったりとなって余り嬉しくはなさそうです。

別の道を探ってみる: with-defer!

さて、前節で実装した with-defer2 ですが、動作はGo言語を模倣しているものの今いちしっくりこない結果となってしまいました。

ここで、マクロ側に自動で先行評価の有無を判断させるという方向性を捨てて、マクロの利用者に先行評価をコントロールさせる道を考えてみます。Common Lisp は多大な自由を与える代わりにプログラマにその制御の責を負わせる傾向が強い言語であるため、こうした方向の方が馴染みそうです。

ということで、次のようにプレフィックス c! をつけたシンボルは先行評価された元の(=プレフィックスなしの)シンボルの値が入るような with-defer! マクロを書くことにします。

(with-defer!
  (dotimes (i 3)
    (defer (format t "~D:~D~%"
                   c!i   ; ← 先行評価した i の値が入る
                   i)))) ; ← 先行評価しない

実行結果は次のようになります。

2:3
1:3
0:3

実装は次のようになります。行数的には with-defer2 より若干長いですが、関数の判定といった辛い作業がないため、かなり気楽な実装になっています。なお、読んだことのある方は分かると思いますが、Let Over Lambdadefmacro! マクロの実装やインタフェースを多いに参考にしています*3

;; 補助関数:ネストしたリストをフラットなリストに変換する
;; Ex. (1 2 (3 4 (5)) 6 7) -> (1 2 3 4 5 6 7)
(defun flatten (x)
  (labels ((rec (x acc)
             (cond ((null x) acc)
                   ((atom x) (cons x acc))
                   (t (rec
                       (car x)
                       (rec (cdr x) acc))))))
    (rec x nil)))

;; 補助関数:シンボル名に "c!" のプレフィックスがあるか判定する
(defun c!-symbol-p (s)
  (and (symbolp s)
       (> (length (symbol-name s)) 2)
       (string= (symbol-name s)
                "C!"
                :start1 0
                :end1 2)))

(defmacro with-defer! (&body body)
  (let ((g-defer-list (gensym "DEFER-LIST"))
        (g-body (gensym "BODY")))
    `(let (,g-defer-list)
       (macrolet ((defer (&body ,g-body)
                    (let (; ↓ "c!" プレフィックスつきのシンボルを抽出する
                          (syms (remove-duplicates
                                 (remove-if-not #'c!-symbol-p
                                                (flatten ,g-body)))))
                      ;; ↓ c!a, c!b, c!c を見つけたとすると、次のように変換される
                      ;;   (let ((c!a a)
                      ;;         (c!b b)
                      ;;         (c!c c))
                      ;;     ...)
                      `(push (let ,(mapcar (lambda (s)
                                             `(,s ,(intern (subseq
                                                            (symbol-name s)
                                                            2))))
                                           syms)
                               (lambda () ,@,g-body))
                             ,',g-defer-list))))
         (unwind-protect
              (progn ,@body)
           (dolist (func ,g-defer-list)
             (funcall func)))))))

この方法であれば、defer の中に式が複数あるとダメとか let のようなスペシャルフォームで包むとダメといった制約はありません。

(with-defer!
  (dotimes (i 3)
    (defer (print c!i)
           (let ((x 100))
             (print (+ c!i x))))))
;; ↓実行結果
2
102
1
101
0
100

こうして様々な「構文」をいじって遊べるのは Lisp の面白いところですね。


*1:例ではやっていませんがネストさせることもできます

*2:ちなみに、クロージャの方は "loop variable i captured by func literal" と go vet に怒られます

*3:実装が似ているだけで動作的には無関係なので、defmacro! の詳細は略します