Scheme:マクロの危険

Scheme:マクロの危険

Lisp系マクロを言語に取り込むべきかどうかというのは、 しばしば大きな議論を呼ぶ。 (ここでは伝統的なLispのマクロとScheme/Dylanのマクロの違いは問題にはしない)

Lispのマクロを知る者は、少なくともそれが非常に強力な言語機能だという点では 一致する。見解が分かれるのはその先だ。マクロ反対派は、マクロが「強力すぎる」 ために、むしろ言語は制御された力を提供すべきだとする。マクロ推進派は、 力を使いこなすのも濫用するのもプログラマの手に委ねるべきだとする。

参考議論:

この問題に一般的な解はない。誰が何のために言語を使うかによって 答えが変わって来るからだ。

そこで、ここでは見方を変えて、「どんなふうにマクロを使ったらまずいのか」 あるいは「マクロを使いこなすにはどんなふうに使えばいいのか」ということを 整理してみよう。


マクロの使用パターンによる違い

Scheme:マクロの効用で述べたように、マクロが使われる局面には パターンらしきものがいくつか見られる。パターン毎にその局面で マクロを使うべきか、他の代替手段は使えないのか、マクロを使った場合の 利点と欠点等を考えてみよう。

performance hackとしてのマクロ

マクロ展開によって、マクロを呼び出したコンテキストに直接コード片を挿入すれば、 関数呼び出しのオーバヘッドが減る。

簡単な例としては、中間データを保持するのにいちいち構造体やクラスを 作らずにリストで済ませるといったもの:

