y2q_actionman’s ゴミクズチラ裏

内向きのメモ書きを置いてます

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 に使ってはいけないのだが、 @declarationdeclare の内部に展開してしまい、結果として規格違反のコードが生成される。
  • @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マクロでも出来ます。

uiopserapeum に収録されている 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-revisit は 使わないことをお勧めします

*1:SBCL はこの辺をがんばって実装しているのでしょうか・・

*2:もちろん defun の標準の構文を使うのが王道

*3:README に記述した例

*4: annotation となるシンボル以外を先にmacroexpandする (code)。これにより、 README に例示される @リーダーマクロのネストを実現しているが、一方で macroexpand される前の式に annotation から触れる手段はない。