clojure.spec入門2

clojure.spec入門1」ではclojure.specを使った仕様の作り方について説明しました。 今回は実際に関数に仕様を登録しましょう。

なお、この文書ではclojure.spec名前空間sclojure.spec.test名前空間tという別名を付けています。 また、この文書はClojure 1.9.0-alpha7、test.check 0.9.0の使用を前提としています。

(require '[clojure.spec :as s])
(require '[clojure.spec.test :as t])

次のようなmy-range関数の仕様を考えます。

(defn my-range
  [begin end]
  (vec (range begin (inc end))))

(my-range 10 20)
;;=> [10 11 12 13 14 15 16 17 18 19 20]

関数に仕様を登録するにはs/fdefマクロを使います。 s/fdefで登録できる仕様には:args:ret:fnの3種類があります。

:argsは関数の引数に対する仕様です。 引数はリストとして述語に渡されます*1。 ここでは例として次のような仕様をclojure.specで書きます。

  • 第1引数は1以上10000以下の整数
  • 第2引数は1以上10000以下の整数
  • 第1引数は第2引数よりも小さい
(s/and (s/cat :begin (s/and integer? #(<= 1 % 10000))
              :end   (s/and integer? #(<= 1 % 10000)))
       #(< (:begin %) (:end %)))

:retは関数の返り値に対する仕様です。 返り値はそのまま述語に渡されます。 ここでは例として次のような仕様をclojure.specで書きます。

(s/and vector? #(every? integer? %) #(every? pos? %) #(= % (sort %)))

:fnでは関数の引数と返り値との関係に対する仕様です。 引数と返り値は{:args s/conform後の引数 :ret s/conform後の返り値}というマップとして述語に渡されます。 ここでは例として次のような仕様をclojure.specで書きます。

  • 返り値のベクターの要素の個数は第2引数 - 第1引数 + 1
#(= (count (:ret %)) (inc (- (:end (:args %)) (:begin (:args %)))))

これらの仕様を次のようにしてmy-rangeに登録します。

(s/fdef my-range
  :args (s/and (s/cat :begin (s/and integer? #(<= 1 % 10000))
                      :end   (s/and integer? #(<= 1 % 10000)))
               #(< (:begin %) (:end %)))
  :ret  (s/and vector? #(every? integer? %) #(every? pos? %) #(= % (sort %)))
  :fn   #(= (count (:ret %)) (inc (- (:end (:args %)) (:begin (:args %))))))

関数に登録した仕様はそのままでは何もしません*2s/instrument関数を使うと関数に登録した:argsのチェックが有効になります。

(s/instrument #'my-range)

(my-range 1 5)
;;=> [1 2 3 4 5]
(my-range -10 10)
;;=> ExceptionInfo Call to #'user/my-range did not conform to spec:
;;   In: [0] val: -10 fails at: [:args :begin] predicate: (<= 1 % 10000)
;;   :clojure.spec/args  (-10 10)
;;     clojure.core/ex-info (core.clj:4706)

:argsのチェックを再び無効にするにはs/unstrument関数を使います。

(s/unstrument #'my-range)

(my-range 1 5)
;;=> [1 2 3 4 5]
(my-range -10 10)
;;=> [-10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10]

:ret:fnについてはtest.checkでのジェネレーティブテストで使います。 少し試してみましょう。

(t/check-fn #'my-range (s/fn-spec #'my-range))
;;=> {:result true, :num-tests 100, :seed 1466393888201}

これでmy-rangeについて:argsから自動的に作った100組の引数について:ret:fnを満たしているかどうかのテストが行われました。 引数の生成は:argsを登録するときに使ったinteger?が対応するジェネレーターを持っていることにより実現しています。

なお、:argsinteger?#(<= 1 % 10000)とを逆にするとジェネレーティブテストは動きません。

(s/fdef my-range
  :args (s/and (s/cat :begin (s/and #(<= 1 % 10000) integer?)
                      :end   (s/and #(<= 1 % 10000) integer?))
               #(< (:begin %) (:end %))))

(t/check-fn #'my-range (s/fn-spec #'my-range))
;;=> IllegalStateException Unable to construct gen at: [:begin] for: (<= 1 % 10000)  clojure.spec/gensub (spec.clj:222)

s/andはジェネレーターについて、第1引数に対応するジェネレーターを使い、第2引数以降をフィルターとして扱います。 そのため、新たに作った述語関数である#(<= 1 % 10000)を第1引数にした場合、s/andは適当なジェネレーターを見付けることができず例外が発生します*3


ここで、clojure.specの利用方法について"clojure.spec - Rationale and Overview"から引用します。

  • Documentation
    • Functions specs defined via fdef will appear when you call doc on the fn name. You can call describe on specs to get descriptions as forms.
    • (日本語訳)fdefで定義された関数の仕様は関数名についてdocを呼び出したときに表示されます。 説明をフォームの形で得るために仕様についてdescribeを呼び出すことができます。
  • Parsing/destructuring
    • You can use conform directly in your implementations to get its destructuring/parsing/error-checking. conform can be used e.g. in macro implementations and at I/O boundaries.
    • (日本語訳)分配束縛、パース、エラーチェックのために実装で直接conformを使うことができます。 conformは例えばマクロの実装や入出力の境界で使うことができます。
  • During development
    • You can selectively instrument functions and namespaces with instrument and instrument-ns. instrument swaps out the fn var with a wrapped version of the fn that tests all three of the fn specs. unstrument returns a fn to its original version. You can generate data for interactive testing with gen/sample.
    • (日本語訳)instrumentinstrument-nsを使って関数や名前空間を選んでチェックすることができます。 instrumentは関数のvarを関数の仕様の3つ全てをテストするバージョンと交換します(現在のinstrumentで交換される関数は:argsのみをテストします)。 unstrumentは関数をオリジナルのバージョンに戻します。 インタラクティブなテストのためにgen/sampleを使ってデータを生成することができます。
  • For testing
    • You can run a suite of spec-generative tests on an entire ns with run-tests. You can get a test.check compatible generator for a spec by calling gen. There are built-in associations between many of the clojure.core data predicates and corresponding generators, and the composite ops of spec know how to build generators atop those. If you call gen on a spec and it is unable to construct a generator for some subtree, it will throw an exception that describes where. You can pass generator-returning fns to spec in order to supply generators for things spec does not know about, and you can pass an override map to gen in order to supply alternative generators for one or more subpaths of a spec.
    • (日本語訳)run-testsを使って仕様に基づくジェネレーティブテストを名前空間全体に対して行うことができます。 genを呼び出すことで仕様に対するtest.checkと互換性のあるジェネレーターを得ることができます。 clojure.coreのデータのための述語関数には対応するジェネレーターとのビルトインの関連があります。そして、仕様の複合演算子はそれらのジェネレーターをどのように作るのかを理解します。 もしある仕様についてgenを呼び出し、どこかのサブツリーについてジェネレーターを作ることができなければ、それがどこであるのかを説明する例外が投げられます。 対応するジェネレーターを知らない仕様に対しては、ジェネレーターを提供するためにジェネレーターを返す関数をspecに渡すことができます。そして、仕様の1つ以上のサブパスに対して別のジェネレーターを提供するためにオーバーライドマップをgenに渡すことができます。
  • At runtime
    • In addition to the destructuring use cases above, you can make calls to conform or valid? anywhere you want runtime checking, and can make lighter-weight internal-only specs for tests you intend to run in production.
    • (日本語訳)前述の分配束縛の利用例に加えて、実行時のチェックをしたい場所であればどこでもconformvalid?を呼び出すようにすることができます。そして、プロダクション環境で実行することを意図したテストのためにより軽量で内部的な仕様を書くことができます。

以上のとおり、簡単にclojure.specの使い方を説明しました。 clojure.specにはこの文書では触れていない重要な機能がまだたくさんありますので、この文書を読んでclojure.specに興味を持った方はぜひ参考資料を読んでください。

Clojure 1.9.0はまだアルファ版であり、clojure.specについてもClojure 1.9.0のリリースまでにまだまだ変わっていく可能性があります。 また、clojure.core名前空間への適用もまだ始まっていません*4。 しかし、clojure.specがClojureに標準で付いてくる以上、今後はclojure.specで仕様を書くというスタイルが一般的になることはほぼ確実だと思いますので、今のうちから少しずつ試してみるのがよいと思います。

参考資料

*1:引数が1つだけの場合でもリストとして述語に渡されるので忘れないようにします

*2:マクロの場合は登録しただけで仕様に基づくチェックが有効になります

*3:ちなみに、clojure.core内の述語関数でもeven?、odd?、pos?、neg?など対応するジェネレーターを持っていないものがあります。それらについてはinteger?などと組み合わせて使うことになります

*4:clojure.core内の仕様についてはClojureのコミッターで書くそうです("instrumenting clojure.core")