マクロのその他の落し穴

マクロを書くときには注意が余分に必要だ. 関数は自分のレキシカルな領域に孤立しているが, マクロは,呼出側のコード内に展開されるので, 注意深く書かないと不愉快な驚きをもたらすことがある. 第9章で扱った変数捕捉はその最たるものだ. この章ではマクロを定義する際に避けるべき問題をさらに議論する.

評価の回数

前の章ではforの不適切な定義例をいくつか示した. 第\ref{fig:ControlingArgEval}図にはさらに2例を,比較用の適切な定義と共に示した.

適切なヴァージョン:
(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    `(do ((,var ,start (1+ ,var))
          (,gstop ,stop))
         ((> ,var ,gstop))
       ,@body)))
複数回の評価を起こし得る:
(defmacro for ((var start stop) &body body)
  `(do ((,var ,start (1+ ,var)))
       ((> ,var ,stop))
     ,@body))
評価の順番が間違っている:
(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    `(do ((,gstop ,stop)
          (,var ,start (1+ ,var)))
         ((> ,var ,gstop))
       ,@body)))
\caption{引数評価の制御} \label{fig:ControlingArgEval}

捕捉を起こすことはないが,2番目にはバグがある. これの展開形はstopとして渡された式を反復の度に評価してしまう. 一番運のよかった場合でも,この種のマクロは非効率で,1回行えばよいことを繰り返している. stopに副作用があれば,このマクロが実際に間違った結果をもたらすかも知れない. 例えば次のループはいつまでも終了しない. ゴールが反復の度に遠のいてゆくからだ.

> (let ((x 2))
     (for (i 1 (incf x))
        (princ i)))
12345678910111213...

forのようなマクロを書くときには, マクロの引数は式であって値ではないことを忘れてはいけない. それらが展開形内に現れる場所によっては複数回評価されることもある. 上の場合,解決策は式stopが返した値に変数を束縛し, 繰り返しの間にはその変数を参照することだ.

マクロは,明確に反復を意図していなければ, 式がきっかりマクロ呼び出しに出て来る回数だけ評価されるようにしておくべきだ. この規則の当てはまらない明らかな例がある: Common Lispのorは,全ての引数を必ず評価するようになったら ずっと不便になってしまうだろう(それではPascalのor構文だ). しかしこのような場合では何回評価が行われるかは分かっている. 上の2番目のforではそうではない. 式stopが複数回評価されると想定するような理由はないし,実際そうであるべき理由もない. 2番目のforのようにして書かれたマクロは過誤の産物であることが一番多い.

意図せぬ複数回の評価はsetfの上に構築されたマクロにとっては特に難しい問題だ. Common Lispはそのようなマクロを書き易くするための幾つかのユーティリティを提供している. 問題と解決策は第12章で扱う.

評価の順番

式が評価される順番は式が評価される回数程重要ではないが,時々問題になる. Common Lispの関数呼び出しでは,引数は左から右の順で評価される.

> (setq x 10)
10
> (+ (setq x 3) x)
6

マクロについてもそうするのがよい習慣だ. マクロでは常に確かに式がマクロ呼び出し内と同じ順で評価されるようにするべきだ.

第\ref{fig:ControlingArgEval}図では,3番目のforにも微妙なバグがある. 仮引数stopstartより前に評価される. マクロ呼び出しでは逆の順番になっているというのに.

> (let ((x 1))
    (for (i x (setq x 13))
         (princ i)))
13
NIL

このマクロは,時間が逆戻りしたかのようなdisconcerting印象を与える. 字面の上では式startが最初に出て来るにも関わらず, 式stopの評価が式startの返す値に影響しているのだ.

適切に定義されたforでは,引数は確かに出て来る順で評価されるようになっている.

> (let ((x 1))
    (for (i x (setq x 13))
         (princ i)))
12345678910111213
NIL

これならxを式stop内で設定しても,前の引数が返す値には何の影響もない.

上に示した例はこのために作ったものだが, この種の問題が顕在化する状況は存在し,そしてこのようなバグは発見が極めて難しい. マクロのある引数の評価が別の引数が返す値に影響するようなコードを書く人はまずいないだろう. しかし人は意図的には決してやらないことでも誤ってやってしまうことがあるものだ. ユーティリティは,意図された通りに使われれば正しく機能するだけでなく, バグを隠蔽してもいけない. 上の例のようなコードを書く人がいたら,それはきっと間違って書いたのだろう. しかし適切に定義されたforは容易に誤りに気付かせてくれる.

関数によらないマクロ展開

Lispは,マクロ展開を生成するコードは 第3章で論じた意味で純粋に関数的であるものと予期している. 展開を行うコードは引数として渡された式にのみ依存すべきで, 値を返す他には周囲の世界に影響しようとすべきではない.

\textsf{CLtL2}にもあるように(p. 685), コンパイル済みコード内でのマクロ呼び出しは 実行時に再展開されることはないと思っておくのが安全だ. そうでなければ,Common Lispはいつの時点で, またはどれほどの頻度でマクロ呼び出しが展開されるのか保証できなくなってしまう. マクロの展開結果がそのどちらかによって変わることがあれば,それは誤りと見なされる. 例えば,あるマクロが使われた回数を数えたいとしよう. 単にソースに検索をかければよい訳ではない. そのマクロがプログラムの生成したコード内で呼ばれているかも知れないからだ. だから次のようなマクロを定義したくなる.

(defmacro nil! (x)                   ; 誤り
  (incf *nil!s*)
  `(setf ,x nil))

