cl-annot を再実装して cl-annot-revisit を作った
この記事は Lisp Advent Calender 2019 に便乗して書かれました。 先週は C++ と Common Lisp を見比べながらどっちかに物申す 記事を書き、今週はその続きと行きたかったのですが、元気と時間がなかったので別の話にしました。
2019-12-28 追記: nest
マクロについての余談を追加。
概要; なんで再実装なんかしたのか
cl-annot を再実装した、 cl-annot-revisit なるものを書いていたのでご紹介です。
cl-annot とは Lisp のリーダーマクロを用いて、 Python の デコレータのような記法を作るライブラリです。
例えば以下のようなコードを書くと:
@eval-always (defun hoge () ...)
このように展開されます:
(eval-when (:compile-toplevel :load-toplevel :execute) (defun hoge () ...))
cl-annot の実装がすでにあるのに、なぜ再実装したのかというと・・かなり前に reddit 上で以下の批判的書き込みを見たことに起因します。 www.reddit.com
このリンク先の内容や、その他いくつかの再実装の理由を以下に書いていきます。
バグがあった
上記 reddit では、 @doc
のバグが挙げられています。 @doc
は docstring を埋め込むもので、以下のcl-annot の README の例 のように使います:
@doc "docstring" (defun f () ...)
上記は、以下のように通常の docstring の構文に展開されます。
(defun f () "docstring" ...)
しかし、本体が空の関数に使うと問題が起こります。
@doc "foo" (defun bar ())
このコードの意図するところは、「何もしない関数 bar
を定義する」「その docstring は "foo"
とする」という辺りでしょう。
しかし、このコードは以下のように展開されてしまいます。
(defun bar () "foo")
これは、「"foo"
を返す関数 bar
を定義する」「その docstring はない」という状態です。この結果は、おそらく意図したものではありません。この関数 bar
に docstring をつけるのなら、以下のように documentation
を使わなければなければなりません:
(defun bar ()) (setf (documentation 'bar 'function) "foo")
この他にも、 cl-annot には問題がいくつかあります:
- (上述)
@doc
を body がないdefun
,defmethod
などにつけると、戻り値を置き換えてしまう。 - (上記 reddit での指摘)
@doc
を、defvar
の初期値を与えない(defvar *foo*)
用法の式に使うとエラーになる。また、@doc
を docstring がある関数に適用すると、処理系によってはエラーになる。 method-qualifier を持つdefmethod
に与えると、規格違反のコードに展開される。 - ANSI CL の
declaration
という宣言は proclamation にしか使えず、 declaration に使ってはいけないのだが、@declaration
はdeclare
の内部に展開してしまい、結果として規格違反のコードが生成される。 @ignore
,@ignorable
,@dynamic-extent
には、(function <関数名>)
が渡せない。例えば@ignore (function foo)
と書くと、(declare (ignore function foo))
とフラットに展開されてしまい、結果として関数をignore
する記法が書けない。defstruct
オプションの(:constructor)
(デフォルトのコンストラクタ関数を作る)が、(:constructor nil)
(コンストラクタ関数を作らない)と混同されて扱われる。- また、
(:constructor nil)
を二回書くと、コンストラクタ関数を作らないのにデフォルトのコンストラクタ関数と同名のシンボルが export される。
本家が archived になってしまった
そういう問題が見つかっているのなら報告すればいいじゃないという話になるのですが、いつの間にやら本家レポジトリが github 上で archived
になってしまいました。
こうなってしまうと、 Pull Request を投げたりすることも出来ないので、では自分で書くかという気になりました。
他の標準のリーダーマクロとの相性が悪い
@
リーダーマクロは、ANSI CL の標準のリーダーマクロと相性が悪い点があります。
quote
例えば、 '
(quote) を使ってみます。上記 @doc
を使ったコードを quote するには、以下のようになります:
'@doc "docstring" (defun foo () t)
一つの quote が、 (defun ...)
まで波及します。通常の構文だと @doc
だけを quote しているように見えますが、 @
が囲いのないリーダーマクロであることにより、見た目上三つの式を quote するように見えてしまいます。
この見た目の問題は、どんなリーダーマクロでも起こり得るのですが、 @
は他の標準リーダーマクロと違って2つ以上の後続の式に作用し、しかも )
のような終端を示す記号がないので、まるで複数の式に作用するかのうような奇妙さが増す結果になっています。
backquote
さらに問題となるのは backquote です。例として、上記 @doc
を使いつつ、関数名は外から与えることを考えてみます:
(let ((name 'foo)) `@doc "docstring" (defun ,name () t))
このコードは移植性がありません。 具体的には、SBCL では(なぜか)通りますが、 Allegro Common Lisp では「backquote の外で ,
(comma) を使ってはいけない」というエラーになります。
@
リーダーマクロはその中で read
を呼ぶのですが、上の使い方では backquote の中で呼ばれたリーダーマクロが、 (defun ,name () t)
を backquote から見て間接的に read
するという状況になります。
ANSI CL の backquote 構文 では、 backquote の中でリーダーマクロを経由して read
が再帰的に呼ばれた中に ,
があった場合の言及がないように見えます。この状況を引き起こす上のコードは処理系依存になっていると思われます。 *1
この状況を回避するには、 @doc
の実体である doc
を使い、リーダーマクロを使わない形式にすることが考えられます *2 :
(let ((name 'foo)) `(doc "docstring" (defun ,name () t)))
しかし、これだと @
リーダーマクロを使う意味はほとんどありません。
再実装方針
ところで、 @
リーダーマクロは何をやっているのでしょうか。 cl-annot のREADME にも述べられていますが、このマクロの展開は、以下のような式を:
@foo bar
以下に変換するものだそうです:
(foo bar)
つまり、 冒頭の @
によって末尾の )
を省略しているだけ と考えることができます。この省略された )
をどこに挿入するかは、 @
の次のシンボルによって定まります。( cl-annot:export
なら1, cl-annot.doc:doc
なら 2 となります。)
cl-annot-revisit は、この性質をもっと前面に押し出すべきと考えて実装しました:
@
リーダーマクロは、所定の個数の式をread
して( )
で包むことだけを行う。- 上で生成された展開結果は、通常のマクロとして振る舞い、 標準の
macroexpand
によって展開が行われる。
以上の2点に分離することで、不要な相互依存を排除しました。例えば以下のように記述すると*3:
@cl-annot-revisit:documentation "docstring" @cl-annot-revisit:export (defun foo () t)
以下のように展開されます:
(cl-annot-revisit:documentation "docstring" (cl-annot-revisit:export (defun foo () t)))
実際に docstring を埋め込んだり、シンボルを export
したりするのは、個々のオペレータの仕事となります。
@
リーダーマクロを介さずとも、 通常の ( )
で囲う記法を準備して通常のマクロとリーダーマクロとを分離しておけば、より汎用性が増して使いやすくなります。例えばマクロを書く場合、上記のように backquote との組み合わせを行うことが多く、その場合通常のマクロが必ず必要になります。
元の cl-annot では、 README で述べられているにも関わらず、残念ながら上記のように分離されていませんでした。 cl-annot のコードは、 @
リーダーマクロによって cl-annot.expand::%annotaion
という専用の展開器 を使う形式に展開されます。この展開器は macroexpand
と少しだけ違うことをする *4 ので、単純な置換にはなっていなかったでした。
一番の変更点は上の点ですが、他に以下の変更点があります:
- 上で述べた問題は cl-annot の問題はできるかぎり修正した。
- Dispatching Macro として実装した
#n@
マクロを追加。 Dispatching Macro の引数部分でread
する数を指定できる。 - cl-annot の
@ignore
などの宣言に展開されるマクロは、(declare ...)
を配置してよい場所でしか使用できない。これは README にも明示されておらず、例えばトップレベルで使用するとエラーになる。 cl-annot-revisit の宣言に展開されるマクロは、その引数の数で(declare ...)
に展開したり、 トップレベル用にlocally
で囲ってdeclare
を足すなど出し分け、融通をきかせている。 - リーダーマクロを用いる一般的な問題として、マクロ文字に指定した文字を含むシンボルの扱いが難しくなるという点があるが、 cl-annot-revisit では @ が含まれたシンボルが annotation として使うよう指定されてなければ通常のシンボルとして読み戻すので、他のシンボルの破壊が少ない。
inline
というオプションを使うと警告する。これは cl-annot にあったもので、read
時にマクロ展開することができるが、#.
(read
時に eval する)を@
の前につけることで代用できるため。
余談: nest
マクロ
ところで、上記の例を見ると、 @
リーダーマクロを用いることで二段階の ( )
のネストを平坦に記述することが出来ているように見えます。この見た目の変更をして @
リーダーマクロの有用性が解かれることもありますが・・ ネストを減らすだけなら通常のLispマクロでも出来ます。
uiop や serapeum に収録されている nest マクロを使うと、上の例を以下のように平坦に書けます:
(uiop:nest (cl-annot-revisit:documentation "docstring") (cl-annot-revisit:export) (defun foo () t))
この nest
マクロについては、 serapeum のドキュメント や、私が過去に書いた記事 をご覧ください。
まとめ
cl-annot を少しだけ整理した cl-annot-revisit を作りました。お暇なら覗いてみてください。
GitHub - y2q-actionman/cl-annot-revisit: re-implementation of cl-annot
後記
ただ・・冒頭に示した reddit には、以下のコメントが付けられています:
- リーダーマクロは、各種データ型の外部表現を読みこむのを実装するための機能である。
- (cl-annot の手法は) S式という構文の基礎を破壊するので勧められない。 (上述のように、 quote や backquote のような、単一のS式を前提としたリーダーマクロで問題になり得ます。)
私もその意見に賛同します。構文木を触るなら通常のマクロでよく、字句構造を触ることを企図しない限りはわざわざリーダーマクロを持ち出す必要はありません。そのため・・ cl-annot-revisit は 使わないことをお勧めします 。