Types as constraint in Clojure

@gakuzzzz さんが先日渋谷 java で興味深い話をしていたので、それを Clojure で再現してみようという話です。

はじめに

どういう話だったか簡単に書くと property based test を用いて、テストデータの半自動生成と性質のテストをしようという話。 ただ、そうは言っても制約をロジックで表しているコードの Generator は定義するのが難しいので制約を型で表現しましょう、というのが大まかな話。

詳細は以下のスライドを参照してください。

Clojure ではどのようなアプローチをとればいいのか

Clojure でも property based test そのものは行えます。 test.check というライブラリを使うことで実現できますし、これ自体は Clojure そのもののテストにも用いられていて信頼性は高いです。

ですが、 Clojure において型で制約を表現するというのは少々難しいです(不可能?)。そこで Clojure ではどうするかというと今回は plumatic/schema を使って、この問題に挑むことにします。

今回は以下のバージョンを使います。

[[org.clojure/clojure "1.8.0"]
 [prismatic/schema "1.1.0"]
 [org.clojure/test.check "0.9.0"]
 [prismatic/schema-generators "0.1.0"]]

plumatic/schema を使って制約を表現する

設問を表現する

元のスライドにあるような問題を解いてみることにしましょう。改めてスライドから問題を引用してみます。

設問を3つまで持つことができる簡易アンケートで、設問種別がA~Eの五種類がある。 ただし設問種別Aだけは、一つのアンケートで最大1個までしか持つことができない。

まずは設問を表現してみましょう。今回種別 A という設問だけを特別扱いしたいので次のように一旦定義できると思います。

(ns demo.schema
  (:require [schema.core :as s]
            [schema.experimental.abstract-map :as abstract-map]))

(s/defschema Question
  (abstract-map/abstract-map-schema
   :type
   {:title s/Str
    :description (s/maybe s/Str)}))

(abstract-map/extend-schema
 AQuestion Question [:a] {})

(abstract-map/extend-schema
 OtherQuestion Question [:b :c :d :e] {})

schema.experimental.abstract-map/abstract-map-schema と同名前空間から extend-schema というものを使っています。これらの関数は名前空間名が示す通り、まだ実験的な関数なので今後変わるかもしれませんが今回使うだけなら十分でしょう。

これで A~E まである 5 種類の設問を表現することが出来ました。簡単に解説すると最初に abstract-map-schema を使って Question というスキーマを定義しています。 そして、 extend-schema を使って Question スキーマを継承した AQuestion スキーマと OtherQuestion スキーマを定義しています。

abstract-map-schema 関数はいわゆる抽象クラスを表現するスキーマを作ります。このスキーマはサブタイプのいずれかとマッチするようになっています。ここでいうサブタイプというのは extend-schema 関数で作成するスキーマのことです。

これらは次のように動作します。

