Clojurianでラブライブ!ファン(海未🏹&曜⛵推し)のlagénorhynque (a.k.a. カマイルカ)です。
今年9月に開催された勉強会「市ヶ谷Geek★Night#14 市ヶ谷java 〜JVM言語の玉手箱〜」(通称「市ヶ谷clojure」)でSpectacular Future with clojure.specと題してclojure.specの基本を紹介する発表をしましたが、そういえばマクロでの利用には触れていなかったので本記事ではclojure.specとマクロの関係についてご紹介します。
clojure.specとマクロ
clojure.specでは、述語(predicate)の組み合わせでデータに対する仕様を記述することができ、それを関数の引数や戻り値に対して適用すれば関数の事前条件、事後条件のチェックが可能になり、ある種の契約プログラミングが実現できる。
一般的な静的言語の型チェックとは異なり、基本的に実行時にデータが仕様を満たしているかチェックする仕組みであるため、実行時の値に対する柔軟なチェックが可能というメリットがある一方で、実行時のチェックというオーバーヘッドがある(そのため、assertionなどと同様に開発時のみ有効化する使われ方が多いと思われる)。
しかし、マクロはコンパイル時に実行される特殊な関数であるため、実行時のオーバーヘッドなくclojure.specで入力データ(データとしてのコード)に対してチェックを行うことができる(そのため、関数に対するものとは異なり、マクロに対するspecのチェックは直ちに有効化される)。
実例から学ぶ: if-let
マクロ
例えば、 clojure.core/if-let
マクロは以下のように定義されており、
;; https://github.com/clojure/clojure/blob/clojure-1.9.0-RC2/src/clj/clojure/core.clj#L1833-L1851
(defmacro if-let
"bindings => binding-form test
If test is true, evaluates then with binding-form bound to the value of
test, if not, yields else"
{:added "1.0"}
([bindings then]
`(if-let ~bindings ~then nil))
([bindings then else & oldform]
(assert-args
(vector? bindings) "a vector for its binding"
(nil? oldform) "1 or 2 forms after binding vector"
(= 2 (count bindings)) "exactly 2 forms in binding vector")
(let [form (bindings 0) tst (bindings 1)]
`(let [temp# ~tst]
(if temp#
(let [~form temp#]
~then)
~else)))))
これに対するspecはclojure/core.specs.alphaに以下のように定義されている。
;; https://github.com/clojure/core.specs.alpha/blob/core.specs.alpha-0.1.24/src/main/clojure/clojure/core/specs/alpha.clj#L57-L60
(s/fdef clojure.core/if-let
:args (s/cat :bindings (s/and vector? ::binding)
:then any?
:else (s/? any?)))
つまり、 if-let
マクロの引数がそれぞれ
-
:bindings
: ベクターの束縛フォーム(::binding
) -
:then
: 任意の式 -
:else
: 省略可能な任意の式
という仕様を満たすように制約が付加されている。
Clojure 1.8で例えば次のように if-let
の使い方を盛大に間違えたとすると、
;; Clojure 1.8.0
user> (if-let [0 1] 2 3)
CompilerException java.lang.Exception: Unsupported binding form: 0, compiling:(/private/var/folders/j8/yfkf8mt564b1lq0qx06bq9cw0000gn/T/form-init18058313592079046392.clj:1:1)
「0はサポートされていない束縛フォーム」だというかなり素っ気ないコンパイルエラーになる(if-let
の実装で対応できるassertionがないためさらに先でエラーになる)ところ、clojure.specが(まだalpha版とはいえ)導入されたClojure 1.9では、
;; Clojure 1.9.0-RC2
user> (if-let [0 1] 2 3)
CompilerException clojure.lang.ExceptionInfo: Call to clojure.core/if-let did not conform to spec:
In: [0 0] val: 0 fails spec: :clojure.core.specs.alpha/local-name at: [:args :bindings :binding :sym] predicate: simple-symbol?
In: [0 0] val: 0 fails spec: :clojure.core.specs.alpha/seq-binding-form at: [:args :bindings :binding :seq] predicate: vector?
In: [0 0] val: 0 fails spec: :clojure.core.specs.alpha/map-bindings at: [:args :bindings :binding :map] predicate: coll?
In: [0 0] val: 0 fails spec: :clojure.core.specs.alpha/map-special-binding at: [:args :bindings :binding :map] predicate: map?
#:clojure.spec.alpha{:problems ({:path [:args :bindings :binding :sym], :pred clojure.core/simple-symbol?, :val 0, :via [:clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/local-name], :in [0 0]} {:path [:args :bindings :binding :seq], :pred clojure.core/vector?, :val 0, :via [:clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/seq-binding-form], :in [0 0]} {:path [:args :bindings :binding :map], :pred clojure.core/coll?, :val 0, :via [:clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/map-binding-form :clojure.core.specs.alpha/map-bindings], :in [0 0]} {:path [:args :bindings :binding :map], :pred map?, :val 0, :via [:clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/binding-form :clojure.core.specs.alpha/map-binding-form :clojure.core.specs.alpha/map-special-binding], :in [0 0]}), :spec #object[clojure.spec.alpha$regex_spec_impl$reify__2436 0x3c9f1e55 "clojure.spec.alpha$regex_spec_impl$reify__2436@3c9f1e55"], :value ([0 1] 2 3), :args ([0 1] 2 3)}, compiling:(/private/var/folders/j8/yfkf8mt564b1lq0qx06bq9cw0000gn/T/form-init837778551915099089.clj:1:1)
という非常に詳細な(詳細すぎて逆に分かりづらいくらいの)specのチェックエラーを報告してくれるようになる。
ここでは、第1引数の第1要素([0 0]
)の値 0
が束縛フォーム(:bindings
)の束縛部分(:binding
)としての要件(シンボル(:sym
)かシーケンス(:seq
)かマップ(:map
))を満たしていないと指摘される。
;; 使われているspecの定義を調べる
user> (s/describe :clojure.core.specs.alpha/bindings)
(and vector? (* :clojure.core.specs.alpha/binding))
user> (s/describe :clojure.core.specs.alpha/binding)
(cat :binding :clojure.core.specs.alpha/binding-form :init-expr any?)
user> (s/describe :clojure.core.specs.alpha/binding-form)
(or :sym :clojure.core.specs.alpha/local-name :seq :clojure.core.specs.alpha/seq-binding-form :map :clojure.core.specs.alpha/map-binding-form)
このように、specを利用することでマクロの実装と引数に対するチェックを分離し、再利用可能な形で詳細なチェックを適用することができる。
実践してみる: Common Lisp/Scheme風 cond
マクロ
次は実際に独自のマクロを書き、その引数に対するspecを定義してみる。
題材として、Common LispやSchemeにおける(Clojureのものとは少し異なる) cond
を実装してみることにする。
Clojureの cond
Clojure標準ライブラリのマクロ clojure.core/cond
は、引数として条件式と結果式を交互に並べて最初にtruthyになった条件式に対応する結果式を評価する。
;; Clojure
;; 簡単な例として、フィボナッチ数を計算する関数(とても非効率)
user> (defn fib [n]
(cond
(= n 0) 0
(= n 1) 1
:else (+ (fib (- n 1))
(fib (- n 2)))))
#'user/fib
user> (map fib (range 10))
(0 1 1 2 3 5 8 13 21 34)
Common Lisp/Schemeの cond
一方、Common LispやSchemeの cond
は、条件式と結果式のグループごとに ( )
で囲み、結果式は1個以上の式を並べられるようになっている(暗黙のprogn)。
;; Common Lisp (SBCL)
(defun fib (n)
(cond ((= n 0) 0)
((= n 1) 1)
(t (+ (fib (- n 1))
(fib (- n 2))))))
FIB
* (mapcar #'fib (loop for i from 0 below 10 collect i))
(0 1 1 2 3 5 8 13 21 34)
;; Scheme (Gauche)
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))
fib
gosh> (map fib (iota 10))
(0 1 1 2 3 5 8 13 21 34)
Clojureの cond
は括弧の利用が最小限になるようにという設計思想の結果だと思われるが、見た目の簡潔さと引き換えに
- (特に式が大きくなってくると)条件式と結果式の対応関係が見た目に分かりづらい
- 条件式と結果式のグループ単位で編集しづらい
- 結果式を複数並べたい場合に
do
などが必要になる
といった欠点もある。
ClojureでCommon Lisp/Scheme風の cond
そこで、ClojureでCommon Lisp/Scheme風の cond
(名付けて kond
)を実装してみる。
まず、 kond
マクロが期待する引数のspecを定義する。
;; Clojure 1.9.0-RC2
user> (require '[clojure.spec.alpha :as s])
nil
user> (s/fdef kond
:args (s/cat :clauses (s/* (s/spec (s/cat :test any?
:exprs (s/+ any?))))))
user/kond
つまり、kond
マクロの引数 :clauses
は0個以上の次のようなシーケンスの繰り返しとする。
-
:test
: 任意の式(条件式) -
:exprs
: 1個以上の任意の式(結果式)
このような引数を取るマクロとして kond
マクロを実装すると、
user> (defmacro kond [& clauses]
(when-let [[[test & exprs] & cls] clauses]
`(if ~test
(do ~@exprs)
(kond ~@cls))))
#'user/kond
このようにCommon Lisp/Schemeとよく似た形で条件を表現することができる。
user> (defn fib' [n]
(kond [(= n 0) 0]
[(= n 1) 1]
[:else (+ (fib' (- n 1))
(fib' (- n 2)))]))
#'user/fib'
user> (map fib' (range 10))
(0 1 1 2 3 5 8 13 21 34)
↑ではClojureらしく(?)グループをまとめるのに [ ]
を利用したが、ここでは特にベクターに制限していないため、よりCommon Lisp/Schemeらしく ( )
で囲む↓のような書き方もできる。
user> (defn fib'' [n]
(kond ((= n 0) 0)
((= n 1) 1)
(:else (+ (fib'' (- n 1))
(fib'' (- n 2))))))
#'user/fib''
user> (map fib'' (range 10))
(0 1 1 2 3 5 8 13 21 34)
また、結果式が暗黙のdoになっている(複数の式をそのまま並べられる)ので、以下のような使い方もできる。
user> (defn fib''' [n]
(kond ((= n 0) (println "branch 1")
0)
((= n 1) (println "branch 2")
1)
(:else (println "branch 3")
(+ (fib''' (- n 1))
(fib''' (- n 2))))))
#'user/fib'''
user> (map fib''' (range 10))
branch 1
branch 2
branch 3
;; 中略
branch 2
branch 1
branch 2
(0 1 1 2 3 5 8 13 21 34)
そして、例えばあるグループの結果式を書き忘れると、
user> (defn fib'' [n]
(kond ((= n 0) 0)
((= n 1))
(:else (+ (fib'' (- n 1))
(fib'' (- n 2))))))
CompilerException clojure.lang.ExceptionInfo: Call to user/kond did not conform to spec:
In: [1] val: () fails at: [:args :clauses :exprs] predicate: any?, Insufficient input
#:clojure.spec.alpha{:problems [{:path [:args :clauses :exprs], :reason "Insufficient input", :pred clojure.core/any?, :val (), :via [], :in [1]}], :spec #object[clojure.spec.alpha$regex_spec_impl$reify__2436 0xb838373 "clojure.spec.alpha$regex_spec_impl$reify__2436@b838373"], :value (((= n 0) 0) ((= n 1)) (:else (+ (fib'' (- n 1)) (fib'' (- n 2))))), :args (((= n 0) 0) ((= n 1)) (:else (+ (fib'' (- n 1)) (fib'' (- n 2)))))}, compiling:(/private/var/folders/j8/yfkf8mt564b1lq0qx06bq9cw0000gn/T/form-init837778551915099089.clj:2:3)
第2引数 [1]
の結果式(:exprs
)が足りないと指摘してくれる。
まとめ
- マクロに対してspecを定義すると
- マクロに対する不正な入力を早期に検出できる
- マクロの実装と引数に対するチェックを分離できる
- 想定する引数の形式を強力なspecの語彙で宣言的に記述できる
- clojure.specでClojureはますます楽しくなる>ω</