"Generic Scheme"仕様を考える

yuniのBiwaScheme対応を進める上で用意した"Generic Scheme"仕様が意外と便利な気がしてきたので一般化してSRFI-96( https://srfi.schemers.org/srfi-96/srfi-96.html )のようなrequirement仕様にできないか考えてみる。
"Generic Scheme"はexpanderが簡略化されるぶんR7RS Smallよりも更に小さなScheme仕様になるので、syntax-rulesのサブセット実装をyuniに丸投げすることで簡単に市場の他のScheme処理系と共通のライブラリを使える環境を目指したい。

BiwaSchemeの制約

BiwaScheme( https://www.biwascheme.org/ )はJavaScript上に実装されたSchemeインタプリタで、R6RSの多くの手続きを実装している。(Bytevector等のバイナリ手続きは無いのでyuniでは専用のforkであるbiwasyuni https://github.com/yuniscm/biwasyuni/blob/0e5339ecbce44b148137070bfc7acf285da958b4/biwasyuni_core.js を使用している)
yuniは基本的にR6RS/R7RS処理系専用だが、GambitのようにAlexpanderを使って無理矢理サポートしている処理系もいくつかある。BiwaSchemeでもAlexpanderを使うことを考えたが、Alexpanderはちょっと遅いためBiwaSchemeの仕様に合わせて適度に規模縮小を図って実装することにした。
重要な制約には以下がある:

  • syntax-rulesなどの健全マクロは無く、define-macroのみ備える。yuniのGambitサポートはAlexpanderを使って完全にexpandしてから処理系に渡すアプローチを取っているが、これはexpandのコストが掛かるため避けたい。このため、syntax-rulesを適当に制約を付けてdefine-macroで実現する( http://d.hatena.ne.jp/mjt/20180521/p1 )方向を取った。
  • define-macroをトップレベルにしか書けない。Gambit等のdefine-macro処理系は通常defineとの混ぜ書きが可能で、let等によってスコープすることができる。BiwaSchemeではmacroはスコープすることができない。

マクロがスコープできないということは、let-syntaxが実装できないという点が問題になり得る。しかし、yuniでlet-syntaxを使っているのはidentifierと ... (ellipsis)の検出に使っているOlegのテクニック https://github.com/okuoku/yuni/blob/dcffed4556cdbccef46bba0a9c1198a8be2fe527/lib/yuni/base/match.sls#L566 くらいなので、ここだけライブラリ化してしまえば事足りる。
もっと直接的な問題は構文をリネームする方法が無い点が有るが、通常のシチュエーションではユースケースは無い、と思う。
これをユーザに見える制約にすると、

  • define-syntaxはライブラリ/プログラムのトップレベルにしか書けない。手元のSchemeアプリだとたまにletの中でdefine-syntaxしているものが有り、そういうコードはトップレベルに移動してやる必要がある。マクロを使ってトップレベル以外で定義したものを挿入できないという制約とも言える。
  • define-syntaxしているライブラリはトップレベルにしかimportできない。逆に言うと、define-syntaxを含まないライブラリは(let () ...)で囲むことでグローバル定義を汚さずにimportできる。Gambitやs7のようなdefine-macroをスコープできる処理系にはこの問題は無い。
  • syntax-rulesは特殊なサブセット仕様となる。これはちょっと複雑。

syntax-rulesサブセット仕様

define-macroを使って、かつ、(letやdefineのような)組込み構文の知識無しでsyntax-rulesを実装する場合、どうしても避けて通れないバインディングの生成問題がある。syntax-rulesで新しいバインディングを導入する場合は、字面上の名前が同じであってもマクロが展開される度に新しいバインディングを生成しなければならない。

  • Generic Schemeでは上手くいかないケース
(import (yuni scheme))

(define-syntax chk
  (syntax-rules ()
    ((_ nam val)
     (begin
       (define tmp val) ;; ★ Generic schemeではそのまま "tmp" をdefineしてしまう
       (define (nam) tmp)))))

(chk a 10) ;; (a) => 10 のはず
(chk b 20) ;; (b) => 20 のはず

(display (list (a) (b))) (newline) ;; ★ (20 20) を出力

ここで定義している構文 chk は、引数に指定した値を返す手続きをdefineするが、テンポラリなシンボルとして使用したtmpがそのままコードに出力されてしまうため、2回目の chk の使用でtmpが上書きされてしまい、Generic Scheme処理系ではうまく動かない。これはsyntax-rulesを適切に実装した処理系ではちゃんと (10 20) を返す。

(define-syntax chk
  (syntax-rules ()
    ((_ nam val)
     (begin
       (define __1 val)
       (define (nam) __1)))))

そこで、Generic Schemeではsyntax-rulesの_ とか ... のようなリテラルに加えて、 __1 〜 __9 を予約して、tmpのような仮置きのシンボルとして使えるようにした。(R7RSやSRFI-46では ... を置き換えることができるが、__1 〜 __9 を置き換える方法は提供しない。) これは内部的にはよくある gensym 手続きを呼び出すだけとなっている。
... たぶん、letやlambdaのような標準のbind構文は全て自動的にこれらを置き換える機能を用意してあげた方が良い気はしている。現状だと

(lambda (a) a)

のような記述をsyntax-rules内に書く場合、

(lambda (__1) __1)

のように書き換えてやらないと、マクロ展開の外で識別子 a がbindされていた場合に意図せずマスクしてしまう。

  • 上手くいかない例
(import (yuni scheme))

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

(define a 10)
(out 20 (lambda (b) (display (list a b)) (newline))) ;; ★ やっぱり (20 20) と出力される
  • 手動で書き換えた例
(define-syntax out
  (syntax-rules ()
    ((_ x code)
     ((lambda (__1) (code __1)) x))))

通常の展開器実装では、define-syntaxした位置で出力テンプレートがbindされていなければ適宜リネームという戦略を取ることができるが、今回はdefine-macroを使う縛りなのでsyntax-rulesの側にlambdaとかletといった標準構文の知識が必要になる。

Generic Schemeの意義

こんなサブセットをしてまでdefine-macro処理系を取り込む必要が有るのかはちょっと何とも言えないが、ちゃんとしたライブラリのサポートとsyntax-rulesの実装は意外と面倒で、かつ、どうしても処理系のバリエーションを増やしてしまうので、それをyuniに丸投げして処理系本体をコンパクトにできるなら言うほど悪くないんじゃないかという気がしている。
今のところGeneric Schemeターゲットになり得るのは:

あたりが有る。Gambitにはpsyntaxベースのsyntax-rulesが有るが、Gambit本体のマクロ展開器とよく連携しないので、直接Gambitのマクロ展開器を使うにはGeneric Schemeのようなアプローチが必要になる。
もちろん上にライブラリシステムやexpanderを被せてこれらの処理系をちゃんとしたR7RS処理系にすることも不可能ではないと思うが、処理系本来のdefine-macroとマクロ展開器を活用した、可能な限り薄い互換レイヤというのも方向性としては有りなんじゃないかと思う。すくなくともこの3つとも非常に魅力的な処理系で、syntax-rulesが無いからといって非サポートにするのは非常に惜しい。