define-macro処理系をできるだけHygieneにする

追記: これsyntax-rulesに展開されるsyntax-rulesがダメなんじゃないかという気がする。もっとも、何度リネームしても実害は無い気がするけど。。
前回( http://d.hatena.ne.jp/mjt/20181209/p1 )、define-macro処理系上のsyntax-rulesでシンボルのリネームを実現するために __1 とか __2 のような予約シンボルを導入する方法を考えたが、既存のコードを書き換えるのは超たいへんなのでテンプレートの方を暗黙に書き換えるというインチキで乗り切ることにした。
とりあえず手元のアプリを分析したところ、lambdaとlet、defineさえ救えば十分なことがわかったのでそれらだけ救う対応を入れた。

救われるコード

lambda、let、defineでのbindingは暗黙にリネームされるようになった。つまり、

(import (yuni scheme))

(define-syntax out
  (syntax-rules ()
    ((_ x code)
     ((lambda (a) (code a)) x))))

(define-syntax out2
  (syntax-rules ()
    ((_ x code)
     (let ((a x))
      (code a)))))

(define-syntax out3
  (syntax-rules ()
    ((_ x code)
     (begin
       (define a x)
       (code a)))))

(define a 10) ;; それぞれsyntax-rulesの中ではaに20を代入している
(out3 20 (lambda (b) (display (list a b)) (newline)))
(out 20 (lambda (b) (display (list a b)) (newline)))
(out2 20 (lambda (b) (display (list a b)) (newline)))

のようなコードがs7やBiwaSchemeでも正常に"(10 20)"を出すようになった。これは、例えば上記のdefineであれば、

(define-syntax out3
  (syntax-rules ()
    ((_ x code)
     (begin
       (define __1 x)
       (code __1)))))

のように暗黙に書き換えるのと同じ操作を実装している。

前提

この対応によって救うことができるのはletやdefineといった予約語がsyntax-rulesのテンプレート内に完全な形で出現している必要がある。つまり、

  • letやdefineの定義を置き換えていない。yuniでは構文のリネームは許さない方向で制約しているが、依然letとかlambdaで別のものを束縛するのは合法となっている。今のところ、define-syntaxをトップレベル以外では許していない(= define-syntaxされる文脈は常にtop-levelなので他の構文がbindされようが無い -- (define let 10) とかしない限り)が、今後define-syntaxもスコープできるようにすると問題になるかもしれない。
  • syntax-rulesテンプレート中に完全な形で構文を使用する。syntax-rules内のテンプレートを見てテンプレートを直接書き換える手法は、2段以上のマクロ展開を使った場合に成立しない可能性がある。letやlet*のような構文シンボルをマクロの外部から与えること自体は合法だが、今回のsyntax-rulesではこのようなケースでは内部でletが行われることを知りようが無い。

前者はともかく、後者はついうっかりやってしまいそうな気はする。
もちろん、処理系がdefine-record-typeとかその他bindをする非標準の構文を持っていたら、それぞれの対応を導入する必要がある。

実装

実装は、syntax-rulesのテンプレートをスキャンし、letとかdefineでbindされる位置にあるシンボルが有ったら、そのシンボルをテンプレート変数の1つとして昇格している。Generic runtimeではchibi-schemeのsyntax-rules実装を改造してsyntax-rulesを実装しているので、それをパッチする形で実装した。

  1. letとかdefineに対する、bindされる可能性があるシンボルを抽出する手続きをそれぞれ用意する https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-6fc3666a46e36a52d314bb3257b9b2edR117
  2. expand-pattern に引数を追加し、テンプレート内でbindされる可能性があるシンボルのリスト(potential-binds)を渡せるようにする https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R272
  3. expand-pattern 先頭で、bindされる可能性があるシンボルのリストを map してそれぞれgensymしておく https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R198
  4. テンプレート変数を展開するときに、テンプレート変数のシンボルにマッチしなかったものをpotential-bindsと突き合わせて、マッチした場合はgensymしたシンボルに差し替える https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R217

現状の実装は処理系独自のscan手続きを追加することができない。

意義

ここまでしてsyntax-rules""の構文を使うことに何の意味があるのかというのは微妙な問題だが、これ(define-macroでエミュレートできるようにsyntax-rulesの機能を制約する)が今のところ各種Schemeでマクロを記述する最大公約数なのではないかと思っている。もちろんexpanderを載せて真面目にsyntax-rulesなりsyntax-caseなりを実装する手も有るが。。
完全にテンプレート展開のみに絞って明示的に __1 のようにgensym位置を書かせるのと、今回のように暗黙にリネームを挟むのとどちらが良いのかはなんとも言えない。ただ、個人的には暗黙にリネームを挟む方がUXとしては優れているのではないかと考えている。bindされるシンボルに意味のある名前を付けられるし、処理系が対応していればそれなりに見易いエラーを出力することもできる。
例えば、

(import (yuni scheme))

(define-syntax check
  (syntax-rules ()
    ((_ temp)
     (define (temp in)
       (car in)))))

(check check2) ;; 手続きcheck2を定義
(check2 10) ;; check2にペアでない値を渡す(エラー)

のようなコードをyuniのGeneric runtimeをloadしたs7で実行すると

;car argument, 10, is an integer but should be a pair
;  check.sps[10]
;
; check2: (car {in}-16)                      ; {in}-16: 10
; ((load prog)); ((cdr args*) (%%extract-program-args (cdr...
; ((set! %%selfboot-yuniroot "."))

のように、ちゃんと引数名が表示される。
syntax-rulesじゃなくてdefine-macroの方に寄せないのかよという意見も有るかもしれないけど、fomentのようにsyntax-rulesしかマクロを持たない処理系も有るし、何よりletくらい普通に書かせてくれた方が便利だし。。