紙箱

覚えたことをため込んでいく

スレッドマクロを整理する

この記事はもともとTumblrに書いていた自分のブログ記事を転載したものです。投稿日時も当時の投稿日時を再現してあります。

スレッドマクロって?

スレッドマクロ(threading macro)は、Clojureのソースを人間に読みやすい形で書けるマクロで、現在のClojure 1.5.1には、結構な数のが用意されています。1.5で初めて追加されたものもありますし、まとめておくと、今後Clojureを始める人にも役立つかもしれないなあってことで、ブログ記事に書いておくことにしました。1.5で導入された新しいスレッドマクロも含めて既に知っている人には役に立たないのであしからず。

「スレッド」マクロといっても、並列プログラミングのスレッドとはまったく関係がないです。Clojureの -> や ->> のような、矢印系マクロの総称として使われています。Clojure 1.5.1では、次のスレッドマクロがあります。

  • ->
  • ->>
  • as->
  • some->
  • some->>
  • cond->
  • cond->>

基本は -> と ->> で、他のものは、この基本スレッドマクロの便利版と思ってよいです。

スレッドマクロの役割は、関数ベースのClojureでは「実行する順」に書けない、もしくは書いても冗長になるコードを、簡便に、「実行する順」に書けるようにすることです。「AしてBしてCする」みたいな処理を簡単に書けるようにするわけです。

(func3 (func2 (func1 :arg) :arg) :arg)

これを、

(-> (func1 :arg)
    (func2 :arg)
    (func3 :arg))

と書けるようにします。

-> (thread-first) マクロ

-> はその性質から「thread-first macro」と呼ばれるようです。->マクロは、初期値と、スレッディング対象となる式を受け取ります。実物を見た方がわかりやすいです。

(-> {:type :person
     :age  30}
  (assoc :name "John Doe")
  (assoc :city "tokyo")
  (dissoc :age))

この例では、初期値は {:type :person, :age 30} というマップです。この初期値が、次のフォームである (assoc :name "John Doe") の第1引数の位置に挿入されます。
続けてこの (assoc 初期値 :name "John Doe") が、2つ目のフォームである (assoc :city "tokyo") の第1引数になります。さらにこの assoc が、最後のフォームである (dissoc :age) の第1引数になるのです。

assoc を2回呼ぶなら可変長引数でひとつにまとめろ、という話はちょっと脇へ置いておいてください)

式が挿入される位置を x で表現してみると、次のように、

(assoc   x :name "John Doe")
(assoc   x :city "tokyo")
(dissoc  x :age)

式が挿入される位置に書いた x が、各フォームを貫いているように見えます。
このように、前の引数を次のフォームの同じ位置で使う場合に、読みやすいコードを書けるわけです。

もしスレッドマクロがなければ、前述のコードは、次のような関数の入れ子として書かなければいけません。

(dissoc (assoc (assoc {:type :person, :age 30} :name "John Doe") :city "tokyo") :age)

とても読みにくいです。無理矢理でも実行順に書こうとすると、let を使って各結果値をシンボルに束縛しなければならないでしょう。

(let [init {:type :person, :age 30}
      x (assoc init :name "John Doe")
      y (assoc x :city "tokyo")]
  (dissoc y :age))

let の各シンボルは使い捨てみたいで、なんだか疲れます。スレッドマクロであれば、直前の結果を次の式ですぐに使う、というコードを、簡潔に記述できます。

-> はスレッドマクロの基本であり、ほかのものは、「-> とどこが違うのか」で説明できます。

->> (thread-last) マクロ

->>マクロと->マクロの違いは、引数が入る位置が、第1引数ではなく、最終引数の位置になる、という点だけです。Clojureの関数の多くは、関数の操作対象を第1引数で受け取るように作られているのですが、第1引数として関数を取る関数の多くは、操作対象を最終引数で受け取ります。map, filter, distinctといった、コレクション操作系の関数のほとんどが、そのような作りになっています。

->>マクロを使えば、コレクションを filter して map して distinct する、といったコードを綺麗に記述できます。

