プログラミング言語 Scheme の仕様はときおり改定がされていますが、その中で R6RS と呼ばれる仕様ではレベルという概念が導入されました。 レベルについて解説を試みます。 この記事中でいう Scheme は R6RS に基づきますので R5RS 以前、または R7RS 以後については必ずしも記述通りではないことに留意してください。
また、後述のように R6RS を標榜している処理系においても仕様より寛容である場合もありますのでこの記事の中でエラーの例として書いているものが許容されるかもしれません。
環境が分離される
レベルが必要なのはマクロ展開の都合ですが、その前提として環境の分離があります。 実行時の環境とマクロ展開時の環境が違います。 環境というのを簡単に説明すると、そこから見える名前です。
さて、 Scheme 以外の LISP 系言語、たとえば Common Lisp で
(defun my-list (&rest args)
(apply #'list args))
(defmacro nil! (x)
(my-list 'defvar x 'nil))
(nil! x)
(print x)
というコードを書くと、それは問題なく動作します。
では愚直に Scheme に置き換えてこう書くとどうなるでしょうか。
#!r6rs
;; エラーになるプログラムです
(import (rnrs))
(define my-list list)
(define-syntax define-null-variable
(lambda(ctx)
(syntax-case ctx ()
((k x)
(my-list #'define #'x #''())))))
(define-null-variable x)
(display x)
my-list
という名前が見つからないというエラーになります。 Scheme では式の評価を始める前に全てのマクロ展開を終えるという規則になっているからです。 つまり、ここで define-null-variable
というマクロ展開をする時点ではまだ my-list
という変数 (手続き) は定義されていないのです。
逆にマクロ変換子の中で手続きを定義してもそれは実行時には見えません。
実行時の環境とマクロ展開時の環境を分けない Common Lisp では上から順番に定義されていくので問題になりませんでしたが、 Scheme では違う規則があるわけです。
ライブラリで定義する
実行時に定義される手続きはマクロ展開時に使えませんが、自分が定義した手続きをマクロ展開時に使う方法はあります。 レベル 1 の手続きとして import
するという方法です。 レベルは import
するときに指定するので、マクロ展開時に使いたい手続きは別のライブラリに分ける必要があります。
#!r6rs
(library (foo)
(export foo)
(import (rnrs))
(define foo list))
#!r6rs
(import (rnrs)
(for (foo) (meta 1)))
(define-syntax define-null-variable
(lambda(ctx)
(syntax-case ctx ()
((k x)
(foo #'define #'x #''())))))
(define-null-variable x)
(display x)
このとき (meta 1)
というのがレベルの指定です。 foo
というライブラリから導入した名前はマクロ展開時 (フェーズ1) に使うという指定です。 (meta 1)
と書くかわりに expand
と書いてもかまいません。 マクロ展開時に使うと指定した名前は実行時 (フェーズ0) には使えません。
レベルを指定していない場合には (meta 0)
と指定したものと見做され実行時にしか使えません。 (標準ライブラリのほとんどの名前については特殊な規則があるのですがそれは後述します。)
レベル 2 以上
(meta 0)
が実行時 (フェーズ0) で (meta 1)
がマクロ展開時 (フェーズ1) を意味するわけですが、 (meta 2)
以上を指定する必要がある場合もあります。
#!r6rs
(library (bar)
(export bar)
(import (rnrs))
(define bar values))
#!r6rs
(import (for (rnrs) run expand)
(for (bar) (meta 2)))
(define-syntax define-null-variable
(lambda(ctx)
(syntax-case ctx ()
((k x)
(let-syntax ((baz
(lambda(ctx2)
(syntax-case ctx2 ()
((k y ...)
(bar #'(list y ...)))))))
(baz #'define #'x #''()))))))
(define-null-variable x)
(display x)
先ほど述べた式の評価を始める前に全てのマクロ展開を終えるということを思い出してください。 マクロ変換子の中で評価する式においても、その式を評価する前に全てのマクロ展開を終えていなければなりません。 それがレベル 2 です。 もちろん、そのマクロ変換子の中にマクロがあればレベル 3 、更にそこにマクロがあればレベル 4 といくらでも大きいレベルが有り得ます。
インポートレベル・エクスポートレベル
以上の説明を真剣に読んだ人は、例として示したコードがおかしいと気づいたかもしれません。 レベル 1 のはずの箇所で使われている構文がレベル 0 で、レベル 2 のはずがレベル 1 で import
されている箇所があります。 しかし、それは間違いではありません。
ここまで import
構文の for
での指定のことをレベルと呼んできましたが、実際にはインポートレベルです。 これはエクスポートレベルを加算して実際のレベルとなります。 ユーザがライブラリから export
した名前はエクスポートレベル 0 ですが、標準ライブラリ (rnrs)
がエクスポートしている名前はエクスポートレベル 0 とエクスポートレベル 1 の両方でエクスポートされています。 なので、これらについてはインポートレベルの指定が一段階ひくくてもかまわないのです。 レベル 2 以上が必要になることはほとんどないので、 (rnrs)
に含まれる名前を利用する分には for
でインポートレベルを指定することはあまりないでしょう。
現実の処理系では
このように、マクロ展開フェイズと実行フェイズが完全に分離されているのが Scheme の特徴ですが、実際の処理系はもっと寛容である場合もあります。 寛容さの程度を私なりに分類するとしたら
- レベルの指定に完全に従う
- レベルを指定しなくてもプログラムの内容から自動で計算する (implicit phasing)
- 環境の分離を有耶無耶にする (明確に分けない)
といったようなものがあります。
実行時にマクロ展開が全て終えているのであればマクロ展開時の環境は全て捨ててしまってもよい (実際にはわかりやすいエラー報告のために情報を残しておく場合もあるようですが) わけでコンパイラにとっては有利と言えますが、伝統的な LISP がそうであるように対話的な開発を尊重するのであれば環境を分離しない方が良い面もあり、どちらが良いとは一概に言えません。 もしも使う Scheme 処理系に迷うことがあればこのあたりの厳格さは考慮すべき要素のひとつでしょう。
結び
ここではレベルに焦点をあてて説明しましたが、レベルはマクロとライブラリの仕組みに密接に関連しているのでそれらを充分に理解していないとレベルの説明だけを見てもまるで意味不明かもしれません。 ただ、ライブラリやマクロについての (日本語での) 説明はいくつかあるもののレベルについての説明は極めて少ないようだったので、私なりに解説をしてみた次第です。