17
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Clojureのマクロとclojure.specのある未来

Last updated at Posted at 2017-12-03

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はますます楽しくなる>ω</

Further Reading

17
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?