この定義ではグローバル変数*nil!s*nil!が展開される度に1だけ増加する. しかしこの変数の値からnil!の呼ばれた回数が分かると思ったら間違いだ. あるマクロ呼び出しは複数回展開され得る(実際しばしばそうだ). 例えばソースコードの変換を行うプリプロセッサは,式内のマクロ呼び出しを, それを変換すべきかどうか判断する前に展開しなければならないかもしれない.

一般則としては,展開を行うコードは引数以外の何にも依存すべきでない. だから例えば展開形を文字列から作り出すようなどのマクロも, 展開時に何のパッケージ内にいるかについて勝手に予想しないよう注意すべきだ. 次の簡潔だがかなり病的な例は,

(defmacro string-call (opstring &rest args)              ; 誤り
     `(,(intern opstring) ,@args))

オペレータの印字名をとってそのオペレータへの呼び出しを展開するマクロを定義している.

> (defun our+ (x y) (+ x y))
OUR+
> (string-call "OUR+" 2 3)
5

internは文字列を取り,対応するシンボルを返す. しかしオプショナル引数のパッケージを省くと,カレント・パッケージが使われる. よって展開形はそれが生成されるときのパッケージに依存することになる. our+がそのパッケージで可視でない限り,展開形は未定義関数への呼び出しになる.

MillerとBensonの\emph{Lisp Style and Design}は 展開コードの副作用から起きる問題の特別に汚い例に言及している. Common Lispでは\textsf{CLtL2}にもあるように(p. 78), &rest引数に束縛されたリストが新しく生成されたものである保証はない. それはプログラム内のどこかのリストと構造を共有しているかも知れない. その結果,&rest引数は破壊的に操作してはいけないことになる. 同時に他の何に変更を加えているか分からないからだ.

この可能性は関数とマクロ両方に影響する. 関数についてはapplyを使うと問題が顕在化する. Common Lispの規格に沿った処理系の実装では次の問題が起こり得るのだ. 引数のリストの末尾にet alを付け加える関数et-alを定義したいとしよう.

(defun et-al (&rest args)
  (nconc args (list 'et 'al)))

この関数は普通に呼ぶ限りでは適切に動作するように思える:

> (et-al 'smith 'jones)
(SMITH JONES ET AL)

しかしそれをapply経由で呼び出すと,既存のデータ構造が変更される可能性がある:

> (setq greats '(leonardo michelangelo))
(LEONARDO MICHELANGELO)
> (apply #'et-al greats)
(LEONARDO MICHELANGELO ET AL)
> greats
(LEONARDO MICHELANGELO ET AL)

少なくともCommon Lispの規格に沿った処理系ではこうなる可能性がある. 今のところ実際にはそのような処理系はないようだが.

マクロでは危険度が大きい. &rest引数に変更を加えるマクロはそれによってマクロ呼び出しに変更を加える可能性がある. 言い替えれば,うっかり自己修正的なプログラムができてしまいかねないということだ. 現実的な危険性もある ---これは既存の処理系で起こったことなのだ. 何かを&rest引数にnconcするマクロを書き \footnote{`',(foo)`(quote ,(foo))と等価な表現だ.},

(defmacro echo (&rest args)
  `',(nconc args (list 'amen)))

次にそれを呼び出す関数を書く:

(defun foo () (echo x))

広く使われているCommon Lisp処理系では,次のような結果になる.

> (foo)
(X AMEN AMEN)
> (foo)
(X AMEN AMEN AMEN)

fooは誤った結果を返すだけでなく,実行する度に結果は異なる. マクロが展開される度にfooの定義が変更されているからだ.

あるマクロ呼び出しが複数回展開されることについて先に論じた点についても, この例から分かることがある. この特定の処理系では,fooの1回目の呼び出しは2個のamenから成るリストを返す. 何かの理由でこの処理系はマクロ呼び出しをfooの展開時に1回展開し, そしてその後呼び出される度にも1回ずつ展開したのだ.

echoは次のように定義した方が安全だ.

(defmacro echo (&rest args)
  `'(,@args amen))

これはコンマ・アットはnconcでなくappendと等価だからだ. このマクロを再定義した後は, fooはコンパイルされていなくとも再定義しなければならない. 前のヴァージョンのechoがそれを書き換えてしまったからだ. ??? ここから??ENDまでの行が挿入か削除されたようです

マクロでは,同様の危険があるのは&rest引数だけではない. リストであるマクロの引数はいずれも変更しないでおくべきだ. 引数のどれかに変更を加えるマクロとそれを呼ぶ関数を定義すると,

(defmacro crazy (expr) (nconc expr (list t)))

(defun foo () (crazy (list)))

呼出側関数のソースコードは変更されてしまう可能性がある. ある処理系では1回目に呼び出したときにそうなった.

> (foo)
(T T)

これはコンパイラとインタプリタ両方で起きたことだ.

要するに,引数のリスト構造を破壊的に変更することでコンシングを避けようとしないことだ. 出来上がったプログラムは,例え動作したとしても可搬性を持たないだろう. 可変個の引数を取る関数内でコンシングを避けたいなら, 解決策の一つはマクロを使い,コンシングをコンパイル時にずらす方法だ. マクロをこのように使うことについては,第13章を参照すること.

またマクロ展開を行うコードの返した式がクォート付きリストを含むときは, それに破壊的操作を行うことも避けるべきだ. これはマクロの本質的な制限ではなく,第3.3節で大まかに触れた原則の一例だ.

再帰

関数を再帰的に定義するのが自然なときがある. 次のような関数は,本質的に再帰的な性質を持っている.

(defun our-length (x)
  (if (null x)
    0(1+ (our-length (cdr x)))))

上の定義は,等価な反復版よりも(おそらく遅いだろうが)自然に思える.

(defun our-length (x)
  (do ((len 0 (1+ len))
       (y x (cdr y)))
    ((null y) len)))

再帰的でもなく,相互再帰的な関数の集合の一部でもない関数は, 第7.10章で述べた簡単な方法によりマクロに変換できる. しかしただ逆クォートとコンマを挿入するだけでは再帰的関数は変換できない. 組込み関数nthを例に取ってみよう. (話を簡単にするため,このnthはエラーチェックをしないものとする.) 第\ref{fig:MistakenRecursiveFunc}図には nthをマクロとして定義しようとして間違えた例を示した. 表面的にはnthbnthaと等価に見えるが, nthbを使ったコードはコンパイルに通らない. これはマクロ呼び出しの展開が終了しないためだ.

正しく動作するもの:
(defun ntha (n lst)
  (if (= n 0)
      (car lst)
      (ntha (- n 1) (cdr lst))))
コンパイルできないもの:
(defmacro nthb (n lst)
  `(if (= ,n 0)
       (car ,lst)
       (nthb (- ,n 1) (cdr ,lst))))
\caption{誤って再帰関数と同じように捉えてしまった例.} \label{fig:MistakenRecursiveFunc}

一般的に,マクロが他のマクロへの参照を含むことは,展開がどこかで終了する限りは問題ない. nthbの問題は,nthbのどの展開形もnthb自身への参照を含む点だ. 関数版のnthaは終了する. それはnの値について再帰的なのだが,この値は再帰の度に減少するからだ. しかしマクロ展開は式にしかアクセスがなく,その値までは分からない. コンパイラが,例えば(nthb x y)を展開しようとすると,最初の展開では

(if (= x 0)
  (car y)
  (nthb (- x 1) (cdr y)))

が作られ,これは更に次のようになる.

(if (= x 0)
  (car y)
  (if (= (- x 1) 0)
    (car (cdr y))
    (nthb (- (- x 1) 1) (cdr (cdr y)))))

こうして無限ループに陥ってしまうのだ. マクロが自分自身の呼び出しを展開することに問題はないが, それはいつまでも続かないときの話だ.

nthbのような再帰的マクロの危険な点は,インタプリタでは適切に機能することだ. とうとう動作するようになったプログラムをコンパイルしようとしたとき, それがコンパイルを通りすらしないことになる. それだけでなく,問題が再帰的マクロによることは普通分からない. コンパイラはただ無限ループに陥り,そこで何が悪いのか考える羽目になる.

上の場合,nthaは末尾再帰的だ. 末尾再帰的関数は容易に反復形に変換でき,それはマクロのモデルに使える. nthbのようなマクロはこうして書ける.

(defmacro nthc (n lst)
  `(do ((n2 ,n (1- n2))
        (lst2 ,lst (cdr lst2)))
     ((= n2 0) (car lst2))))

原則的にはマクロで再帰関数を複製することは不可能ではない. しかし複雑な再帰関数の変換は難しかったり,不可能ですらあるときもある.

マクロの用途によっては,代わりにマクロと関数の組合せを使うことで十分なときもある. 第\ref{fig:TwoWayFixProb}には再帰的マクロのようなものを作る方法を2通り示した. nthdで示される1番目の戦略は,単にマクロを再帰関数の呼び出しへ展開させることだ. 例えばマクロが引数にクォートを付ける手間を省くためだけに使われているのなら, この手法で十分だろう.

(defmacro nthd (n lst)
  `(nth-fn ,n ,lst))

(defun nth-fn (n lst)
  (if (= n 0)
    (car lst)
    (nth-fn (- n 1) (cdr lst))))

(defmacro nthe (n lst)
  `(labels ((nth-fn (n lst)
              (if (= n 0)
                  (car lst)
                  (nth-fn (- n 1) (cdr lst)))))
     (nth-fn ,n ,lst)))
\caption{問題の2通りの修正方法.} \label{fig:TwoWayFixProb}

マクロが必要な理由が,その展開形を丸ごと呼出側のレキシカルな環境に挿入したいということなら, ntheに例示した方法の方ががよいだろう. 組込みの特殊式labels(第2.7節を参照)がローカルな関数定義を作っている. nthcの展開形はいずれもグローバル関数nth-fnを呼んでいたが, ntheの展開形はそれぞれの内部に同様な関数を持っている.

再帰関数を直接マクロに変換できなくても,展開形が再帰的に生成されるマクロを書くことはできる. マクロを展開する関数はLispの通常の関数であって,もちろん再帰的になり得る. 例えば組込みのorを独自に定義するときには, 再帰的な展開関数を使うことになるだろう.

第\ref{fig:RecExpansionFunc}図には, orのための再帰的な展開関数を定義する方法を2通り示した. マクロoraは展開形を作るために再帰関数or-expandを呼び出す. これは適切に動作する.また等価なorbも同様だ. orbは再帰的だが, マクロの引数そのものについて再帰的であり(これは展開時にアクセスできる), その値について再帰的なのではない(こちらはアクセスできない). 展開形がorbそのものへの参照を含むようにも見えるが, ある1段階のマクロ展開で生成されたorbの呼び出しは 次の段階でletに置き換えられ, 展開の最終形では入れ子になったletのスタックしか残らない. (orb x y)は次のコードと等価なコードに展開される.

(let ((g2 x))
  (if g2
      g2
      (let ((g3 y))
        (if g3 g3 nil))))

実際oraorbは等価であり,どちらを使うかは個人的な好みの問題に過ぎない.

(defmacro ora (&rest args)
  (or-expand args))

(defun or-expand (args)
  (if (null args)
      nil
      (let ((sym (gensym)))
        `(let ((,sym ,(car args)))
           (if ,sym
               ,sym
               ,(or-expand (cdr args)))))))

(defmacro orb (&rest args)
  (if (null args)
      nil
      (let ((sym (gensym)))
        `(let ((,sym ,(car args)))
           (if ,sym
               ,sym
               (orb ,@(cdr args)))))))
\caption{再帰的展開関数.} \label{fig:RecExpansionFunc}

←: 変数捕捉     ↑: On Lisp     →: 古典的なマクロ

Copyright (c) 2003-2011 野田 開     NODA Kai <nodakai@gmail.com>