マクロ展開時に副作用を起こすことの恐ろしさ

Lisp Advent Calendar 2015の23日目の記事です。

qiita.com

すごく及び腰でしたが、ずっと空いていたので、えいやで登録してみました。

マクロ展開時に副作用を起こすな危険、という内容です。

前書き

On Lisp: マクロのその他の落し穴によると、「Lispは,マクロ展開を生成するコードは 第3章で論じた意味で純粋に関数的であるものと予期している. 展開を行うコードは引数として渡された式にのみ依存すべきで, 値を返す他には周囲の世界に影響しようとすべきではない.」とあります。下線を言い換えると副作用を起こすなということになると思います。NG例の一つに、マクロの展開回数を数えようとしてグローバル変数 *nil!s* に触る以下の例が示されています。

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

正直に言うとその下にある説明では結局いつ困るのかピンと来ませんでした。が、Parenscriptをいじっていてこれで散々ハマった*1ので、勉強結果を展開してみます。次を伝えることが目標です。

  • どう恐ろしいのかという感覚
  • どうしてそうなるのかという理屈

大多数のLisperにとっては分かりきった話だろうと思いつつ、次のような感じで進めていきます。

  • 前座:Parenscriptの簡単な紹介
  • 怖さが伝わるかもしれない例
  • 解説
  • 実際にハマった話
  • まとめ

前座:Parenscriptの簡単な紹介

ParenscriptはCommon Lispの(サブセット)コードをJavaScriptコードに変換してくれるライブラリです。下のように ps:ps マクロの中にCommon Lispコードを書くとJavascriptコードを文字列として出力してくれます。

CL-USER> (ql:quickload :parenscript :silent t)
(:PARENSCRIPT)
CL-USER> (ps:ps (test-func 10 20))
"testFunc(10, 20);"
CL-USER> (ps:ps (funcall (lambda (a b) (+ a b))
                         10
                         20))
"(function (a, b) {
    return a + b;
})(10, 20);"

Lispとしては外せないマクロもサポートされていて、大きくは次の2つの方法で定義できます。