(define-macro (make-foo x y) `(cons ,x ,y))
(define-macro (foo-x foo) `(car ,foo))
(define-macro (foo-y foo) `(cdr ,foo))

複雑な例としては、複数の任意の構造に対する繰り返し処理を、特別なイテレータ オブジェクトもクロージャも作らずに実現するもの (Scheme48のiterateマクロ、 Common Lispのseries等)まである。

もっとも、言語がスクリプティングやプロトタイピングに使われる場合では、 性能はあまり問題ではないことも多い。一方で、このように使われるマクロは 多くの場合、デバッグをやりにくくする(デバッガを備えている処理系でも、 デバッガから見えるのはマクロ展開後のフォームであることが多い)。

さらに、本来このような機能は自動インライン展開をしてくれる賢いコンパイラだとか、 インライン可能な関数を指定できる言語機能があれば不要である。

したがって、こういったマクロの使用パターンはあまり好ましくないといえる。

(ただし、プロダクションコードを書いていて、開発の最終段階でどうしても あと10% inner loopのオーバヘッドを削らなければならない! とか 将来はコンパイラが賢くなるんだろうけどそれまで待ってられないんだよ! という 止むに止まれぬ事情がある場合に、このパターンのマクロは特効薬となる。 濫用したら危険だけれど、最後に頼れる薬があるってわかってると プロジェクトを賭ける気になれるんだよね。)

(ちなみにこのパターンのハックはGaucheのコンパイラ (0.8.x時点) にも てんこもりだ。特に構造体をベクタに展開してるやつ。GaucheのVMは、 インデックスが定数であるベクタアクセスは高速だ。 ASTの構造体へのアクセスはinner loopで呼ばれるのでものすごく効いてくる。 このハックは、コンパイル時間が実用的な範囲に収まるかどうかという かなり重要な差異を生み出している。もちろん最終的には、高速なアクセサを 生成する構造体をGaucheがネイティブでサポートすればいいんだけれど、 それまでのつなぎにこういうハックが使えるか使えないかは大きい)

common idiomの抽出

ifがあれば条件判断はすべて書けるが、しばしば条件が成立した時のみ、 あるいは成立しなかった時のみに処理をずらずら書きたい場合がある。 (if expr (begin body ...)) が (when expr body ...) と 書けたら見やすかろう。と、こういうパターンだ。 when, unless, dotimes, dolist, push!、なんてのがすぐに思い付く。

このパターンは、事実上、言語の構文を拡張していることになる。

従って、このパターンのマクロをグローバルに使いまわす場合は特に、 「新たな構文を加えることによる複雑度の増加」に見合うだけのメリットが マクロにあるかどうかを考える必要がある。 また、そのマクロが言語の構文と同程度に明確に定義され、 ドキュメントされていることが必要だ。

もうひとつ、このパターンの陥りやすい罠は、汎用性と簡潔さを 両立させようとするあまり、サブ言語を作ってしまいがちになることだ。 CommonLispのloopマクロはその極端な例だ。 ここでサブ言語とは、S式に独自のセマンティクスを持たせるという意味では 下に述べるdomain specificな言語に似ているが、対象ドメインを特に限定していない ものを指すことにする。

マクロ反対者が危惧する、「人のプログラムを見て理解できなくなる」という ケースの多くは、このパターンの濫用にあると思われる。 loopマクロ並のサブ言語を勝手にいくつも使われたら、確かにとんでもないことになる。

実際、このようなcommon idiomのリファクタリングにマクロは必須ではない。 first class closureさえあればほとんど同じことができる(Lisp, Schemeでは 'lambda'がたくさんくっついたり括弧が増えたりということはあるが、 言語の字句構造を工夫すればある程度避けることができる)。 性能に関しても関数のインライン展開で言語が対応してくれるとすれば、 複雑な制御構造をマクロで実現する意味はほとんどない。

従ってこのパターンでのマクロは、グローバルに使う場合は無闇に柔軟にせず、 when, unless並に単機能かつ十分一般的なパターンを捕まえる場合のみに 限るべきだろう。

例外的に、あるモジュール中で繰り返し表れるパターンがある場合に、 それを簡潔に記述するためにローカルなマクロを定義して使う、ということは あっても良いと思う。マクロ定義が使用のすぐ上にあれば、 理解するのはさほど難しくない。

domain specificな言語構造

common idiomのパターンと重なる部分もあるが、より対象分野が明確である場合だ。 このパターンの最もポピュラーな例は(マクロではないが)正規表現である。 最近の多くの(特にスクリプト系の)言語は、本来異質な構文を持つはずの 正規表現のリテラル表記を言語に取り込んでいる。ミトコンドリアを取り込んだ 細胞のように。これは、

  1. 正規表現の文法が、(若干揺れはあるものの)その分野で十分に 確立されたものであること
  2. 言語の構文に異質なものを混ぜても余りあるメリットがある

ということだろう。

正規表現を言語に取り込むことを否定しない人ならば、 上記の条件さえ満たされれば、他のdomain specificな言語構造を マクロによって取り込むことに意を唱えることはないだろう。

このパターンのマクロの大規模なものとしては、 例えばSchemeにPrologソルバを埋め込める Schelogとか、 BiglooのLALRパーザ等が 思い浮かぶ。小さなものでは、 Scheme:マクロの効用に挙げたlist comprehensionのマクロもそうだ。 (これは後に、Eager comprehensionとしてSRFIになった → SchemeXref:SRFI-42?)。

このパターンの亜種として、特定の分野で使う構造を宣言的に記述する ためにマクロを使う、という方法がある。裏ではランタイムに動的に 色々な手続きが走って構造を作っているのだけど、プログラム上はあたかも静的に 構造が定義されているように見える、というものだ。 そのようなマクロは必ずしも上記の1.の条件を満たさないが、 多くの場合、宣言的マクロは "define-なんとか" で始まったりするので、 見慣れていればだいたい見当がつく。

もちろんこの使い方の代表格は、クラス定義のdefine-classマクロだ。 本当は

(define myclass 
  (make <class> :name 'myclass
                :supers `(,parent) 
                :slots  `((a :init-value 0) (b :init-keyword :b) ...))

などと書いたっていいのだが、

(define-class myclass (parent)
  ((a :init-value 0)
   (b :init-keyword :b)
   ...))

と書いた方が、「クラスを定義している」という気分になるではないか。 もちろん、プログラムの意味もずっとわかりやすくなる。

但し、このパターンでも何でもかんでもマクロにすれば良いとは限らない。 例えば上記define-classに対して、「自分はいつでもスロットに:accessorと :init-keywordをつけるから、それを自動的にやってくれるdefine-class-simple マクロを書こう」とやりはじめると、言語構文がdivergeしていってしまう。 あくまで、domain specificなマクロはそのドメインを扱うモジュールでの ローカルな使用に留めるべきだろう。

その点に留意すれば、このパターンのマクロは最も有効なマクロの使いかたの ひとつであると思う。

言語拡張実験のツール

言語の拡張を考える際には、その拡張がどのような影響をもたらすかを 多角的に検討しなければならない。実際にその拡張を使ったプログラムを 書いてみないとわからないこともある。

マクロがあれば、言語処理系に手を加える前に、 実際にその拡張を言語自身で実装して検証してみることができる。

例えばごく最近、comp.lang.schemeにて、force/delayによる遅延評価は 本当にapplicative orderな言語でnormal orderな評価を実現できるのか、 ということが話題になった (Space-safe Lazy Computation)。 このような検討をマクロ無しで行うことは難しい。 (ちなみに類似のスレッド Delay, force and tail recursionにはcall/ccの巧みな利用もある。)

言語のユーザの中で、このような実験を必要とする人の割合はほんの 少しではあるだろうけれど、実際にそれが出来る、ということは やりたい人にとっては大きな助けになる。


開発形態による違い

別の切り口として、マクロに対する賛否に、 開発形態の違いを見て取ることもできる。

マクロは言語のユーザに簡単に言語を拡張する手段を与える。 もし大きなチームでの開発で、それぞれのプログラマが自分の好きな用に マイマクロを定義して使っていたら、人のコードの保守は悪夢となる。

大きな開発プロジェクトでは、マクロの定義は、 基本クラス階層の定義と同様の重みを持ってなされるべきだろう。

一方で、例えば一人のプログラマが10万行のプログラムを書くことを 考えてみる。数10万行のC++コードを数ヵ月で一人で書いてしまう人を 見たことはあるが、普通、一人で書ける量には限界があるものだ。 マクロを使うことによってコード量を5万行に出来るなら、 プログラムを書くのも保守するのもずっと楽になる。

実際、マクロのどのくらいのコード圧縮効果があるかどうかはわからない。 Shiro個人は数万行のCommonLispプログラムを書いてメンテしていたことがあるが、 そんなに極端なマクロは使っていなかった。それでも、上に挙げた宣言的 マクロは相当使ったので、モジュールによってはコードは半分くらいに なっていると思う。 (但し、メンテが楽だったのはマクロのせいだけでなく、 packageやmetaobjectを使ったモジュール化のおかげでもある)。

おそらく、マクロは、少人数による緊密な開発に向いているだろう。 チーム内の各プログラマが十分に定義されドキュメントされたマクロを書けるなら、 マクロ反対派が危惧する「他人のコードが読めない」という事態は あまり起こらないと思われる。

ちなみに、全くの他人が書いた大規模なCommonLispプログラム (行数は数えたこともない)を読んだこともあるが、マクロはむしろ読む助けになった。 大部分のマクロは、宣言的構文のために導入されたものだった。


他の言語機能との比較

どうしてもマクロでなければできないことというのは 実はあまり無い。処理の抽象化の多くは(lambdaの氾濫を気にしなければ) 高階関数で可能だし、オブジェクト指向の機構についてのいろいろな処理は メタオブジェクトが使えれば大抵なんとかなる。

これらの機構とマクロとを比較するとどうだろう。

高階関数

マクロの場合、その引数が評価されるかどうか、またどのような コンテクストで評価されるか、はマクロの実装に委ねられている。 そのため、マクロの仕様を知らなければ、それに渡されているコード片の セマンティクスを知ることはできない。一方、高階関数を使った抽象化の 場合、抽象化関数に渡す引数の評価コンテクストは、その呼び出し元だけを 見ていればわかる。そういう意味では、高階関数の方が綺麗だ。

しかし、逆に抽象化機構の定義の方を見た場合、 マクロは定義だけを見れば何をしているかがわかるのに対し、 高階関数を取る関数では、渡される関数の意味をも知らなければ 動作が見えないことが多い。

静的型付きの言語なら、少なくとも渡される高階関数の型が判明しているので それが手がかりになることも多いが、Lisp/Schemeの場合は 渡されるものがどういう関数であるかがコードからは全くわからない。 完全なドキュメントがあれば良いが、無い場合は、その抽象化関数が 呼びだされている箇所を探して、そこでどういう高階関数が渡されているかを 調べる必要がある。この作業はなかなか難しい。

メタオブジェクト


良いマクロの使い方とは

Paul Graham ``On Lisp'' 「いつマクロを使うべきか」より

More ...