Scheme:マクロ:CommonLispとの比較

Scheme:マクロ:CommonLispとの比較

関連: Scheme:マクロ:anaphoric ifの代替, Scheme:マクロの効用, Scheme:マクロの危険

2007/05/15 00:08:13 PDT追記: 黒田さんの再反論と、それに対するコメント:Scheme:マクロ:CommonLispとの比較:意味論

安全なマクロ

MSIの黒田さんの About Schemeより:

first class symbol がないということは様々な弊害を引き起こしますが、なかでも深刻なのは、名前の衝突に関して無力な点です。
そうすると、実質 macro が書けない…
例えば以下の arithmetic-if は、いかなる名前とも衝突しない uninterned な名前を var に割当てることで、 どういうコンテクストにおいても動作保証のできるマクロ展開結果を与えてくれるわけですが、 Scheme はこういった真似ができません。

  (defmacro arithmetic-if (test neg-form &optional zero-form pos-form)
     (let ((var (gensym)))
       `(let ((,var ,test))
          (cond ((< ,var 0) ,neg-form)
                ((= ,var 0) ,zero-form)
                (t ,pos-form)))))

「どういうコンテクストにおいても動作保証のできるマクロ」、ここでは安全な マクロと呼ぶことにしますが、何をどうしたら安全を保証できるか、というのは gensymだけの話に留まらず、なかなかに難しい問題です。Schemeのマクロが もう10何年もごちゃごちゃ議論してるのもそのためです。

何が難しいのか、ひとつづつ見ていきましょう。

束縛変数の衝突

まず、上の文章が問題にしている、マクロが導入する一時変数varの衝突に 関してですが、これはSchemeでもR5RSで正式に回避できるようになりました (R4RSではマクロがappendixだったので)。上のarithmetic-ifと同じマクロは 次のように書けます。

(define-syntax arithmetic-if 
  (syntax-rules ()
    ((arithmetic-if test neg-form zero-form pos-form)
     (let ((var test))
       (cond ((< var 0) neg-form)
             ((= var 0) zero-form)
             (else      pos-form))))
    ((arithmetic-if test neg-form zero-form)
     (arithmetic-if test neg-form zero-form #f))
    ((arithmetic-if test neg-form)
     (arithmetic-if test neg-form #f #f))))

後半4行はzero-formとpos-formをオプショナルにするためのもので、このへんは defmacroの方が簡潔に書けますね。で、マクロ展開の本体では変数varを マクロ展開結果に挿入してますが、特にgensymしてません。Common Lispマクロに 慣れた人なら、あれ? と思うんじゃないでしょうか。

でもご心配なく。neg-form等にvarを含む式を渡しても、変数名が衝突することはありません。

(let ((var 3))
  (arithmetic-if -1 (list var))) => (3)

Common Lisp的な感覚で、上のマクロ定義に沿ってそのままarithmetic-ifを 置き換えるとこんなふうにvarが衝突しちゃってるわけですが:

(let ((var 3))
  (let ((var -1))
    (cond ((< var 0) (list var))
          ((= var 0) #f)
          (else #f))))

R5RSマクロは「letが変数束縛をする」ということを知っており、マクロが 挿入する束縛変数とマクロの「外から来た」変数とを区別するのです。 上のフォームは、「実質的に」次のフォームであるかのように実行されます。

(let ((var 3))
  (let ((var_1 -1))
    (cond ((< var_1 0) (list var))
          ((= var_1 0) #f)
          (else #f))))

従って、Schemeマクロにgensymは必要ありません。

自由変数の衝突

しかし、マクロの安全性は「展開結果内での束縛変数」だけではなく、 「展開結果内での自由変数」についても検討する必要があります。 arithmetic-ifの例では、マクロの展開結果にはlet, cond, <, =, else と言った束縛されていないシンボルが含まれています。もしマクロの使用環境で それらが束縛されていたらどうなるでしょうか。

R5RSマクロでは、マクロの定義環境での意味がそのまま保持されます。 従ってマクロの使用環境で次のように '<' が束縛されていたとしても、 マクロの展開結果に含まれる '<' の意味には影響を与えません。

(let ((< (lambda args #f)))
  (arithmetic-if -1 'neg 'zero 'pos)) => neg

Common Lispマクロの場合、マクロが挿入するシンボル '<' は マクロの使用環境に影響を受けます。例えばCMUCLでは次のコードは 何の警告も出さずにPOSを返します。

(labels ((< (&rest args) (declare (ignore args)) nil)) 
  (arithmetic-if -1 'neg 'zero 'pos)) => POS

従って、「どういうコンテキストでも動作保証」と言う意味を厳密に 取るなら、CL版のarithmetic-ifは安全ではないということになります。

もっとも実際の現場でこれが問題になることは滅多にありません。 こんなふうに運用で回避してるからです:

ここでパッケージが出てくるのがポイントです。Common Lispの色々な仕様 というのは実にうまいぐあいに他の部分の仕様と絡み合って効果を発揮するように なっていて、マクロとパッケージシステムもその例のひとつです。 (なので、Common Lispの特定の機能だけをコピーしようとしても 元の機能になかなか及びません。多くのSchemeにはCLのdefmacro相当の 機能がありますが、CLerにとってそれがまがいものにしか見られないのは そういう理由があります。)

なお、非標準の関数を挿入するケースですが、Schemeの場合、R6RSでは マクロとモジュールシステムが相互作用します。マクロが挿入する自由変数は マクロが定義されたモジュールでの束縛を保持し、マクロが使われるモジュールでの 同名の変数には影響を受けません。 モジュールの構文はまだ正式決定していませんが、R5.91RSの構文で例を書けば こんな感じになります。

(library (macro-def)
  (export foo)
  
  (define (foo-helper x y) (+ x y))

  (define-syntax foo
    (syntax-rules ()
      ((foo a b) (foo-helper a b)))))

(library (macro-use)
  (import (macro-def))

  (define (foo-helper x y) (* x y))

  (display (foo 3 5)))  ;; => 8

fooの展開結果にはfoo-helperが含まれますが、それはモジュールmacro-defの foo-helperを参照していて、macro-useのfoo-helperには影響を受けません。

他の機能との絡み合いのもう一つの例は、「マクロ展開に補助手続きを使う場合は 展開時までにその手続きが定義されてないとならない」という問題です。 Common Lispではeval-whenとdefsystemを使うことで動作を保証します。 Schemeの場合、R5RSマクロの範囲内ではマクロ展開時に通常のScheme手続きが 呼べないのでこの問題は表面化しませんが、syntax-caseのようにマクロ展開を 手続的に行う場合に問題となります。low-levelマクロがSchemeになかなか 入らなかった理由のひとつはこれでした。R6RSではphaseという概念を 導入して解決を試みようとしています。

Common Lispのこのような設計方針については、Eli Barzilayが c.l.lの投稿でうまくまとめています:

There are other important parts in the CL-style macro system that are required to avoid unreliable macros. One is the need to load files in a particular order. Another is the requirement to not modify any builtins. Also there's the general trust in the programmer thing -- it's easy to shoot your feet, but you're expected to avoid doing that. (Personally, I don't like shooting anybody's feets including mine, but every once in a while I do like to play around with my feet and see what happens if I turn them to extra hands or if I rewire them to my head -- things that are just not possible in CL.)

S式≠プログラム?

Schemeのマクロは変数衝突を「自動的に回避する」と書きましたが、一体どうやって 回避してるんでしょうか。

ローカル束縛(arithmetic-ifにおける変数var)の衝突の回避については、 マクロ展開ルーチンがマクロが挿入する変数varを衝突しないようにリネーム してやることで回避できます。gensymをマクロシステムが自動的にやってくれると 言っても良いでしょう。

しかし、自由変数についてはマクロ展開ルーチンだけで解決はできません。 衝突を避けるためにリネームすると、本来参照したかった変数も参照 出来なくなってしまうからです。S式の範囲内で解決したければ、 モジュール全てを対象とするグローバルな変換をかけてやる必要があります。 …S式の範囲内で解決したければ、ですが。

実際には、Gaucheのモジュールシステムを含め、健全なマクロルーチンは プログラムの字面の「S式そのもの」だけではなく、それが現れた「環境」も 参照しながら展開を行い、展開結果も単なるS式ではなくS式と環境を あわせたものになります。概念的に言えば、マクロ展開ルーチンの 「型が違う」ということになります。

Lispのmacro expander:   Sexp -> Sexp
Schemeのmacro expander: Sexp, Environment -> Sexp, Environment

ところで、「式と環境をくっつけたもの」と聞いて何か思い浮かびませんか。 そう、クロージャですね。クロージャは式と実行時環境を一緒にしたものでした。 Schemeのマクロは、その思想をマクロの領域まで広げたものとみなせます。

マクロが扱う「環境」はクロージャが実行時に綴じ込む環境と似ていますが 同じものではありません。マクロ展開はプログラムの実行前に行われるので、 ここで言う「環境」には実行時の束縛は含まれていないのです。 ただ、実行時の値はわからなくても「この変数とこの変数はローカルに定義されてて、 このシンボルはこのモジュールからインポートしたグローバルな構文キーワードと して作用していて…」といったことはわかっているわけで、そういう情報がここでの 「環境」に含まれます。

「LispではS式によってプログラムとデータを同様に扱うことができる」と良く 言われます。実際、最初のLisp処理系では、関数オブジェクト (lambda式) は クオートされたS式そのものでした。しかしそれでは高階関数を扱うのに 不便なことがわかり (funarg問題)、明示的に実行時環境とlambda式を 一緒にするfunctionフォームが発明されました。 Schemeはそれを一歩進めて、明示的なfunctionフォーム無しでも lambda式が自動的にクロージャになることにすれば、シンプルかつ 効率の良い処理系が作れることを示したわけです。

funarg問題がプログラムの実行時の環境問題であるとすれば、 安全なマクロの問題は、プログラムを扱うプログラムにとっての環境問題です。 Common Lispが、プログラム=S式、という枠を崩さない範囲でこの問題に 取り組んだ結果が、gensymであり、パッケージシステムであり、eval-when であったわけです。たぶん、プログラム=S式、という枠の中では、これ以上 現実的な解は無いんじゃないかと思います。

一方Schemeは、レキシカルクロージャを導入したのと同じ発想を、 一段メタな領域に適用しようとしています。そこではもはやプログラムはS式そのもの ではありません。一度読み込まれて構文解析されたプログラムは 「S式+環境」となり、マクロは「S式+環境」に対して作用します。

[image]

Schemeのマクロに関する議論でしばしば"syntactic closure"という言葉が 出てきますが、それがこの「S式+環境」を指しています。

なお、実際にこのマクロ展開時環境が影響を与えるのはシンボルの束縛解決 のみなので、S式+環境はこんな感じで変換することができます:

  E{symbol }  => identifier
  E{(quote x)} => (quote x)
  E{(x y ...)} => (E{x} E{y} ...)

(E{S式} が 「S式」に環境をくっつける、という意味を示しています。quasiquote とかは省略)

つまり末端のシンボルがidentifierに化けるのと、クオートされたリテラルに 環境が影響を与えないことを除いて、元のS式の構造はキープされるってことですね。

試しにGaucheで、上のarithmetic-ifの例をmacroexpandしてみます。 ローカルな束縛環境を考慮してマクロを展開するには構文である%macroexpandを 使う必要があります(見やすいように結果をインデントしてあります):

gosh> (let ((var 3)) (%macroexpand (arithmetic-if -1 (list var))))
(#<identifier user#let> ((#0=#<identifier user#var> -1))
  (#<identifier user#cond> ((#<identifier user#<> #0# 0) (list var))
                           ((#<identifier user#=> #0# 0) #f) 
                           (#<identifier user#else> #f)))

表示された結果はarithmetic-ifの展開結果で、外側の(let ((var 3) ...) は 含んでいないことに注意してください。マクロが挿入した変数が全てidentifier に置き換えられていることがわかると思います。また、マクロの外側から渡された フォーム (list var) に含まれる var と、マクロ展開で挿入される変数varとが ぶつかっていないこともわかると思います (この場合、一方はidentifierで、 もう一方はsymbol。両方がidentifierだったとしても、 各identifierはマクロ展開のたびに生成されるので、異なるフェーズで 挿入されるidentifierがeq?になることはない)。

Gaucheで普段マクロ展開の結果を見る時には、#<identifier ...>がたくさん 入ってくるのはうるさいので、unwrap-syntaxをかけて見ることが多いです。 unwrap-syntaxは上記Eの逆変換で、identifierをsymbolにしてくれますが、 情報が失われるので、varが衝突しないことなどはわからなくなってしまうことに 注意が必要です。

gosh> (unwrap-syntax (let ((var 3)) (%macroexpand (arithmetic-if -1 (list var)))))
(let ((var -1)) (cond ((< var 0) (list var)) ((= var 0) #f) (else #f)))

マクロ展開ルーチンの入力で必ず変換Eを末端までかけてしまえば、Schemeのマクロは (シンボルがidentifierになることを除いて) Lispのマクロと同じようにS式の 領域での操作に帰着させることができます。ただ、それをやるとプログラムコード全体が ほぼコピーされることになるので、マクロ展開のたびにやるのは非常に高価です。 通常、マクロ展開時に隅から隅までソースを見る必要はなくて、 上位の構造だけをいじることが多いので、変換Eを常に最後まで実行するのは 効率が悪いことになります。

syntax-caseなどの低レベルマクロルーチンは環境を暗黙のうちに持ち運んで、 パターンマッチに必要な部分だけ変換Eをかける、という方法でこの問題を 回避しています。

(もうひとつ、「S式+環境」を展開しないで持っておくと、「環境」の部分に 色々なメタ情報、例えばソースコードへの参照などをいれておくことができる、 というメリットもあります)

ここでぐるっと回って最初の引用に戻ります。SchemeにはCommon Lispのような リッチなオブジェクトとしてのsymbolはありません。それでもマクロがちゃんと 書けるのは、マクロが symbol + 環境であるidentifierを扱っているからです。 ちょうど、実行時にlambda式が単なるS式ではなくクロージャとして扱われるのと 同じように。

ただ、レキシカルクロージャに関してはCommon Lispもそれを取り入れたり、 他の言語も普通に採用するなりして成功したと言えますが、マクロ領域での syntactic closureに関しては果してこれが正しい方向なのかどうか、 まだはっきりとはわからないですね。実績の面ではCLのマクロの方が 遥かに先行してるわけですし。

議論、コメント

(let ((var 3))
  (let ((var_1 -1))
    (cond ((< var_1 0) (list var_1))
          ((= var_1 0) #f)
          (else #f))))

の3行目は

    (cond ((< var_1 0) (list var))

ではないでしょうか。


あまり本筋に関係ないところなのですが、HyperSpecを眺めていて気付いたので、自由変数の衝突の箇所についてコメントさせて下さい。

自由変数の衝突

「従って、「どういうコンテキストでも動作保証」と言う意味を厳密に 取るなら、CL版のarithmetic-ifは安全ではないということになります。」

なのですが、ANSI Common Lisp(HyperSpec)によると、COMMON-LISPパッケージのシンボルの再束縛について

Except where explicitly allowed, the consequences are undefined if any of the following actions are performed on an external symbol of the COMMON-LISP package:

...

2. Defining, undefining, or binding it as a function. (Some exceptions are noted below.)

とのことなので、上記の CL:< の関数の flet での再束縛の結果は未定義ということが定められているようです。(例外として関数ではなく値を束縛するのは合法)
このことから、

標準関数を(ローカル環境であっても)再束縛しない、ということがルールになっている

というのは、実際の現場の運用でもって仕様の脆弱性を回避している、のではなく、仕様上の未定義動作を回避している、ことになると思います。
(本文にもあるように、最近の処理系では、この場合大概パッケージロックの警告がでます。)

また、逆に未定義なので、

(flet ((open (filename &key direction) 
         (format t "~%OPEN was called.")  
         (open filename :direction direction))) 
  (with-open-file (x "frob" :direction ':output)  
    (format t "~%Was OPEN called?")))

のような場合、 flet で定義した open を with-open-file が使わないような処理系があっても良いような示唆があったりもするようです。(私が知っている処理系でそういう実装はないですが…)

個人的には、衛生マクロ的動作であっても良いというのはなかなか面白いなと思います。


Last modified : 2015/04/10 01:04:58 UTC