;; ps環境内でdefmacroを呼ぶ方法 
CL-USER> (ps:ps (defmacro test-macro (a b)
                  `(+ ,a ,b))
                (test-macro 10 20))
"10 + 20;"
CL-USER> (ps:ps (test-macro 20 30))   ; グローバルに定義される
"20 + 30;"

;; defpsmacro による方法
CL-USER> (ps:defpsmacro test-psmacro (&rest rest)
           `(* ,@rest))
TEST-PSMACRO
CL-USER> (ps:ps (test-psmacro 10 20 30))
"10 * 20 * 30;"

ps環境内でのdefmacroは内部的にはdefpsmacroを呼んでいます。このため、どちらも同じように使えます…だったら良かったのですが…。

何が起きるのか

Parenscript用のマクロ(以下、PSマクロ)定義を2種類紹介しました。これらはPSマクロを管理するグローバルな変数parenscript::*macro-toplevel*に登録されるタイミングが異なります。

  • ps環境内での defmacro: 展開「時」にマクロ定義が登録される
  • defpsmacro: 展開「後」にマクロ定義が登録される

従って、ps環境内での defmacro の方が展開時に副作用を起こすというまずい動作をしています。これが何を引き起こすのか見てみます。

準備

REPLや一つのスクリプトファイルで試していても中々起きない現象であるため、小さなプロジェクトを一つ起こして、quicklispの配下に置きます。

CL-USER> (ql:quickload :cl-project :silent t)
(:CL-PROJECT)
CL-USER> (cl-project:make-project (merge-pathnames #p"test-ps-eval-order" ql:*quicklisp-home*)
                                  :author "eshamster"
                                  :licence "MIT"
                                  :depends-on '(parenscript))
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/.gitignore
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/README.markdown
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/README.org
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/test-ps-eval-order-test.asd
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/test-ps-eval-order.asd
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/src/test-ps-eval-order.lisp
writing /home/esh/.roswell/impls/ALL/ALL/quicklisp/test-ps-eval-order/t/test-ps-eval-order.lisp
T

次に、できあがったsrc/test-ps-eval-order.lisp を編集して次のように2種類の方法でParenscript用のマクロを定義してみます。また、これらのマクロの展開結果を確認するため、print-ps関数を作成 & exportします。なお、eval-whenがないと(print (ps (test-defpsmacro)))の部分でマクロが動きませんが、本題ではないので詳細略です*2。

gist.github.com

さらに、できるだけクリーンな環境で実行したいので、Roswellスクリプトを一つ起こして、上記のprint-ps関数を呼び出すコード(とParenscriptをロードするコード)を追加します。

# ※OSコンソール
$ cd 任意の場所
$ ros init execute.ros

gist.github.com

実行

さて実行です。

$ ./execute.ros
To load "test-ps-eval-order":
  Load 1 ASDF system:
    test-ps-eval-order
; Loading "test-ps-eval-order"
[package test-ps-eval-order]
----- From test-ps-eval-order::print-ps -----
"ok = 'expanded by test-defpsmacro';"
"ok = 'expanded by test-defmacro-in-ps';"

どちらの定義もパッケージ内部ではうまく動いているようです(下3行)。次はパッケージ外(execute.ros側)から呼び出すため、以下を修正します。

  • src/test-ps-eval-order.lispのexportに両マクロを追加
  • execute.rosのmain関数にこれらを呼び出すコードを追加
;; src/test-ps-eval-order.lisp
(defpackage test-ps-eval-order
  (:use :cl
        :parenscript)
  (:export :test-defpsmacro
           :test-defmacro-in-ps
           :print-ps))
;; execute.ros
(defun main (&rest argv)
  (declare (ignorable argv))
  (print-ps)
  (princ "----- From execute.ros ----")
  (print (ps (test-defpsmacro)))      ; ここと
  (print (ps (test-defmacro-in-ps)))  ; ここの2行はprint-ps関数と同じコード
  (fresh-line))

そして実行。

$ ./execute.ros
To load "test-ps-eval-order":
  Load 1 ASDF system:
    test-ps-eval-order
; Loading "test-ps-eval-order"
[package test-ps-eval-order]
----- From test-ps-eval-order::print-ps -----
"ok = 'expanded by test-defpsmacro';"
"ok = 'expanded by test-defmacro-in-ps';"
----- From execute.ros ----
"ok = 'expanded by test-defpsmacro';"
"ok = 'expanded by test-defmacro-in-ps';"

なんだ問題ないじゃないか…と思って、もう一度実行してみます。

$ ./execute.ros
To load "test-ps-eval-order":
  Load 1 ASDF system:
    test-ps-eval-order
; Loading "test-ps-eval-order"

----- From test-ps-eval-order::print-ps -----
"ok = 'expanded by test-defpsmacro';"
"ok = 'expanded by test-defmacro-in-ps';"
----- From execute.ros ----
"ok = 'expanded by test-defpsmacro';"
"testDefmacroInPs();"

なんということでしょう。1回目と異なり、execute.ros側だけps環境内のdefmacroで定義したtest-defmacro-in-psマクロが消えています(関数扱いされています)。

  • 同じように書いたのに結果が違う…
    • → 書いたコードをいくら眺めても原因が分からない
  • 2度実行すると結果が変わる…
    • → 再現条件に確信を持てないため、色々いじっても直ったのか判断できない

見た瞬間デバッグする気力が削られる要素に満ちています。

解説

どうしてこうなったかを解説し、さらに、現象を再現する小さなコードを書いて動作を眺めてみます。

どうしてこうなった

上記の2回連続実行の出力を良く見ると、本体であるprint-psの出力の手前に違いがあります。1回目は[package test-ps-eval-order]の出力がありますが2回目はありません。quicklisp/setup.lispを見てみると、これはコンパイル時のみ出力されるメッセージのようです*3。

(defun macroexpand-progress-fun (old-hook &key (char #\.)
                                 (chars-per-line 50)
                                 (forms-per-char 250))
;; ~略~
             (show-package (name)
               ;; Only show package markers when compiling. Showing
               ;; them when loading shows a bunch of ASDF system
               ;; package noise.
               (when *compile-file-pathname*
                 (finish-line)
                 (show-string (format nil "[package ~(~A~)]" name))))

ここから、以下の違いにより1回目と2回目で結果が変わったと推測できます。マクロ展開時の副作用はバイナリには残らないことに注意します*4。

  • 1回目:test-ps-eval-orderをコンパイル*5、続けてそれをロードしてexecute.rosを実行
    • コンパイルから実行までが同じ環境で行われる
    • → マクロ展開時の副作用(test-defmacro-in-psマクロの定義)はバイナリには残らないが、環境には残っている
    • → test-defmacro-in-psマクロの定義がexecute.rosからも見える
  • 2回目:コンパイル済みのtest-ps-eval-orderをロードしてexecute.rosを実行
    • コンパイルと実行が異なる環境で行われる
    • → マクロ展開時の副作用はバイナリには残らないし、そのため環境にもロードされない
    • → test-defmacro-in-psマクロの定義がexecute.rosからは見えない

結局のところマクロ展開時の副作用は、ライブラリに変更がない場合はコンパイルを省略しても結果は変わらない、という(妥当な)仮定を崩すことになります。

なお、その他2点の疑問は以下のように説明できます。

  • なぜ、test-ps-eval-orderライブラリ内部からは常にtest-defmacro-in-psが見えるのか
    • バイナリにはtest-defmacro-in-psマクロが既に展開された状態で記録されているため
      • ps:psマクロによるtest-defmacro-in-psマクロの展開もコンパイル時に行われる
  • なぜ、test-defpsmacroは常にどこからでも見えるのか
    • バイナリにParenscript用マクロ定義(= parenscript::*macro-toplevel*への登録)を行う処理自体が残るので、ロード時に定義が実行されるため

小さく再現してみる

解説のためというよりは、現象をより剥き出しにするための小さなコードを書いてみます。

2つのファイルを用意します。1つはライブラリのつもりでtest-lib.lispを、もう1つはこれを利用するアプリケーションのつもりでtest-app.rosスクリプトを用意します。

test-lib.lisp

(eval-when (:compile-toplevel :execute :load-toplevel)
  (defvar *hoge-func-list* nil))

(defmacro defhoge (name &body body)
  `(progn (pushnew ',name *hoge-func-list*)
          (defun ,name ()
            ,@body)))

(defmacro defhoge-wrong (name &body body)
  (pushnew name *hoge-func-list*)         ; 誤り
  `(defun ,name ()
     ,@body))

(defhoge lib 1)
(defhoge-wrong lib-wrong 2)

(defun print-all-hoge ()
  (dolist (hoge (reverse *hoge-func-list*))
    (format t "~A from ~A~%" (funcall hoge) hoge)))

外の環境に触れたくなるのは大抵define系マクロだろうと思い、test-lib.lispではdefhogeというhogeを定義するマクロを提供します。defhoge-wrongも同様ですが、マクロ展開時に登録を行うという間違った動作をします。また、それぞれを利用して2つのhoge、libとlib-wrongを定義します。さらに、hogeを登録順に出力するprint-all-hoge関数も提供します。

test-app.ros

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#

(defvar *load-kind* 0) ; ここを書き換えて実行する: 0, 1, 2

(case *load-kind*
  (0 (load "test-lib.lisp"))
  (1 (compile-file "test-lib.lisp" :output-file "test-lib.fasl" :print nil :verbose nil)
     (load "test-lib.fasl"))
  (2 (load "test-lib.fasl"))
  (t (error "arg error")))

(defhoge app 10)
(defhoge-wrong app-wrong 20)

(defun main (&rest argv)
  (declare (ignorable argv))
  (print-all-hoge)
  (fresh-line))

test-app.rosでは上記のtest-lib.lispをロードして2つのhoge、appとapp-wrongを定義し、print-all-hogeを呼び出して登録済みhoge一覧を出力します。ロードは*load-kind*の値に応じて3種類のいずれかの方法で行います。

*load-kind*が0の場合:test-lib.lisp自体をロード

$ ./test-app.ros
1 from LIB
2 from LIB-WRONG
10 from APP
20 from APP-WRONG

*load-kind*が1の場合:test-lib.lispをコンパイルし、続けてtest-lib.faslをロード

$ ./test-app.ros
2 from LIB-WRONG
1 from LIB
10 from APP
20 from APP-WRONG

*load-kind*が2の場合:コンパイル済みtest-lib.faslをロード(※事前に1のケースを動かすこと)

$ ./test-app.ros
1 from LIB
10 from APP
20 from APP-WRONG

ここまでの説明で原理は分かるはずなので解説は省略します。念のため、最初のtest-ps-eval-orderの例では、1回目の実行は*load-kind*が1の場合に、2回目の実行は*load-kind*が2の場合に相当します。なお、この例では*load-kind*を固定している限り、何度実行しても実行結果は変わりません。

余談ですが、hogeを記録する*hoge-func-list*をdefvarではなくdefparameterで定義すると、*load-kind*が1のときの結果が2の場合と同じになります。定義済みの変数を上書きするdefparameterと上書きしないdefvarの挙動の違いですね。

実際にハマった例

最後にこの記事の発端となったコードを。Parenscriptをもう少し便利に使えないかと色々実験をしているps-experimentというライブラリを作っています*6。

github.com

この中で、ps環境下で利用できるdefstructのサブセットを作ったのですが、これを利用するコードでエラーが出て散々にハマりました。定義の一部を載せます。冒頭のパース系の関数の定義は本題と無関係なため省略します。

gist.github.com

コメントにありますが、アクセサの定義で利用しているdefmacroが問題です。ここで注意ですが、defpsmacroで定義したParenscript用のマクロは、結局ps環境下で展開されます。このため、defpsmacro下でのdefmacroはps環境下でのdefmacroと実質上同じものです。

アクセサがマクロ展開時に定義されてしまうため、このdefstructで定義した構造体を別のライブラリから使おうとすると、アクセサだけ見えない(ことがある)という問題に悩まされることになります(なりました)。

厄介なことに、この問題はps-experimentのテストでは検出されませんでした。テストでは同じ環境下でdefstructによる構造体定義とそのテストコードをロードするため、問題なく「動いてしまいました」。上記のtest-libとtest-appの例で言うと、test-app側でのdefhoge-wrongの利用に相当するケースです。

結局どうしたのかですが、ps環境下でのdefstructはデサポートすることにしました。代わりに、これをラップしてトップレベルで利用するために用意していたdefmacro.psマクロを直接提供することにしました。

gist.github.com

defxxx.ps系マクロはトップレベルでParenscript用の色々を定義するためにps-experimentで用意しているマクロ群です。全体として、defpsmacroをdefmacroで、その他defxxxをdefxxx.psで置き換えた以外、見た目に大きな違いはありません。

ただ、マクロ展開時にグローバルな値を読み込んでいる箇所があり、問題がないか気にしています。具体的には、include(スロット定義の継承)を実現するために、parse-defstruct-name-and-optionsが*ps-struct-slots*(上記では省略)というグローバルなハッシュを読み込んでいます。このハッシュへのスロットの登録はregister-defstruct-slotsで行っています。eval-whenによる指定で、下記のようなコードをコンパイルしたときにも、childのマクロ展開よりも早い段階でparentの登録を行う形になっています(かつマクロ展開時の副作用を避けています)。ここのハッシュ読み込みは明らかに「純粋に関数的」でないため怪しいのですが、当面これで様子を見ようと思っています。

(defstruct parent a b)
(defstruct (child (:include parent)) c)

現状で見えている怪しげな動作というと、コンパイル→ロードとすると同じ定義が2度実行されるというものがあります。が、同じもので上書きするだけなので大抵問題ない…はず。ちなみに、この辺りの動作は上記test-lib, test-appにおいて、1. defhogeのprognをeval-whenに置き換える、2. pushnewをpushで置き換える、3. *load-kind*を1に設定する、としてみると確認できます("1 from LIB"が2回出ます)。

記事を書くにあたり、参考にClozure CLのdefstructの実装を見てみたのですが、グローバルな環境への登録はあくまでロード時に行っており(%defstruct-do-load-time)、コンパイル時にはレキシカルな環境&environment envに一時的に登録することで副作用を避けているようです(define-compile-time-structure)。マクロ展開が「純粋に関数的」な動作をするようにかなり慎重に作られている様子が伺えます。注意ですが、まだ&environmentを理解し切れていないので嘘を言っているかもしれません。


まとめ:恐しさについて改めて

マクロ展開時に副作用を起こすことの恐ろしさは、原因を特定しにくいバグにつながる、というところにつきます。

マクロ展開時の副作用の結果は環境には残るため、Lispの利点であるインクリメンタルな開発の最中にはまず気づきません。さらに、テストを書いてクリーンな環境で実行していてもまだ気づかないケースも多いです。これは、上記のps-experimentのdefstructサブセットのように、自身では使わない外向けに提供する機能で起こりやすいです。そしてある日、実行条件に応じて結果が変わるような再現しにくいバグに遭遇します。

バグの原因特定を困難にする典型的な要因である、発見までに時間がかかることと、再現条件が分かりにくいことという両方を満たすわけです。自分はこのバグに遭遇してから見当違いの方向にも走りつつ数日苦しみました。

ということで、マクロ展開時の副作用には敏感になりましょう、と釈迦に説法をしたところで終わりにします。


*1:Parenscriptの仕様と関係なく勝手にハマった部分も多々ありますが、、とりあえず関係ある部分の紹介です

*2:eval-whenがdefpsmacro内で呼ばれていないのは、それはそれで問題なのですが、外付けで対処可能なため傷は浅いです。eval-when自体について参考になるのはこの辺り「macros - Eval-when uses? - Stack Overflow」でしょうか。なお、Parenscriptのこのコミット(リンク)でmasterは修正されていますが、quicklispの参照しているhttp://common-lisp.net/project/parenscript/release/parenscript-latest.tgzに反映されていないようです

*3:本題と関係ない調査メモ。HyperSpecによると、*compile-file-pathname*はcompile-file関数の実行中のみファイルパスが設定され、それ以外はnilにセットされるもののようです。また、macroexpand-progress-fun自体は、*macroexpand-hook*用のhook関数を返します。切り出したコードの少し下を見ると、defpackageマクロの展開時であることが内部関数show-packageを呼び出す条件の一つになっています。以上を合わせて、コンパイル時のみパッケージ名を(重複なく)出力する動作を実現しているようです。

*4:HyperSpecにあるexpansion functionの説明では"The value of the last form executed is returned as the expansion of the macro"と記述されています。

*5:上の例では、ここでコンパイルさせるためにtest-ps-eval-orderライブラリ側にわざとらしく変更を加えています :)

*6:参考記事:Parenscriptで遊んで見る (1) defun編 - eshamster’s diary