(->> (my-great-func-returns-coll)
     (filter #(not (nil? %))) ;nilをはぶく
     (map :name)              ;:nameだけのリストにする
     (distinct))              ;重複をのぞいたリストを作る

->>マクロもスレッドマクロの基礎の一つで、このマクロがあるので、他のスレッドマクロの矢印の>が2つある場合は、フォームの最後に直前の式が挿入されるのだな、という暗黙の了解ができています。

as->マクロ

as->マクロはちょっと特殊で、->マクロの不便なところを補う役割があります。

->では、式が挿入される位置が「第1引数の位置」に固定されています。しかし、時には、式を別の位置に挿入したいこともあるはずです。そういう場合は、次のような、ちょっとアクロバティックなコードを書く必要がありました。

(-> {}
  (assoc :type ::book)
  (#(my-great-func arg1 % arg3))
  (dissoc :result))

引数をひとつ受け取る匿名関数を生成して、それを呼び出すためにさらにカッコで囲うわけです。すると、その匿名関数の第1引数となる位置に、直前の式が挿入されるのです。

また、スレッドマクロの途中には関数しかおけないので、途中にログを出力する処理を挟みたいと思っても、ログを出力して引数をそのまま返すような関数を定義しなければいけません。簡単にかきたいからスレッドマクロを使っているわけで、ちょっと本末転倒なところがあります。

つまり、処理中の値(直前の式の結果値)を補足できればいいわけです。それを行うのが、as-> マクロの役割です。このような用途であるため、as->マクロは、スレッドマクロの内側で使うことを前提にしています。もちろん、自分で第1引数に値を渡せばスレッドマクロ外でも動きますが、そうしても特に役立ちませんし。
さらに、as->マクロは->系統のスレッドマクロ (->, some->, cond->) でしか使えません(->>系統のマクロでは使えません。as->>というマクロはありません)。

(-> {:result "test"}
  (assoc :type ::book)
  (as-> x (my-great-func1 :a x :b)
          (my-great-func2 x))
  (dissoc :result))

as-> x によって、直前の結果値を x に束縛できます。あとは、シンボル x を使って、my-great-funcの好きな位置に、直前の結果値を挿入できます 。さらに、my-great-func1の実行結果は再び x に束縛されます。my-great-func2に渡される x は、(assoc :type ::book)の結果ではなく、my-great-func1 の結果になります。

as->を使うと、スレッディングの途中の値を、ログ出力することも出来ます。

(-> {}
    (assoc :test "1")
    (as-> x (do (println (str "x = " x)) x))
    (assoc :test2 "2"))

この例では、(assoc :test "1")の結果を x に束縛し、ログ出力したあと、そのまま x を返しています。(assoc :test "1") の結果はそのまま (assoc :test2 "2") に渡ります。スレッドマクロの途中に、手軽にログ出力を挿入したわけです。

as-> が導入されたおかげで、アクロバティックなコードを書く必要がなくなりました。

some->マクロ、some->>マクロ

some-> マクロは、-> マクロのシンプルな拡張です。同様に、some->> マクロは ->> マクロの拡張です。
スレッディング処理を実行中に、途中で結果が nil になることがあります。nilになったらもう後続の処理は実行する意味がないとか(Clojureの多くの関数は nil を渡すと単に nil を返しますが、確実ではないし、無駄でもあります)、後続にJavaのメソッドをコールする部分があって、nil を渡すと NullPointerException になってしまう、ということはあり得ます。

some-> は、スレッディング途中に結果が nil になったら、そこで処理を打ち切って nil を返却します。

(defn die [v]
  (when (nil? v) (throw (NullPointerException.))))

(some-> [:a :b :c]
  next
  next
  next
  die)

ベクタ[:a :b :c]に対して、「先頭要素を省いたシーケンスまたはnilを返す」という動作をするnext関数を3回使うと、結果はnilになります。->マクロであれば、最後にnildie関数に渡され、NullPointerExceptionがスローされます。
some->であれば、3回目のnextで結果がnilになった段階でnilを返すので、dieは実行されず、例外はスローされません。

実開発では、意外に使い勝手のいいスレッドマクロです。

cond->マクロ、cond->>マクロ

cond->cond->> の違いも、some->, some->> と同じく、引数の挿入位置だけです。それぞれ->あるいは->>と同じ場所に式を挿入します。

cond->は、条件付きスレッドマクロです。次のように、条件式と、実行式のペアを記述していきます。

(let [v {:age 30, :place :japan}]
  (cond-> v
    (>= (:age v) 20)      (assoc :adult true)
    (= :us (:place v))    (update-in [:recommended] conj "Bank of America")
    (= :japan (:place v)) (update-in [:recommended] conj "MUFG")))

;;結果→ {:age 30, :place :japan, :adult true, :recommended ["MUFG"]}

cond->は、初期値以降に、まず条件式を、次にその条件が真だった場合に実行する式を書きます。かならず2つをペアで書かなければなりません。
直前の式は、右側の式の第1引数の位置に挿入されます。左側(条件式)には挿入されないので注意が必要です。

cond式と異なり、cond->では、全ての条件式が評価され、真であれば、右の式を評価します。このとき、右の式に対してスレッディング処理が行われます(第1引数もしくは最終引数の位置に、直前の式が挿入される)。条件式が真でない場合は、対となる右側の式はスキップされ、次の条件式が評価されます。

前述の例なら、(= :us (:place v)は真ではないので、(update-in [:recommended] conj "Bank of America")は実行されず、最終結果の:recommendedにも"Bank of America"は入っていません。

スレッドマクロを使いたいのだけど、スレッディング内の式の一部だけを条件付きで実行したいときに便利です。意外と、そういうことは多くあります。特にcond->>マクロであれば、コレクション操作を条件付きで実行できるので便利です(特定のパラメータがオンのときだけ、最後にソートを実行する、など)。

まとめ

スレッドマクロは、関数スタイルで書くととても読みにくくなるコードを、劇的に読みやすくしてくれる便利なマクロで、人気も高いようです。標準のマクロ以外にも、オープンソースで公開されている独自マクロもあったりします。やはり「AしてBしてC」という処理は、同じ順序に書いたほうが分かりやすいですからね。中には、かなり実用的なものもあります。ここにある、Diamond Wandなんかはかなり使えます。

ただ、便利だからといってたくさん導入すると、コードが矢印だらけになって、読むのが大変になるかもしれません。Clojureに標準で存在するスレッドマクロを基本にして、ほかのものはチームと相談しつつ導入、というほうがよいかもしれないですね。

->->>はClojureの初期から存在したので知っていても、cond->some->, as->は知らない、ということもあるかと思います。とても便利なので知っておいて損はないでしょう。