読者です 読者をやめる 読者になる 読者になる

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")

clojure.spec入門1

clojure.specはClojureでデータ、関数、マクロなどの仕様を書くためのライブラリーです。 clojure.specで仕様を書くことによる利点はいろいろありますが、まずは深く考えずにclojure.specで遊びましょう。

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

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

clojure.specでは仕様をスペックオブジェクトで表します。 スペックオブジェクトはs/specマクロに述語関数*1を渡すことで作れます。 次の例では「整数である」という仕様を表すスペックオブジェクトを作ります。

(s/spec integer?)

ある値が仕様を満たしているかどうかを判断するにはs/conform関数を使います。 次の例では100.2がそれぞれ「整数である」という仕様を満たしているかどうかを判断します。

(s/conform (s/spec integer?) 10)
;;=> 10
(s/conform (s/spec integer?) 0.2)
;;=> :clojure.spec/invalid

s/conformは第1引数の仕様を第2引数の値が満たしていないときに:clojure.spec/invalidを返します。 第1引数を第2引数が満たしているときにs/conformが何を返すかは第1引数の種類によります(この例では第2引数をそのまま返しています)。

前述の例は実は次のように書くこともできます。

(s/conform integer? 10)
;;=> 10
(s/conform integer? 0.2)
;;=> :clojure.spec/invalid

これはs/conformの第1引数がスペックオブジェクトよりも広い概念である「述語(predicates)」を受け入れるようになっているからです。 述語には次のものが含まれます。

  • 述語関数
  • セット
  • 登録された述語に付けられた名前
  • スペックオブジェクト
  • 正規表現演算子

clojure.spec内の関数、マクロの多くはs/conformと同様に述語を受け入れるようにできています。 そのため、s/specを使って明示的にスペックオブジェクトを作ることはあまりありません。


ある値が「整数かつ正の数である」という仕様を満たしているかどうかを判断するには次のように書きます。

(s/conform (s/and integer? pos?) 20)
;;=> 20
(s/conform (s/and integer? pos?) -3)
;;=> :clojure.spec/invalid

s/andマクロは引数として渡された複数の述語を「かつ」で結合します。 s/andの返り値はスペックオブジェクトです。

ある値が仕様を満たしていないとき、仕様のどの部分を満たしていないのかを知りたい場合にはs/explain関数を使います。

(s/explain (s/and integer? pos?) -3)
;;=> val: -3 fails predicate: pos?
(s/explain (s/and integer? pos?) 0.1)
;;=> val: 0.1 fails predicate: integer?

なお、前述の例は次のようにも書けます。