(s/validate Question {:type :a :title "test" :description "test"}) ;;-> {:type :a, :title "test", :description "test"}
(s/validate Question {:type :b :title "test" :description "test"}) ;;-> {:type :b, :title "test", :description "test"}
(s/validate AQuestion {:type :a :title "test" :description "test"}) ;;-> {:type :a, :title "test", :description "test"}
(s/validate Question {:type :a :title "test"}) ;;-> ExceptionInfo Value does not match schema: {:description missing-required-key}
(s/validate AQuestion {:type :b :title "test" :description "test"}) ;;-> ExceptionInfo Value does not match schema: {:type (not (#{:a} :b))}
(s/validate OtherQuestion {:type :a :title "test" :description "test"}) ;;-> ExceptionInfo Value does not match schema: {:type (not (#{:e :c :b :d} :a))}

だいたい直感に反しない動きをしていると思います。

ちなみにこの例をもう少し具体的に書くと次のようなスキーマ定義も可能です。

(s/defschema FormItem
  (abstract-map/abstract-map-schema
   :type
   {:label s/Str
    :value (s/maybe s/Str)}))

(abstract-map/extend-schema
 Text FormItem [:text] {})

(abstract-map/extend-schema
 Radio FormItem [:radio] {:options [s/Str]})

(abstract-map/extend-schema
 Checkbox FormItem [:checkbox] {:options [s/Str]})

アンケートを表現する

次に設問が 3 つしか持てないかつ、設問 A はひとつだけという制約を満たすようにアンケートのスキーマを定義します。

;; in demo.schema
(s/defschema Enquete
  (abstract-map/abstract-map-schema
   :type
   {:name s/Str}))

(s/defschema TwoOtherQuestionsOrLess
  (s/constrained [OtherQuestion] #(<= (count %) 2)))

(abstract-map/extend-schema
 AEnquete Enquete
 [:a-enquete]
 {:a-question AQuestion
  :other-questions TwoOtherQuestionsOrLess})

(s/defschema ThreeOtherQuestionsOrLess
  (s/constrained [OtherQuestion] #(<= (count %) 3)))

(abstract-map/extend-schema
 BEnquete Enquete
 [:b-enquete]
 {:other-questions ThreeOtherQuestionsOrLess})

このようになりました。 TwoOtherQuestionsOrLessThreeOtherQuestionsOrLess というスキーマが定義してあるのは後程必要となるからです。名前が示す通り 2 つかそれ以下しか OtherQuestion を持ちえないということを表現しています。

簡単にテストしてみましょう。

;;; good cases
(s/validate AEnquete
            {:type :a-enquete
             :name "a enquete"
             :a-question {:type :a :title "a" :description "aaa"}
             :other-questions []})
;; => {:type :a-enquete, :name "a enquete", :a-question {:type :a, :title "a", :description "aaa"}, :other-questions []}

(s/validate AEnquete
            {:type :a-enquete
             :name "a enquete"
             :a-question {:type :a :title "a" :description "aaa"}
             :other-questions [{:type :b :title "b" :description "bbb"}]})
;; => {:type :a-enquete, :name "a enquete", :a-question {:type :a, :title "a", :description "aaa"}, :other-questions [{:type :b, :title "b", :description "bbb"}]}

;;; bad cases
(s/validate AEnquete
            {:type :a-enquete
             :name "a enquete"
             :a-question {:type :a :title "a" :description "aaa"}
             :other-questions [{:type :a :title "a" :description "aaa"}]})
;; => ExceptionInfo Value does not match schema: {:other-questions [{:type (not (#{:e :c :b :d} :a))}]}

(s/validate AEnquete
            {:type :a-enquete
             :name "a enquete"
             :a-question {:type :a :title "a" :description "aaa"}
             :other-questions [{:type :b :title "b" :description "bbb"}
                               {:type :b :title "b" :description "bbb"}
                               {:type :b :title "b" :description "bbb"}]})
;; => ExceptionInfo Value does not match schema: {:other-questions (not (demo.schema/fn--21712 a-clojure.lang.PersistentVector))}

良い感じに動いていそうです。

Generator を定義する

ここまでで必要なスキーマ(つまり、制約)の定義が出来ました。実際にテストデータを生成する Generator を定義していきましょう。今回 plumatic/schema を使っているので schema-generators を使うことができます。

(ns demo.core-test
  (:require [clojure.test.check.clojure-test :refer [defspec]]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop]
            [demo.schema :as ds]
            [schema-generators.generators :as sgen]
            [schema.core :as s]))

(def a-enquete-generator
  (sgen/generator
   ds/AEnquete
   {ds/TwoOtherQuestionsOrLess (gen/vector (sgen/generator ds/OtherQuestion) 0 2)}))

これで完了です。 schema-generators はスキーマ定義から自動的にスキーマにあったジェネレーターを作成してくれるのでこの程度で済みます。

この Generator は次のテストをパスします。

(defspec always-valid
  10000
  (prop/for-all [ae a-enquete-generator]
                (s/validate ds/Enquete ae)))

10000 回の試行に耐えれるので恐らくは正しいのでしょう。ということでここまでで当初の目的であった Clojure で制約を型(スキーマ)で表現するというのが実現できました。 ただ、これだけだと少々不明瞭な点を残したままになっているので、もうちょっと書いていきます。

前後しましたが、 schema-generators は schema と一緒に動作するライブラリです。簡単な例を以下に示します。

(require '[schema.core :as s]
         '[schema-generators.generators :as sgen]
         '[clojure.test.check.generators :as gen])

(s/defschema MyModel
  {:key1 s/Str
   :key2 s/Int})

(sgen/generate MyModel)
;; => {:key1 "^LT'{\"}Ai", :key2 71N}

(last (sgen/sample 100 MyModel))
;; => {:key1 "]9}[:1//(yW4M*T_Rw0mz1B21O/X:T>[email protected]", :key2 -7630100}

(last (gen/sample (sgen/generator MyModel) 100))
;; => {:key1 ">35yz$#|0,q^)y)syFE<aZUgvdgj;&A&i\"QqCV,\\[&&e\\@gG):s;< D[v+Cn", :key2 60693967}

MyModel というスキーマから自動的に Generator が生成されているのが分かると思います。これは test.check と互換のある Generator なので、最後の例にあるように schema-generators で生成した Generator を test.check の関数へ渡しても動作します。

さて、もしかしたら気付いた方もいたかもしれませんが、 a-enquete-generator を作成するときに schema-generators.generators/generator 関数に渡している引数が少々多いですよね。これは理由があります。まず最初に次の例を考えてみます。

(gen/sample (gen/such-that odd? gen/pos-int) 100)

この例では正の整数を 100 ランダムに取得(サンプル)するようになっています。これは何度実行しても恐らくそう簡単には fail しません。ですが、 10000 だとどうでしょう。

(gen/sample (gen/such-that odd? gen/pos-int) 10000)

恐らく簡単に fail したと思います。何故なら test.check では such-that はデフォルトで 10 回しかリトライしないので、 10 回連続で even? な値を取得してしまうと例外を吐いてしまうんですね。なので、このような場合試行回数を増やすなどの対応が必要になります。

(gen/sample (gen/such-that odd? gen/pos-int 100) 10000)

試行回数を 100 に増やすととりあえずこけないようにはなりますが、これはテストが遅くなる原因のひとつです。

さて、 a-enquete-generator に戻ると次のような定義でした。

(def a-enquete-generator
  (sgen/generator
   ds/AEnquete
   {ds/TwoOtherQuestionsOrLess (gen/vector (sgen/generator ds/OtherQuestion) 0 2)}))

TwoOtherQuestionsOrLess はなんだったかというと次のような定義でした。

(s/defschema TwoOtherQuestionsOrLess
  (s/constrained [OtherQuestion] #(<= (count %) 2)))

schema.core/constrained を使っています。なので、 OtherQuestion のベクタであり、要素の数が 2 以下であることを期待しているわけです。 schema-generators はこのような定義に出会ったときに、普通のスキーマと同様に条件を満すような Generator を作成します。 ですが、 “OtherQuestion のベクタであり、要素の数が 2 以下” という条件を満すことは難しく、デフォルトのリトライ回数では数十回生成しただけで fail してしまいます。

なので、 TwoOtherQuestionsOrLess 専用の Generator をこちらから提供する必要があるわけです。それが schema-generators.generators/generator の第 2 引数です。 ds/TwoOtherQuestionsOrLess の Generator を (gen/vector (sgen/generator ds/OtherQuestion) 0 2) とする、といった具合ですね。試行回数を増やせば解決するのではないか、と思った方もいると思いますが schema-generators は内部で Generator を生成していて外部からリトライ回数を受け取れない(スキーマ定義を再帰的に下っているのもあって無理でしょう)というのが現状の仕様なので、このように避けるのがベターですね。

ということで AEnquete に対する Generator はこれで出来たと言っても良さそうです。

さらに踏み込んだ話

当初の目的は達したといっても良いのですが、このままでは AEnqueteBEnquete を統一的に扱うことが出来ません。この部分を解消していきましょう。なにがしたいのかというと Enquete に対して統一的に全ての Question を取得する関数が欲しいのです。

この問題だけなら通常次のように解決できます。

(defprotocol IEnquete
  (questions [x]))

(defrecord AEnquete [name a-question other-questions]
  IEnquete
  (questions [this]
    (apply conj [] a-question other-questions)))

(defrecord BEnquete [name other-questions]
  IEnquete
  (questions [this] other-questions))

今回はこれに加えてスキーマの定義も保持しなければなりません。なので次のような実装をしました。

(ns demo.core
  (:require [demo.schema :as ds]
            [schema.core :as s]
            [schema.utils :as su]))

;;; Question

(defrecord AQuestion [type title description])

(su/declare-class-schema!
 AQuestion (s/record AQuestion ds/AQuestion))

(s/defn new-a-question :- AQuestion
  [title :- s/Str description :- s/Str]
  (map->AQuestion {:type :a :title title :description description}))

(defrecord OtherQuestion [type title description])

(su/declare-class-schema!
 OtherQuestion (s/record OtherQuestion ds/OtherQuestion))

(s/defn new-other-question :- OtherQuestion
  [type :- (s/enum :b :c :d :e)
   title :- s/Str
   description :- s/Str]
  (map->OtherQuestion {:type type :title title :description description}))

;;; Enquete

(defprotocol IEnquete
  (questions [x]))

(defrecord AEnquete [type name a-question other-questions]
  IEnquete
  (questions [this]
    (apply conj [] a-question other-questions)))

(su/declare-class-schema!
 AEnquete (s/record AEnquete ds/AEnquete))

(s/defn new-a-enquete :- AEnquete
  [name :- s/Str
   a-question :- AQuestion
   other-questions :- [OtherQuestion]]
  (map->AEnquete {:type :a-enquete
                  :name name
                  :a-question a-question
                  :other-questions other-questions}))

(defrecord BEnquete [type name other-questions]
  IEnquete
  (questions [this] other-questions))

(su/declare-class-schema!
 BEnquete (s/record BEnquete ds/BEnquete))

(s/defn new-b-enquete :- BEnquete
  [name :- s/Str other-questions :- [OtherQuestion]]
  (map->BEnquete {:type :b-enquete
                  :name name
                  :other-questions other-questions}))

少々複雑ですが、理屈が分かればなんてことはないです。先程までスキーマを定義していた名前空間は demo.schema なので ds という別名を付けています。

(defrecord AQuestion [type title description])

(su/declare-class-schema!
 AQuestion (s/record AQuestion ds/AQuestion))

このような定義が並んでいますが、まずレコード型 AQuestion を定義して(スキーマ名と同名だけど名前空間が違うので衝突はしない / * などを付けて区別しやすくしてもよかったかもしれない)、 schema.utils/declare-class-schema! という関数で、型に対応するスキーマを登録しています。スキーマは何かというと (s/record AQuestion ds/AQuestion) で生成されたものです。 schema.core/record は型名とスキーマを受け取り、レコード型のインスタンスであるという情報を付与したスキーマを作成します。これによって demo.schema/AQuestion では、ただキーとバリューの組み合せが正しいか見ていただけだったのが、型の情報を見ることが出来るようになったわけですね。

ここまで複雑な定義を書いたおかげで次のようなコードが書けるようになりました。

(questions
 (new-a-enquete "A Enquete"
                (new-a-question "Hi!" "Hello, world")
                [(new-other-question :b "Test" "xxxxxx")]))
;; => [#demo.core.AQuestion{:type :a, :title "Hi!", :description "Hello, world"} #demo.core.OtherQuestion{:type :b, :title "Test", :description "xxxxxx"}]

(s/validate [ds/Question]
            (questions
             (new-a-enquete "A Enquete"
                            (new-a-question "Hi!" "Hello, world")
                            [(new-other-question :b "Test" "xxxxxx")])))
;; => [#demo.core.AQuestion{:type :a, :title "Hi!", :description "Hello, world"} #demo.core.OtherQuestion{:type :b, :title "Test", :description "xxxxxx"}]

schema.core/defrecord を使えばスキーマ定義と同時にレコード型の定義が出来ますが、上記のように違うレコード型を同じスキーマでバリデーションすることは出来ません(複雑な条件分岐を書けばあるいは出来るかもしれませんが)。

まとめ

Clojure でも plumatic/schema を使えば簡潔に制約をロジックではなくスキーマで表現することができました。また、 schema-generators を使えば test.check と親和性があるので property based test にも活かすことが出来ます。 今回触れませんでしたが、 schema-generators.complete という名前空間にある関数を使えば、テストデータの自動生成などもスキーマから出来るのでお得です。

勿論、 plumatic/schema を使うことで得られるメリットもありますが、最後に触れたようにやりすぎると少々複雑になりますし初見だとまず読めないと思うので適切に使うよう心掛けましょう。

ということで plumatic/schema 素晴しいよ、という話でした。