健全でないマクロクロージャー問題

ふだんSchemeのプログラムを書いていて、たまに使うのがマクロクロージャー(今名付けた)。このテクニックをどうやって各種Scheme処理系で実現するかというのが現在の難問。
(Syntax-caseでは問題なく実現できるが、他の不健全/健全マクロシステムではできたりできなかったりする。)

マクロの合成問題とCPSマクロ

(この問題にはOleg氏のずっと良い解説が有る : http://okmij.org/ftp/Scheme/macros.html#Macro-CPS-programming 以下は個人的によく使うケースに絞っている)
まず、マクロの合成問題を理解しないといけない。マクロは関数のように扱うことができない。これはマクロのAPIデザインを著しく制約する。
マクロ plusOne を考える。このマクロは、与えられた整数に1加えた値に評価されるものとする。(もちろんこれは関数で良いが、バイトコードコンパイラがインライン化もできないほどシンプルな状態なら役に立つかもしれない)

(import (rnrs))

(define-syntax plusOne
  (lambda (x)
    (syntax-case x ()
      ((_ num)
       (unless (number? #'num)
         (syntax-violation #f "argument is not a number!" #'num))
       (datum->syntax #'bogus
                      (+ 1 (syntax->datum #'num))) ))))

これは一つの引数ならば期待通りに動作する。

(write (plusOne 1)) ;; => "2"

しかし、複数のplusOneを合成することはできない。

(write (plusOne (plusOne 1)))
Launching script debugger...
 Syntax error
      who : plusOne
  message : argument is not a number!
     form : (plusOne 1) (one.sps:12)

これは、Schemeのマクロは展開器が出会った瞬間に展開されるという制約による。plusOneが手続きであれば、(write (plusOne (plusOne 1)) は完全に合法であるのに比べると、大きな制約と言える。
これを(部分的に)解決するためには、CPSマクロがよく使われる。継続渡しでマクロを呼び出すことを一種のプロトコルとし、そのプロトコルに従うマクロであれば自由に合成が可能となる。

(import (rnrs))

(define-syntax PlusOne ;; Opcode
  (lambda (x)
    (syntax-case x ()
      ((_ k num rest ...)
       (unless (number? #'num)
         (syntax-violation #f "argument is not a number!" #'num))
       #`(k #,(datum->syntax #'bogus
                             (+ 1 (syntax->datum #'num)))
            rest ...)))))

(define-syntax PlusTwo ;; Opcode
  (lambda (x)
    (syntax-case x ()
      ((_ k num rest ...)
       (unless (number? #'num)
         (syntax-violation #f "argument is not a number!" #'num))
       #`(k #,(datum->syntax #'bogus
                             (+ 2 (syntax->datum #'num)))
            rest ...)))))

(define-syntax Calc ;; EntryPoint
  (syntax-rules ()
    ;; Finish
    ((_ arg) arg)
    ;; Step
    ((_ arg Opcode0 Opcode1 ...)
     (Opcode0 Calc arg Opcode1 ...))))

(write (Calc 1 PlusOne PlusOne PlusTwo)) ;; => "5"

CPSマクロ PlusOne と PlusTwo は、arg に演算を行い、継続 k にわたす。ここでの例では、継続は常に Calc マクロとなる。マクロを受けとり、マクロを呼び出すマクロを定義することがマクロのCPS化のキモと言える。

マクロクロージャ

先の例で、PlusOneとPlusTwoはよく似ているのでマクロで纏めたいのが人情と言える。

(import (rnrs))

(define-syntax defPlusN
  (syntax-rules ()
    ((_ name N)
     (define-syntax name
       (lambda (x)
         (syntax-case x ()
           ((_ k num . rest)
            (unless (number? #'num)
              (syntax-violation #f "argument is not a number!" #'num))
            #`(k #,(datum->syntax #'bogus
                                  (+ N (syntax->datum #'num)))
                 . rest))))))))

;; ここから下ではR5RSの範囲の構文しか使っていないことに注目

(defPlusN PlusOne 1)
(defPlusN PlusTwo 2)

(define-syntax Calc ;; EntryPoint
  (syntax-rules ()
    ;; Finish
    ((_ arg) arg)
    ;; Step
    ((_ arg Opcode0 Opcode1 ...)
     (Opcode0 Calc arg Opcode1 ...))))

(write (Calc 1 PlusOne PlusOne PlusTwo))

(マクロを定義するマクロでは ... を使わないのが常套。このため、"rest ..."は". rest"に書き換えられている。R7RSではSRFI-46になったため、...を簡単に置き換えることができ、このようなテクニックは不要になった。)
ここで、defPlusNは不健全なマクロを定義するにも関わらずR5RS構文で呼び出すことができる。この特徴はyuniのように複数のScheme処理系のマクロシステムをwrapする必要のあるシチュエーションで役に立つ。
このPlusOneやPlusTwoは、直接1や2を書かなくてもプログラムに対して1や2の作用を起こすことができる。原理的には、この方法でマクロで導入するマクロに対して環境を保持させることができるため、マクロをクロージャのように利用できる。これをCPSマクロと同時に利用することで、(モジュールシステムの健全性を保ったまま)かなり表現力の高いマクロを実装することが可能になる。
例えば、CalcとdefPlusNを使用して、与えた数の倍を返すマクロを定義するマクロdefDoubleを以下のように定義できる。

(define-syntax defDouble
  (syntax-rules ()
    ((_ name num)
     (begin
       (defPlusN doubler num)
       (define-syntax name
         (syntax-rules ()
           ((_) (Calc num doubler))))))))

(defDouble Ten 5)
(defDouble Twelve 6)

(write (Ten))(newline)
(write (Twelve))(newline)

マクロクロージャによる抽象化の限界

マクロクロージャを使用すると、low-levelマクロとR5RSのsyntax-rulesマクロを良くブリッジできる。ただし、この方式の抽象化は以前書いた( http://d.hatena.ne.jp/mjt/20140914/p1 )ようにいくつかの処理系では使えない。
例えばGaucheには健全なLow-levelマクロが今のところ無いため、low-levelマクロをこの方法で抽象化してR5RSに持ち込むことはできない。

(define-macro (defPlusN name N)
  `(define-macro (,name k num . rest)
     (let ((ex (+ ,N num)))
      (cons k (cons ex rest)))))

のように書いたときに、define-macroを初めとした構文がそれを利用するライブラリにimportされていないと単純にコンパイルに失敗してしまう。この問題の解は思いついていないので、素直にer-macroの実装を待つしかない。