Clojure - Macro入門

教科書:

Clojure in Action

Clojure in Action

マクロ ?

Clojureランタイムの動き

  ソースコード ------> [Read] -------> [Evaluate]

まず、リーダーがソースコードClojureのデータ構造に変換してから、評価されてプログラムが実行される。


マクロは評価される前のデータ構造上で振る舞いを定義することができる関数で、評価される前にプログラムからコードを操作することができる。

  ソースコード ------> [Read] --- <macro> ---> [Evaluate]


これができると、Clojureに新しい機能を追加することができたりするということ。

unlessマクロ

unless、というifフォームと逆のことを行うマクロを書いてみる。
unlessマクロは、マクロを説明する時の"Hello, World"みたいなものらしい。


まず、関数でunlessを作ってみる。

user=> (defn unless [test then]
         (if (not test) then))
#'user/unless

user=> (defn exhibits-oddity? [x]
         (unless (even? x)
           (println "Odd!")))
#'user/exhibits-oddity?

user=> (exhibits-oddity? 3)
Odd!
nil

user=> (exhibits-oddity? 4)
Odd!
nil

なぜか偶数を与えてもthenの式が評価されている。


これは、unlessが関数だからで関数が以下のルールで実行されることが原因。

  • 関数呼び出しのフォームに渡されるすべての引数を評価する
  • 引数の値を使用して関数を評価する

unless関数に渡されるtest/thenという式は、関数本体のifフォームが始まる前に評価されてしまうのでprintlnみたいなことをやると引数として渡った時点でそれが評価されてしまう。

この解決策としてはunless関数のthenを関数呼び出しにする方法がある。

(defn unless [test then-thunk]
  (if (not test)
    (then-thunk)))

(defn exhibits-oddity? [x]
  (unless (even? x)
    #(println "Rather odd!")))

user=> (exhibits-oddity? 3)
Rather odd!
nil

user=> (exhibits-oddity? 4)
nil

これだと、一応は動くがunlessを使う関数側で関数を用意しないといけなくなってしまうのでスマートな解ではない。

unlessをマクロにしてみる

マクロの定義はdefmacroを使う。unlessフォームにはifフォームを使うがthenは必要になるまで評価されてほしくないので(if (not test) then)というS式を生成する様にしてみる。

(defmacro unless [test then]
  (list 'if (list 'not test)
    then)))

(defn exhibits-oddity? [x]
  (unless (even? x)
    (println "Odd!")))

user=> (exhibits-oddity? 3)
Odd!
nil
user=> (exhibits-oddity? 4)
nil


unlessがS式(if (not test) then)に展開される様にしたのでこれは期待通りに動作する。マクロがどのように展開されるのかはmacroexpand関数で確認することができる。