(s/conform #(and (integer? %) (pos? %)) 20)
;;=> 20
(s/conform #(and (integer? %) (pos? %)) -3)
;;=> :clojure.spec/invalid

しかし、このように書いてしまうと仕様を満たしていないときに仕様のどの部分を満たしていないのかを知ることが難しくなります。

(s/explain #(and (integer? %) (pos? %)) 0.2)
;;=> val: 0.2 fails predicate: :clojure.spec/unknown

複数の述語を「かつ」で結合するs/andに対して複数の述語を「又は」で結合するs/orマクロもあります。

(s/conform (s/or :s string? :b boolean?) true)
;;=> [:b true]
(s/conform (s/or :s string? :b boolean?) "Clojure")
;;=> [:s "Clojure"]

s/orでは引数をキーワードと述語とのペアで書きます。 s/orの返り値はスペックオブジェクトです。

s/orを使った場合のs/conformの返り値に注目しましょう。 s/orを使った場合、s/conform分配束縛を行い、その返り値はキーワードと値とのペアのベクターになります。

なお、s/andは引数として渡された述語を前から順番に検討していきますが、ある述語について仕様を満たすと判断した場合、その述語にs/conformを適用したときの返り値がそれ以降の述語に渡されます。

つまり、次のようなことが起こります。

(s/conform (s/and (s/or :s string? :b boolean?) string?) "Clojure")
;;=> :clojure.spec/invalid

s/andの第1引数の述語を検討した後、第2引数の述語に渡されるのは"Clojure"ではなく[:s "Clojure"]になります。

(s/conform (s/and (s/or :s string? :b boolean?) vector?) "Clojure")
;;=> [:s "Clojure"]

「第1要素が整数かつ第2要素が小数であるシーケンスである」という仕様を表すにはどうすればよいでしょうか。 まずは次の方法が考えられます。

(s/conform (s/and #(pos? (first %)) #(neg? (second %))) '(1 -2))
;;=> (1 -2)

しかし、s/catマクロを使うともっとよい感じに書くことができます。 s/catでは引数をキーワードと述語とのペアで書きます。

(s/conform (s/cat :p pos? :n neg?) '(1 -2))
;;=> {:p 1, :n -2}

s/catは渡された引数の述語を連結し、シーケンスにマッチする「正規表現演算子regex ops)」を作ります。 clojure.specではs/cats/altマクロ、s/*マクロ、s/+マクロ、s/?マクロ、s/&マクロの返り値を正規表現演算子と呼びます。 s/catを使った場合、s/conform分配束縛を行い、その返り値はキーワードと値とのペアのマップになります。

なお、s/cats/andを組み合わせると、次のようなこともできます。

(s/conform (s/and (s/cat :i integer? :f float?)
                  #(< (:i %) (:f %))) '(3 6.4))
;;=> {:i 3, :f 6.4}

このような形で「第1要素が整数かつ第2要素が小数かつ第1要素が第2要素よりも小さいシーケンスである」という仕様を表すことができます。

(「clojure.spec入門2」に続く)

参考資料

*1:この文書では引数を1つ受け取り、ブール値を返す関数を述語関数と呼びます

ClojureScriptのコンパイルパイプライン

この文書について

この文書はMichael Fogusさんの「The ClojureScript Compilation Pipeline」を日本語に翻訳したものです。 原文のライセンスと同じく、この文書はクリエイティブ・コモンズ 表示 - 継承 2.5 一般 ライセンスの下に提供されます。


これは「解説Clojureのコンパイル技術」シリーズの5番目の記事です。

来るべきClojureのGoogle Summer of Codeプロジェクトを記念して、私はClojureScriptのコンパイラパイプラインについてのいくつかの解説を公開します。 私はこれについて私のClojureWestでのトークで話しました。しかし、もう少しの検討は歓迎です。 ここで私が述べることのほとんどは、データ構造と実際のプログラム上のインターフェイスの周りの詳細が異なることを除いてClojureパイプラインにも適用されます。 私は抽象度の高いレベルにとどまろうと試みるつもりです。

ClojureScriptのパイプライン概観

これはClojureScriptのパイプラインです。

f:id:ykomatsu0:20151104165814j:plain

あなたがClojureコードを片端に入れると、もう一方の端からJavaScriptが得られます。

f:id:ykomatsu0:20151104165807j:plain

コンパイラーは非常に多くのフェーズから構成され、その最初のフェーズは文字列の読込みとそれらをClojureのデータ構造に変換することに充てられます。

f:id:ykomatsu0:20151104165759j:plain

次のコードをClojure REPLで観察することで、あなたはどのように読込みフェーズが動くのかが分かるでしょう。

(def E (read-string "(-> 42 (- 6) Math/sqrt)"))

(type E)
;=> clojure.lang.PersistentList

(type (last E))
;=> clojure.lang.Symbol

あなたはEがデータ構造であることが分かるでしょう。

次のコンパイルフェーズはマクロ展開フェーズです。

f:id:ykomatsu0:20151104165740j:plain

このフェーズの間に、以下のとおり、フォームはそれがある不動点に到達するまで展開されるでしょう。

(-> E macroexpand-1)
;=> (clojure.core/-> (clojure.core/-> 42 (- 6)) Math/sqrt)

(-> E macroexpand-1 macroexpand-1)
;=> (Math/sqrt (clojure.core/-> 42 (- 6)))

(-> E macroexpand-1 macroexpand-1 macroexpand-1)
;=> (. Math sqrt (clojure.core/-> 42 (- 6)))

(-> E macroexpand-1 macroexpand-1 macroexpand-1 macroexpand-1)
;=> (. Math sqrt (clojure.core/-> 42 (- 6)))

上の実例では3つのレベルのマクロ展開でフォームは(あるレベルから次のレベルに変わらない)不動点に到達しました。 結局は内部の->マクロも展開するでしょうが、それはASTの生成中にフォームが巡回されるときに起きます。 あなたは私がマクロ展開の箱を少し小さく作ったことに気付くでしょう。 この違いの意味はマクロ展開がASTの生成に割り込むということです。 機械的なマクロ展開の他に、このフェーズは.(ドット)演算子の引数を同じ意味の( . target field/method args)形式に入れることもします。

次のフェーズは解析フェーズであり、その主目的はClojureScriptの抽象構文木(abstract syntax tree、AST)を生成することだと思われます。

f:id:ykomatsu0:20151104165728j:plain

もしあなたが私とASTについて話したことがあれば、不幸なことにあなたはおそらく「Lispの構文はプログラム自体のASTである」という不運な言葉(又はいくつかのそのようなバリエーション)に対する私の先入観の全てを聞いてしまったでしょう。 これはごみくずです。 実際のASTはローカル束縛の情報、アクセス可能な束縛、アリティーの情報、その他多くの便利な小さな情報のような船1杯分の追加情報で装飾されています。

f:id:ykomatsu0:20151104165720j:plain

あなたはおそらくこのような図を(そしておそらくパイプラインに似た画像も)他のブログの記事、教科書、論文で見たことがあるでしょう。 しかし、ClojureScript(そしてある日のClojureコンパイラー)の提供する1つの利点は各コンパイルフェーズ間でインターフェイスが単なるClojureのデータで構成されているということです!  リーダーの生成物はリスト、いくつかの他のClojureのデータ型、又はそれらのネストしたものです。 マクロ展開の生成物も同じです。 解析フェーズの生成物はネストしたClojureのマップから構成されたAST自体です。 Clojure又はClojureScript自体(そして数百のライブラリー)が直接処理するツールそのものによって処理できないコンパイルフェーズの生成物はありません。 これはClojureLisp全般の本当にすばらしい機能です。

最後のステージはASTを受け取りJavaScriptを生成するエミッションフェーズです。

f:id:ykomatsu0:20151104165713j:plain

エミッションはおそらくClojureScriptコンパイラー全体で最も複雑な部分でしょう。それはコード700行くらいです。

ClojureScriptのパイプラインへの接続

まず明らかな拡張場所は解析フェーズのバックエンドです。

f:id:ykomatsu0:20151104165728j:plain

これは実際にAmbrose Bonnaire-Sergeantさんが彼のtyped-clojureで採用したアプローチです。 大きな違いは彼の解析プロジェクトがClojureの解析エンジンを使ってClojureScriptに似たASTを提供していることです。 これは非常にクールです。 図で示せば、あなたは次のように置かれたtyped-clojureを想像できるでしょう。

f:id:ykomatsu0:20151104165707j:plain

解析フェーズで生成された木を型チェッカーを使って装飾し、検査します。 ここで質問です。Haskellの型システムの最大の制限は何でしょうか。 それについて考えましょう。 私は私のトークの中ではこれに答えますが、それはこの記事の中心ではありません。

f:id:ykomatsu0:20151104165700j:plain

ただし、概念的にはAST上で順番に起きる追加のチェックを想像することができるので、そこで止める理由はありません。

f:id:ykomatsu0:20151104165653j:plain

それゆえ、派生モデルではパイプラインを通じてASTが徐々に装飾されます。 これがプラガブル *1 コンパイルシステムです。それはClojureの理想である開かれた拡張に完全に従ったものです。

f:id:ykomatsu0:20151104165648j:plain

これは非常に強力なモデルですが、潜在的に複雑さに満ちています

しかし、強力な一方、シーケンシャルモデルはとても便利なものでも望まれたものでもありません。 むしろ、よりよいモデルは分岐やパイプラインへのロジックの入力を許すものでしょう。 例えば、プログラムが独立していて完全に型付けされていれば、分岐(beta)が適切な動作かもしれません。 しかし、プログラムが型付けされていないライブラリーの使用を含んでいれば、おそらく実行時制約チェックで装飾された部分的な静的型付け *2 (alpha)がより適切な分岐先かもしれません *3

f:id:ykomatsu0:20151104165641j:plain

解析フェーズへのインターフェイスとその構造を処理するための一連のツールの設計は比較的単純です。 難しい部分はそれらの使い方、パイプラインへの入力、分岐ロジックです。 ここでは注意深い設計が要求されます。

次の拡張場所はエミッションフェーズの入力部分です。

f:id:ykomatsu0:20151104165713j:plain

もちろん、拡張によって私はそれら独自のエミッターをプラグインできるものを意図しています。

f:id:ykomatsu0:20151104165635j:plain

これはおそらく新しいプラットフォームをClojureScriptのターゲットにするための最も単純な方法であり、実際にclojure-schemeによって採用されています。 つまり、clojure-schemeを使ってNathan SorensonさんはバックエンドにGambit Schemeを生成したということです。 生成されたSchemeはさらにCにコンパイルすること、最後には機械語コンパイルすることができます。

f:id:ykomatsu0:20151104165619j:plain

ただし、このアプローチには関門があります。 ClojureScriptのコンパイラーは非常に単純で、その中のコードの刈込みではほんの少ししか作業をしません。しかし、代わりにその作業をGoogle Closureコンパイラーに委託します。 もしあなたのターゲット言語がその独自の処理(例えばツリーシェイキング)を持たなければ、あなたはあなた独自のものを作る必要があるでしょう。いっそのこと、ClojureScriptをそうするように拡張するという手もあります。

最後の明らかな拡張場所はエミッションフェーズまでのパイプラインを全てあなた独自のパーサーに置き換えることです。

f:id:ykomatsu0:20151104165613j:plain

これは非常に大きな作業(言語のパーサーを1から書く)又は適度に大きな作業(既存のパーサーを修正する)のどちらかでしょう。

f:id:ykomatsu0:20151104165602j:plain

ClojureScriptのパイプラインは様々な次元に沿って拡張することができますが、まだツールインターフェイスの周りでいくつかの仕事を必要としています。 Clojureコンパイラーの拡張性はもう少し低いですが、絶対不可能ということではありません。 パイプラインへの入力という要望と同じ体験を提供するためにはもっと仕事が必要です。 私はclojure-pyによって採用された他のアプローチには言及していません。 つまり、あなたはClojure実行環境全体をあなたの選択したターゲット言語に実装することを選択するかもしれないということです。

あなたが採用を決めたアプローチ(もしあれば)にかかわらず、楽しみ、ロックし続け、何かすばらしいものを作りましょう。

:F

この記事の草稿を読んでくれたことについてCraig AnderaさんBobby CalderwoodさんRich Hickeyさんに感謝します。

*1:Gilad Brachaさんはプラガブル型システムについてhttp://bracha.org/pluggableTypesPosition.pdfにある彼の論文「プラガブル型システム」で述べています

*2:再びGilad Brachaさんで「型は反モジュラーである」

*3:私はdocsフェーズを切り離された分岐に沿って置きましたが、全ての分岐への入力として提供する方がより適切かもしれません