動機
Lispといえばマクロというくらい、Lispとマクロは関連が深いです。
なのですが、自分は簡単なマクロなら自分で書けるものの、長いマクロや複雑なマクロをあまり書いたことがありません。
そろそろ次の1歩に進んでもいい頃合いかなと思うのですが、じゃぁどうするというところが思いつきません。
それなら公式の長めのマクロを読むことで色んなヒントを得ればいいじゃない、ということで読もうと思いました。
言語はClojureで、読むマクロはclojure.coreから長いものを適当に選ぶことにします。
長いマクロベスト4
- case
- defmulti
- fn
- doseq(for)
定量的に図ったわけではないですが、ほぼ間違いないと思います。おそらく、Clojureに触ったことがある人であれば、大体わかるのではないでしょうか。
コードリーディング
ソースコードはGit Hubより引用します。変更される可能性はあるので了承ください。
DocStringは長すぎるので省略します。
case
完成形
(defmacro case
{:added "1.2"}
[e & clauses]
(let [ge (with-meta (gensym) {:tag Object})
default (if (odd? (count clauses))
(last clauses)
`(throw (IllegalArgumentException. (str "No matching clause: " ~ge))))]
(if (> 2 (count clauses))
`(let [~ge ~e] ~default)
(let [pairs (partition 2 clauses)
assoc-test (fn assoc-test [m test expr]
(if (contains? m test)
(throw (IllegalArgumentException. (str "Duplicate case test constant: " test)))
(assoc m test expr)))
pairs (reduce1
(fn [m [test expr]]
(if (seq? test)
(reduce1 #(assoc-test %1 %2 expr) m test)
(assoc-test m test expr)))
{} pairs)
tests (keys pairs)
thens (vals pairs)
mode (cond
(every? #(and (integer? %) (<= Integer/MIN_VALUE % Integer/MAX_VALUE)) tests)
:ints
(every? keyword? tests)
:identity
:else :hashes)]
(condp = mode
:ints
(let [[shift mask imap switch-type] (prep-ints tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :int)))
:hashes
(let [[shift mask imap switch-type skip-check] (prep-hashes ge default tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :hash-equiv ~skip-check)))
:identity
(let [[shift mask imap switch-type skip-check] (prep-hashes ge default tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :hash-identity ~skip-check))))))))
使用例
とりあえずテストを見るとわかりやすいと思います。
; case
(deftest test-case
(testing "can match many kinds of things"
(let [two 2
test-fn
#(case %
1 :number
"foo" :string
\a :char
pow :symbol
:zap :keyword
(2 \b "bar") :one-of-many
[1 2] :sequential-thing
{:a 2} :map
{:r 2 :d 2} :droid
#{2 3 4 5} :set
[1 [[[2]]]] :deeply-nested
nil :nil
:default)]
(are [result input] (= result (test-fn input))
:number 1
:string "foo"
:char \a
:keyword :zap
:symbol 'pow
:one-of-many 2
:one-of-many \b
:one-of-many "bar"
:sequential-thing [1 2]
:sequential-thing (list 1 2)
:sequential-thing [1 two]
:map {:a 2}
:map {:a two}
:set #{2 3 4 5}
:set #{two 3 4 5}
:default #{2 3 4 5 6}
:droid {:r 2 :d 2}
:deeply-nested [1 [[[two]]]]
:nil nil
:default :anything-not-appearing-above)))...
いわゆる他の言語のswitch文みたいな感じです。
分析
メタ情報と引数
{:added "1.2"}
おそらく、追加したClojureのバージョンだと思います。あまり意識しないですが、Clojureのメタ情報の設定のしやすさはすごいですね。
[e & clauses]
eはexpression、clausesはそのままですね。&があるので、clausesは可変長引数ですね。
Clojureの変数名は基本的に短いので、意味をちゃんと確認しながら進むと後から響いてくることがあります。
また、Clojureは動的言語なので、使い方を見ながら何が入ってくるか想像しながら読まないと詰みます。
引数チェック
(let [ge (with-meta (gensym) {:tag Object})
default (if (odd? (count clauses))
(last clauses)
`(throw (IllegalArgumentException. (str "No matching clause: " ~ge))))]
geはgensymの短縮形でそこまで意味はなさそうですね。メタ情報でtagに型を設定しているのはパフォーマンス改善のため?でしょうか。
次はデフォルト値の設定です。最後のclauseはデフォルトに設定しています。
あとは、clausesの数が偶数でないときは例外を投げています。マクロは実行時の挙動が読みづらいのもあり、引数チェックがかなり厳しいほうがよいです。
※追記
コメントでご指摘いただきましたが、例外はクォートされているのでコンパイル時の例外ではなく、実行時の例外です。
実行時の例外をデフォルトに束縛しています。到達しない場合は例外を投げたくないので、実行時エラーを選んだのでしょうか。
※追記2
別のコメントで「もともとデフォルト値の設定有無を利用者側で選べるように設計されていて、デフォルト値を指定しない場合に想定した定数以外で実行時に例外になるのは利用者側の責任」という仕様で設計されているという指摘をいただきました。
マクロで例外を見るときはいつ、どこで実行されるように設計されているかをきちんと確認することが必要ですね。
clausesの解析
(if (> 2 (count clauses))
`(let [~ge ~e] ~default)
(let [pairs (partition 2 clauses)
assoc-test (fn assoc-test [m test expr]
(if (contains? m test)
(throw (IllegalArgumentException. (str "Duplicate case test constant: " test)))
(assoc m test expr)))
pairs (reduce1
(fn [m [test expr]]
(if (seq? test)
(reduce1 #(assoc-test %1 %2 expr) m test)
(assoc-test m test expr)))
{} pairs)
tests (keys pairs)
thens (vals pairs)
mode (cond
(every? #(and (integer? %) (<= Integer/MIN_VALUE % Integer/MAX_VALUE)) tests)
:ints
(every? keyword? tests)
:identity
:else :hashes)
最初はデフォルト値しかない場合の展開系を返しています。そうでない場合は、分解しています。
分解の手順としては、そのままですね。
- ペア(比較する値と返したい値)のシーケンスを作る(pairs)
- ペアをmapに変換する関数を定義する(assoc-test)
- すべてのペアをmap形式につぶす(pairs)
- test部とthen部に分けたシーケンスを作る(testsとthens)
- 条件部の特徴を判定する(mode)
条件部がシーケンスの場合は再帰的に処理しているところが読みづらいですが、1つ1つの処理が明示的に分かれているのでそんなに難しくないはずです。
個人的にはpairs変数を2回使っているのはどうかなと思うのですが、Twitterで聞いたところ違う束縛だからいいのではということでした。これを覚えると地味に便利ですね。
処理の委譲
(condp = mode
:ints
(let [[shift mask imap switch-type] (prep-ints tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :int)))
:hashes
(let [[shift mask imap switch-type skip-check] (prep-hashes ge default tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :hash-equiv ~skip-check)))
:identity
(let [[shift mask imap switch-type skip-check] (prep-hashes ge default tests thens)]
`(let [~ge ~e] (case* ~ge ~shift ~mask ~default ~imap ~switch-type :hash-identity ~skip-check))))))))
最後はmodeに応じてcase*やprep-initsなどに処理を投げています。
そこまでは追う気はないですが、ここら辺をみるとパフォーマンスを改善するためのマクロの使い方という面もあるんじゃないかとみています。
defmulti
完成形
(def global-hierarchy)
(defmacro defmulti
{:arglists '([name docstring? attr-map? dispatch-fn & options])
:added "1.0"}
[mm-name & options]
(let [docstring (if (string? (first options))
(first options)
nil)
options (if (string? (first options))
(next options)
options)
m (if (map? (first options))
(first options)
{})
options (if (map? (first options))
(next options)
options)
dispatch-fn (first options)
options (next options)
m (if docstring
(assoc m :doc docstring)
m)
m (if (meta mm-name)
(conj (meta mm-name) m)
m)
mm-name (with-meta mm-name m)]
(when (= (count options) 1)
(throw (Exception. "The syntax for defmulti has changed. Example: (defmulti name dispatch-fn :default dispatch-value)")))
(let [options (apply hash-map options)
default (get options :default :default)
hierarchy (get options :hierarchy #'global-hierarchy)]
(check-valid-options options :default :hierarchy)
`(let [v# (def ~mm-name)]
(when-not (and (.hasRoot v#) (instance? clojure.lang.MultiFn (deref v#)))
(def ~mm-name
(new clojure.lang.MultiFn ~(name mm-name) ~dispatch-fn ~default ~hierarchy)))))))
使用例
基本的にはオブジェクト指向の多態性を実現するためのものです。deriveを使って関係性を作って、そこから階層をたどっていってメソッドを呼び出したりします。
しかし、そこまで詳しく知らなくてもこのマクロは読めます。
相変わらず、テストより。
(deftest basic-multimethod-test
(testing "Check basic dispatch"
(defmulti too-simple identity)
(defmethod too-simple :a [x] :a)
(defmethod too-simple :b [x] :b)
(defmethod too-simple :default [x] :default)
(is (= :a (too-simple :a)))
(is (= :b (too-simple :b)))
(is (= :default (too-simple :c))))...
分析
メタ情報と引数
{:arglists '([name docstring? attr-map? dispatch-fn & options])
:added "1.0"}
[mm-name & options]
今回はメタ情報をバージョンだけではなく、引数のオプションを入れています。
なんで今回は入れているのかと思わないでもないですが、おそらくオプションが多い場合などは原因をすぐに見つけやすくするために入れていると推察されます。
mm-nameはおそらくmulti method nameの略でしょう。nameが入っているので、そこまで気にしなくても読めます。
引数の分解
(let [docstring (if (string? (first options))
(first options)
nil)
options (if (string? (first options))
(next options)
options)
m (if (map? (first options))
(first options)
{})
options (if (map? (first options))
(next options)
options)
dispatch-fn (first options)
options (next options)
m (if docstring
(assoc m :doc docstring)
m)
m (if (meta mm-name)
(conj (meta mm-name) m)
m)
mm-name (with-meta mm-name m)]
引数を分解して、なければデフォルト値を設定しているだけで特に難しいところはないですね。
しいて言えば、型チェックを地味にしているところと、m(map?)を使いまわしているところを気にするくらいでしょうか。
とはいえ、オプションを大量に設定してそれを分解する仕方は学んでおいて損はないかと思います。
動的な定義
(when (= (count options) 1)
(throw (Exception. "The syntax for defmulti has changed. Example: (defmulti name dispatch-fn :default dispatch-value)")))
(let [options (apply hash-map options)
default (get options :default :default)
hierarchy (get options :hierarchy #'global-hierarchy)]
(check-valid-options options :default :hierarchy)
`(let [v# (def ~mm-name)]
(when-not (and (.hasRoot v#) (instance? clojure.lang.MultiFn (deref v#)))
(def ~mm-name
(new clojure.lang.MultiFn ~(name mm-name) ~dispatch-fn ~default ~hierarchy)))))))
前半の設定とエラーチェックは見た通りとしか言えないです。
見るべきは後半の動的なdefの追加でしょうか。動的にdefを追加して処理を隠ぺいするというのはマクロの中では地味によく使うパターンなので、この形式に慣れておけば動的に関数なり、atomなりなんでも追加できます。
fn
完成形
(defmacro fn
{:added "1.0", :special-form true,
:forms '[(fn name? [params* ] exprs*) (fn name? ([params* ] exprs*)+)]}
[& sigs]
(let [name (if (symbol? (first sigs)) (first sigs) nil)
sigs (if name (next sigs) sigs)
sigs (if (vector? (first sigs))
(list sigs)
(if (seq? (first sigs))
sigs
;; Assume single arity syntax
(throw (IllegalArgumentException.
(if (seq sigs)
(str "Parameter declaration "
(first sigs)
" should be a vector")
(str "Parameter declaration missing"))))))
psig (fn* [sig]
;; Ensure correct type before destructuring sig
(when (not (seq? sig))
(throw (IllegalArgumentException.
(str "Invalid signature " sig
" should be a list"))))
(let [[params & body] sig
_ (when (not (vector? params))
(throw (IllegalArgumentException.
(if (seq? (first sigs))
(str "Parameter declaration " params
" should be a vector")
(str "Invalid signature " sig
" should be a list")))))
conds (when (and (next body) (map? (first body)))
(first body))
body (if conds (next body) body)
conds (or conds (meta params))
pre (:pre conds)
post (:post conds)
body (if post
`((let [~'% ~(if (< 1 (count body))
`(do ~@body)
(first body))]
~@(map (fn* [c] `(assert ~c)) post)
~'%))
body)
body (if pre
(concat (map (fn* [c] `(assert ~c)) pre)
body)
body)]
(maybe-destructured params body)))
new-sigs (map psig sigs)]
(with-meta
(if name
(list* 'fn* name new-sigs)
(cons 'fn* new-sigs))
(meta &form))))
使用例
関数オブジェクトを作成するマクロです。Clojureを使っていれば100%お世話になると言えます。
そもそもこのレベルの機能がマクロで提供されていること自体感動的です。
あんまりいい例がなかったので、適当に。
((fn [a b] (+ a b)) 1 2) ;3
((fn add [a b] (+ a b)) 1 2) ;3
分析
メタ情報と引数
{:added "1.0", :special-form true,
:forms '[(fn name? [params* ] exprs*) (fn name? ([params* ] exprs*)+)]}
[& sigs]
また新しいメタ情報が出てきました。とはいえ、先ほど出てきたようにやはりオプションが多いためではないかなと思います。
これを考えると、Clojureで困ったらAPIのメタ情報を叩くこと、必要ならメタ情報をきっちりと書き込むことが推奨されている感じがします。
引数はsigs(signatures)のシーケンスだけです。ある意味究極の動的さですね
引数の分解
(let [name (if (symbol? (first sigs)) (first sigs) nil)
sigs (if name (next sigs) sigs)
sigs (if (vector? (first sigs))
(list sigs)
(if (seq? (first sigs))
sigs
;; Assume single arity syntax
(throw (IllegalArgumentException.
(if (seq sigs)
(str "Parameter declaration "
(first sigs)
" should be a vector")
(str "Parameter declaration missing"))))))
長いですが、sigsを名前とそれ以外のシーケンスに分解している+エラーチェックなのでほぼ定形な気がします。そんなに深い意図はなさそうです。
sigはオーバーロードにつき1つあるので、複数形なんじゃないかなと思います。そうなると、sigは引数のベクターと実行フォームのペアでしょう。
分配束縛と表明
psig (fn* [sig]
;; Ensure correct type before destructuring sig
(when (not (seq? sig))
(throw (IllegalArgumentException.
(str "Invalid signature " sig
" should be a list"))))
(let [[params & body] sig
_ (when (not (vector? params))
(throw (IllegalArgumentException.
(if (seq? (first sigs))
(str "Parameter declaration " params
" should be a vector")
(str "Invalid signature " sig
" should be a list")))))
conds (when (and (next body) (map? (first body)))
(first body))
body (if conds (next body) body)
conds (or conds (meta params))
pre (:pre conds)
post (:post conds)
body (if post
`((let [~'% ~(if (< 1 (count body))
`(do ~@body)
(first body))]
~@(map (fn* [c] `(assert ~c)) post)
~'%))
body)
body (if pre
(concat (map (fn* [c] `(assert ~c)) pre)
body)
body)]
(maybe-destructured params body)))
new-sigs (map psig sigs)]
psigというとても大きな関数が出てきました。pがなにか難しいところですが、parameterでしょうか。だから何だといわれるとあまりピンときません。
paramsが分配束縛したいvarとフォームで、bodyが実行したいフォームでしょう。また、関数が事前条件と事後条件を設定できることを知っていれば、conds、pre、postが何を指しているかはなんとなくわかります。
それに分配束縛のメイン処理はmaybe-destructuredに投げているので、ここで学べるのは事前条件と事後条件をどうやってマクロで展開しているかくらいでしょうか。
それもそんなに難しくなくて、フォームの前後にassertを仕込んでいるようです。とはいえ、フォームの前後に好きな処理をはさむマクロというのは何かと役に立ちそうな気はします。
メタ情報をフォームに追加
(with-meta
(if name
(list* 'fn* name new-sigs)
(cons 'fn* new-sigs))
(meta &form))))
マクロをずっと眺めていて思いましたが、動的に関数を作ると必ずメタ情報を設定しているような気がします。
ここではフォームを作って、そこに現在のマクロのメタ情報を設定しているみたいです。&formとか、&envはそんなに使わないので正直感覚がよくわかりません。
doseq
完成形
(defmacro doseq
{:added "1.0"}
[seq-exprs & body]
(assert-args
(vector? seq-exprs) "a vector for its binding"
(even? (count seq-exprs)) "an even number of forms in binding vector")
(let [step (fn step [recform exprs]
(if-not exprs
[true `(do ~@body)]
(let [k (first exprs)
v (second exprs)]
(if (keyword? k)
(let [steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)]
(cond
(= k :let) [needrec `(let ~v ~subform)]
(= k :while) [false `(when ~v
~subform
~@(when needrec [recform]))]
(= k :when) [false `(if ~v
(do
~subform
~@(when needrec [recform]))
~recform)]))
(let [seq- (gensym "seq_")
chunk- (with-meta (gensym "chunk_")
{:tag 'clojure.lang.IChunk})
count- (gensym "count_")
i- (gensym "i_")
recform `(recur (next ~seq-) nil 0 0)
steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)
recform-chunk
`(recur ~seq- ~chunk- ~count- (unchecked-inc ~i-))
steppair-chunk (step recform-chunk (nnext exprs))
subform-chunk (steppair-chunk 1)]
[true
`(loop [~seq- (seq ~v), ~chunk- nil,
~count- 0, ~i- 0]
(if (< ~i- ~count-)
(let [~k (.nth ~chunk- ~i-)]
~subform-chunk
~@(when needrec [recform-chunk]))
(when-let [~seq- (seq ~seq-)]
(if (chunked-seq? ~seq-)
(let [c# (chunk-first ~seq-)]
(recur (chunk-rest ~seq-) c#
(int (count c#)) (int 0)))
(let [~k (first ~seq-)]
~subform
~@(when needrec [recform]))))))])))))]
(nth (step nil (seq seq-exprs)) 1)))
使用例
基本的には受け取ったシーケンスを順番に流すだけです。
なのですが、条件に応じてwhile、let、whenのような処理を挟み込むことができます。リスト内法表記に近い感じなのでしょうか。
テストをみると、forとまとめてテストされています。
こういうまとめて雑に処理されているのをみると、すごいと思うと同時にちょっと悲しくなるのは自分だけでしょうか。
※追記
docstringをみると、「doseqの機能はfor マクロに準じる」されているので合理的という指摘をいただきました。
ここは機能・仕様に対してテストするという視点が見えて、面白いところです。
自分は今まで関数、クラスごとにテストを書き、カバレッジに対してテストしていたので、とても意外でした。なので、このやり方に大雑把な印象を少し受けました。
(defmacro deftest-both [txt & ises]
`(do
(deftest ~(symbol (str "For-" txt)) ~@ises)
(deftest ~(symbol (str "Doseq-" txt))
~@(map (fn [[x-is [x-= [x-for binds body] value]]]
(when (and (= x-is 'is) (= x-= '=) (= x-for 'for))
`(is (= (let [acc# (atom [])]
(doseq ~binds (swap! acc# conj ~body))
@acc#)
~value))))
ises))))
(deftest-both When
(is (= (for [x (range 10) :when (odd? x)] x) '(1 3 5 7 9)))
(is (= (for [x (range 4) y (range 4) :when (odd? y)] [x y])
'([0 1] [0 3] [1 1] [1 3] [2 1] [2 3] [3 1] [3 3])))
(is (= (for [x (range 4) y (range 4) :when (odd? x)] [x y])
'([1 0] [1 1] [1 2] [1 3] [3 0] [3 1] [3 2] [3 3])))
(is (= (for [x (range 4) :when (odd? x) y (range 4)] [x y])
'([1 0] [1 1] [1 2] [1 3] [3 0] [3 1] [3 2] [3 3])))
(is (= (for [x (range 5) y (range 5) :when (< x y)] [x y])
'([0 1] [0 2] [0 3] [0 4] [1 2] [1 3] [1 4] [2 3] [2 4] [3 4]))))
分析
メタ情報と引数
{:added "1.0"}
[seq-exprs & body]
オプションがある割にメタ情報がないですね。シンボルを使っていたり、他のものとはオプションの意味合いが違うのかもしれません。
seq-exprsはsequance expressionsでしょう。
引数の検証
(assert-args
(vector? seq-exprs) "a vector for its binding"
(even? (count seq-exprs)) "an even number of forms in binding vector")
(defmacro ^{:private true} assert-args
[& pairs]
`(do (when-not ~(first pairs)
(throw (IllegalArgumentException.
(str (first ~'&form) " requires " ~(second pairs) " in " ~'*ns* ":" (:line (meta ~'&form))))))
~(let [more (nnext pairs)]
(when more
(list* `assert-args more)))))
これも先ほどと一緒です。assert-argsは地味に便利な関数だなぁとほんのり思います。
※追記
assertという名前から推測できるようにこれも実行時のエラーのフォームを投げる関数です。(ちなみに、assert-argsはprivate)
個人的にはこっちはコンパイル時例外のほうが適切な気がするのですが、気になった人は考えてみてください。
※追記2
コメント欄で指摘いただきましたが、assert-argsは確かにクォートされたフォームを返しますが、マクロの中で展開されるので実行時の例外でした。
クォートされたフォームはいつ、どこで展開されていつ実行されるかをきちんと押さえておかないと例外は難しいですね。
制御関数の定義
(let [step (fn step [recform exprs]
(if-not exprs
[true `(do ~@body)]
(let [k (first exprs)
v (second exprs)]
(if (keyword? k)
(let [steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)]
(cond
(= k :let) [needrec `(let ~v ~subform)]
(= k :while) [false `(when ~v
~subform
~@(when needrec [recform]))]
(= k :when) [false `(if ~v
(do
~subform
~@(when needrec [recform]))
~recform)]))
(let [seq- (gensym "seq_")
chunk- (with-meta (gensym "chunk_")
{:tag 'clojure.lang.IChunk})
count- (gensym "count_")
i- (gensym "i_")
recform `(recur (next ~seq-) nil 0 0)
steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)
recform-chunk
`(recur ~seq- ~chunk- ~count- (unchecked-inc ~i-))
steppair-chunk (step recform-chunk (nnext exprs))
subform-chunk (steppair-chunk 1)]
[true
`(loop [~seq- (seq ~v), ~chunk- nil,
~count- 0, ~i- 0]
(if (< ~i- ~count-)
(let [~k (.nth ~chunk- ~i-)]
~subform-chunk
~@(when needrec [recform-chunk]))
(when-let [~seq- (seq ~seq-)]
(if (chunked-seq? ~seq-)
(let [c# (chunk-first ~seq-)]
(recur (chunk-rest ~seq-) c#
(int (count c#)) (int 0)))
(let [~k (first ~seq-)]
~subform
~@(when needrec [recform]))))))])))))
長すぎて、イメージが全然つかめないですね。
こういう時は変数名を眺めながら、とりあえず全体像を把握していきましょう。
- recform
- たぶんrecursion form
- whileに引っ張られるが、whenにもあるので入れ子になったフォームの可能性が高い
- exprs
- expressions
- フォームのシーケンス
- 下でk(key)とv(value)に分解しているので、varとvalueのペアととらえるのが妥当
- steppair
- needrecとsubformに分解されるっぽい
- needrecはおそらくneed recursion
- 真偽値の可能性が高い
- subformは展開系の中に突っ込まれているので、おそらく実行したいフォームでは
- needrecはおそらくneed recursion
- needrecとsubformに分解されるっぽい
- 後半のchunk系
- 名前が大体一緒なので、型が違うだけで役割はたぶん一緒のはず
ここまで把握できるとだいぶ見通しがよくなります。
前半はキーワードの処理をマクロに展開しているようです。
whileとかletは確かに自分で組んでみると確かにそうなるという自然な展開なので、わかりやすいです。
そうなると後半はキーワード以外の処理になります。
ここも辛抱強く見ていくと、単にfor文をloopとrecurでエミュレートしているだけで、関数型になれていれば普通です。
なんでchunkを使っているかは正直知らないのですが、数値処理とかはchunk単位で処理すると早いとかじゃないかという気はします。coreのマクロはchunkを使っているところは何か所かありました。
再帰を書く処理を再帰で書いているので逃げたくなる気持ちはありますが、全体像を把握して分解していけば何とか戦えます。
展開
(nth (step nil (seq seq-exprs)) 1)))
再帰関数を読んでいるだけですね。stepは[needrec form]のシーケンスを返すので、formだけを返しています。
全体的にデータ構造や入出力を把握しないと、この簡単な1文すら読めないです。
まとめ
読むときのポイント
ここまで読んできて長いマクロを読むコツがなんとなくわかってきたかと思います。
まず、使用例をみないと全くわかりません。マクロだけ見て、展開系を予測するというのはできなくはないですが、はっきり言ってやめておいたほうがいいです。
オプションやシグネチャ、形あたりを見ながらそれから、分析に取り掛かりましょう。
個人的にはテストがあればそれを見ながらやるのが網羅的でとても楽でした。
あとは細かいテクニックとして、変数名の正規化、問題の分割あたりをちゃんとできれば、長くても読めると思います。
そして、困ったら最終手段として、テストを書きながら、macroexpandするというところですね。
感想
途中で思ったんですけど、長いマクロを読む力と、長いマクロを書く力って全然違いますよね。
Clojureの規則みたいなのはつかめましたが、道はまだ遠いですね。
おまけ
たぶん最短のマクロはcommentです。
(defmacro comment
"Ignores body, yields nil"
{:added "1.0"}
[& body])
弁明
よくみると、clojure.coreは同じ名前空間でファイルが分割されていて、別のファイルにproxyという長いマクロがいました。
いつかはわかりませんが、いずれこれに関しても追記したいです。