user=> (macroexpand '(unless (even? x) (println "Odd!")))
(if (not (even? x)) (println "Odd!"))

このunlessマクロが展開されたコードがunlessフォームになるので、この後に評価され値を返す様になる。

マクロテンプレート

unlessマクロを作ってみたのはいいけど、いちいちリストを作ったりクオートするのは面倒くさいし、読みにくいのでマクロテンプレートというものを使ってみる。

(defmacro unless [test then]
  `(if (not ~test)
     ~then))

バッククオートとチルダが入っているが、それ以外はシンプルになっている。


バッククオート(`)はテンプレートの開始を表す記号で、S式に展開されてマクロの返り値になる。テンプレート内のチルダがついたシンボルは展開されずにそのまま返される様になるので、マクロに渡された引数の様に変化する必要があるシンボルにつける。

バッククオートでテンプレートにして、テンプレート内のチルダでクオートを無効にする感じ。

user=> (macroexpand `(unless (even? x) (println "Odd!")))
(if (clojure.core/not (clojure.core/even? user/x))
  (clojure.core/println "Odd!"))

;; thenの"~"を忘れた場合
user=> (macroexpand `(unless (even? x) (println "Odd!")))
(if (clojure.core/not (clojure.core/even? user/x))
  user/then)


現状のunlessマクロはthenに1つの式しか受け取れないので

(defn exhibits-oddity? [x]
  (unless (even? x)
    (println "odd!")
    (println "odd!!")))

はうまくいかない。unlessマクロを使う側でdoフォームを使えばとりあえず対応はできる。

(defn exhibits-oddity? [x]
  (unless (even? x)
    (do
      (println "odd!")
      (println "odd!!"))))

user=> (exhibits-oddity? 3)
odd!
odd!!
nil

ただし、これだと、unlessを使う度にthenをdoで囲む必要が出てくるのでunlessマクロで複数のthenを受け取る様にしてみる。

(defmacro unless [test & exprs]
  `(if (not ~test)
     (do ~exprs)))

user=> (exhibits-oddity? 3)
odd
odd!!
java.lang.NullPointerException (NO_SOURCE_FILE:0)

例外がでる...マクロと展開してみると

user=> (macroexpand '(unless (even? x) (println "Odd!") (println "Odd!!")))
(if (clojure.core/not (even? x))
  (do
    ((println "Odd!")
     (println "Odd!!"))))

なぜかdoフォームの中に余計な括弧が付いていて、println関数の返り値はnilなのでthenが(nil nil)になってしまっているらしい。


unlessに渡される引数(& exprs)はリストとして渡されるので、これをリストではなく個々の要素として渡す様にすれば良い。ここで"~@(アンクオートスプライスリーダマクロ)"を使う。リストを取ってアンクオートする代わりにリストの要素を分割してくれる。

(defmacro unless [test & exprs]
  `(if (not ~test)
     (do ~@exprs)))

user=> (macroexpand-1 '(unless (even? x)
                         (println "Odd!")
                         (println "Odd!!")))
(if (clojure.core/not (even? x))
  (do
    (println "Odd!")
    (println "Odd!!")))

user=>
(defn exhibits-oddity? [x]
  (unless (even? x)
    (println "Odd!")
    (println "Odd!!")))
#'user/exhibits-oddity?

user=> (exhibits-oddity? 3)
Odd!
Odd!!
nil

user=> (exhibits-oddity? 4)
nil

マクロ内で名前を作る

関数が呼び出されたログを出力するマクロを作ってみる。

(defmacro def-logged-fn [fn-name args & body]
  `(defn ~fn-name ~args
    (let [now (System/currentTimeMillis)]
      (println "[" now "] Call to" (str (var ~fn-name)))
      ~@body)))

引数に関数を渡すと、実行する前にログを出力する様な関数を作るマクロを作りたいが、これを使おうとすると例外が発生してしまう。

user=> (def-logged-fn
         printname [name]
          (println "hi " name))
java.lang.Exception: Can't let qualified name: user/now (NO_SOURCE_FILE:60)

user=> (macroexpand-1 '(def-logged-fn
                         printname [name]
                          (println "hi " name)))
(clojure.core/defn printname [name]
  (clojure.core/let [user/now (java.lang.System/currentTimeMillis)]
    (clojure.core/println "[" user/now "] Call to"
      (clojure.core/str (var printname)))
    (println "hi " name)))

letでバインドしたnowがなぜかuser/nowに解決されている。バッククートはシンボルを名前空間で修飾された名前に解決してしまい、ここでは、letフォーム内では修飾された名前は使用できないので例外が発生している。

この場合、リーダマクロ"#"を使うと、マクロに渡されたコード内で使用される他の名前と競合しないユニークな名前がついたシンボル生成することができる(auto-gensym)。

user=> `foo#
foo__980__auto__
user=> `foo#
foo__983__auto__

これを使うとdef-logged-fnマクロは以下の様に書き直せる。

(defmacro def-logged-fn [fn-name args & body]
  `(defn ~fn-name ~args
    (let [now# (System/currentTimeMillis)]
      (println "[" now# "] Call to" (str (var ~fn-name)))
      ~@body)))

user=> (def-logged-fn printname [name] (println "hi " name))
#'user/printname

user=> (printname "daigo3")
[ 1288509101749 ] Call to #'user/printname
hi  daigo3
nil