(spec/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])
Clojureは動的言語だ。何よりこれは型アノテーションがコードの実行に必要とされないことを意味する。Clojureは型ヒントをいくらかサポートしているが、型ヒントは強制のメカニズムではなく、網羅的でもなく、効率的なコード生成を支援するためコンパイラに情報を伝えることに限定されている。ClojureはJVM自身による豊富な型に対する実行時チェックの恩恵を受けている。
しかし、情報をデータとしてシンプルに表現することが常にコミュニティで広く評価され実践されてきたClojureの指針だった。したがって、Clojureシステムの重要なプロパティはデータの形状やその他の述語的なプロパティによって表現され伝達され、実行時の型は区別の付かない異種の型が混在するマップやベクターであるため、それ以外で捕捉されたりチェックされたりすることはない。
構造を記述するためのたいていのシステムではキーのセットに関する仕様(例えばマップのキーやオブジェクトのフィールドに関するもの)とそうしたキーが指し示す値に関する仕様を混同している。つまり、そうしたアプローチではあるマップに対するスキーマは:a-keyの型がx-typeで:b-keyの型がy-typeだと言うかもしれない。これは硬直性と冗長性の大きな原因だ。
Clojureではマップを動的に合成し、マージし、構築することによって力を得ている。日常的に選択的なデータや部分的なデータ、信頼できない外部ソースから生み出されたデータ、動的なクエリなどを扱っている。こうしたマップは同一のキーの様々な集合や部分集合、共通部分、和集合を表し、一般に同一のキーはどこで使われても同一の意味を持つ必要がある。あらゆる部分集合/和集合/共通部分に対する仕様を定義し、そして個々のキーの意味を冗長に述べるというのは、たいていの動的なケースでアンチパターンであり上手く機能するものでもない。
多くのユーザー、特に初心者は、とりわけ2つの実行コンテキストがあるマクロ(マクロのコンパイル時の実行と実行時の展開はどちらもユーザーエラーのために失敗しうる)において、手書きのパース処理やコードの分配束縛により生成されるエラーメッセージにフラストレーションを覚えたり困難に直面したりしている。これは「マクロ文法」の必要性につながっているが、実際にはマクロはデータ→データの関数にすぎず、データのバリデーションや分配束縛のためのあらゆる解決策はその他の関数と同様にマクロにも機能するはずだ。つまり、マクロは上記の問題の一例だといえる。
最後に、動的でもそうでなくてもすべての言語において、テストは品質に不可欠なものだ。あまりにも多くの重要なプロパティが一般的な型システムでは捕捉されない。しかし手動でのテストは労力に対する効果の割合が非常に低い。 test.check でClojure向けに実装されているような、プロパティベースの生成的テストは手書きのテストよりもずっと強力であることが分かってきた。
それでも、プロパティベーストテストはプロパティの定義が必要となり、それにはそれを生み出すためのさらなる労力と熟練が必要となり、そうした定義は関数のレベルでは関数の仕様記述と大きな重複がある。関数のレベルでの多くの興味深いプロパティがすでに構造的+述語的なspecによって捕捉されているだろう。理想的には、specは生成的テストと統合し、ある種の生成的テストを「ただで」提供するべきだ。
Species - appearance, form, sort, kind, equivalent to spec (ere) to look,
regard Specify - species + -ficus -fic (make) |
仕様(specification)とはあるものがどう「見える」かに関するものだが、最も重要なのは、見られるものだ。specは、読みやすく、プログラマがすでに使っている「単語」(述語関数)で構成され、ドキュメンテーションに統合されているべきだ。
specを書くことで以下のことが 自動的 に可能になるべきだ:
バリデーション
エラー報告
分配束縛
specの組み込み(instrumentation)
テストデータ生成
生成的テストの生成
例えば関数を異なる方法で定義することを要求してはならない。 doc
と macroexpand
への小さな変更によって、独立して書かれたspecが再定義することなくfn/macroの振る舞いを修飾することが可能になる。
私たちはミスを起こしえない世界で生きているわけではない(そうすることもできないだろう)。その代わりに、私たちは定期的にミスしていないことをチェックするのだ。Amazonは
UPS<Trucks<Boxes<TV>>>
経由でテレビを送ってくるわけではない。そのため時には電子レンジを受け取ることもあるかもしれないが、サプライチェーンが正確性を証明する責任を負っているわけではない。その代わりに私たちが終端でチェックしてテストするのだ。
主に型システムがしているのはそれなのだが、仕様記述を証明可能なものに制限する理由はない。システムについてコミュニケーションを取り、正しさを確かめたいことはもっとたくさんある。これは、例えば定義域を狭めたり複数の入力の間の関係や入力と出力の間の関係を詳細に述べる、構造的/表象的な型や述語に対するタグ付け以上のものだ。加えて、私たちが最も気にするプロパティは多くの場合実行時の値に関するプロパティであり、静的な概念のようなものではない。そういうわけで spec は型システムではない。
あらゆるプログラムは(型システムが使わない場合でさえ)名前を使い、名前は重要な意味を捉えている。 Int x Int x Int
は十分に良いものではない(長さ/幅/高さ なのか 高さ/幅/奥行き なのか?)。そのため、 spec
にはラベルなしのシーケンス要素やタグ付けされていない和集合の束縛はない。このことの効用は、 spec
がspecについてユーザーに何か言わければならない時、例えばエラー報告する場合、また逆に、例えばユーザーがspecのジェネレータをオーバーライドしたい場合に明らかになる。すべての分岐に名前が付いていれば、
paths を使ってspecの部分について言うことができる。
Clojureは名前空間付きのキーワードとシンボルをサポートしている。ここでは名前空間で修飾された名前のことを言っているのであって、Clojureの名前空間オブジェクトのことを言っているわけではないことに注意しよう。これらは悲しいことにあまり活用されていないが、ディクショナリ/db/マップ/セットの中で衝突することなく常に共存することができるため、重要な利益をもたらす。 spec はspecを名付けるのに名前空間付きのキーワードとシンボル(だけ)を認めている。名前空間付きのキーを情報を持ったマップのために使う(広まってほしい習慣だ)と、マップの属性のためのspecを直接マップのキーの名前で登録することができる。このことは、とりわけ動的な状況において、マップというものの自己記述を絶対的に変えることになり、合成や一貫性を促進することになる。
varやメタデータなどには何も付加されない。すべての関数には名前空間付きの名前があり、それは別のどこかに格納された関連するデータ(例えばspec)へのキーとして働く。
Lisp(したがってClojure)では、コードはデータだ。しかし、データはそれに関する言語を定義するまでコードではない。この領域での多くのDSLはスキーマのデータ表現に向かっている。しかし、述語的なspecには開かれた大きな語彙があり、便利な述語の多くがすでに存在し、coreやその他の名前空間の関数としてよく知られている、もしくはシンプルな式として書くことができる。こうしたあらゆる述語を「データ化」する、あるいは名前を付け直さなければならないことによって得られる価値はほとんどなく、正確な意味を理解するのに明確なコストがある。
spec
ではむしろ、もともとの述語と式が最初からデータであるという事実を活用して、ドキュメンテーションやエラー報告でのユーザーとのコミュニケーションで利用するためにそうしたデータを捉えている。そう、
clojure.spec
の表層領域の多くはマクロになるが、specは圧倒的に人によって書かれ、組み合わせる時も手で行うということだ。
上述の通り、キーの値についての詳細を定義したマップは、根本的な関心の絡まり(complecting)であり、支持できないものだ。マップのspecは必須/オプションのキー(つまりセットの所属関係)について詳しく述べるが、キーワード/属性/値の意味は独立している。マップのチェックは、必須のキーの存在、そしてキー/値の一致という2フェーズだ。後者は、実行時に存在する(ネームスペース修飾された)キーがマップのspecにない場合でさえ行うことができる。これは合成と動的な性質のために極めて重要なものだ。
いつでも、人は仕様記述システムを実装の決定について詳述するのに利用しようとするが、そうすることによって自らに損害を与えている。最も良く最も有用なspec(とインターフェース)は純粋に情報の側面に関するものだ。情報のspecだけがネットワークを越えてシステムを横断して機能する。私たちは情報のアプローチを常に優先し、対立がある場合にはそちらを好むだろう。
基本的なアイディアは、specは単なる述語の論理的な組み合わせにすぎないというものだ。根底では、 int?
や symbol?
、自分で組み立てた式 #(< 42 % 66)
のような、慣れたシンプルなbooleanの述語について言っているのだ。 spec は
spec/and
と spec/or
のような論理演算子を追加しており、論理的な方法でspecを組み合わせ、深いところまでの報告、生成、conformのサポート、 spec/or
のケースではタグ付きの戻り値を提供する。
マップのキーセットのためのspecは、必須とオプションのキーのセットについての仕様記述を提供する。あるマップに対するspecは、キーの名前のベクターに対応付けた
:req
と :opt
キーワード引数で keys
を呼び出すことで生成される。
:req
のキーは論理演算子 and
と or
をサポートしている。
(spec/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])
spec と他のシステムとの間で最も見た目に明らかな違いのひとつは、マップのspecに(例えば ::x
がとりうる) 値
について記述する場所がないことだ。これは、 :my.ns/k
のような名前空間付きのキーワードに関連付けられた値の仕様はそのキーワード自体に登録され、そのキーワードが現れるあらゆるマップに適用されるべきだという
spec の(強制的な)主張だ。これにはいくつもの利点がある:
すべての利用で意味が共有されるべきアプリケーション内で、そのキーワードのすべての利用に一貫性を保証する
同様に、ライブラリとその利用者との間の一貫性を保証する
そうでなければ多くのマップのspecがkについて同じ宣言をする必要があるため、冗長さを減らすことになる
名前空間付きキーワードのspecはマップのspecがそのキーを宣言していない時でさえチェックされる
この最後の点はマップを動的に構築し、合成し、生成する場合に極めて重要だ。あらゆるマップの部分集合/和集合/共通部分にspecを作るというのは上手くいかない。利用される時ではなく入ってきた時にフェイルファストで悪いデータを検知することも容易になる。
もちろん、多くの既存のマップベースのインターフェースは名前空間なしのキーをとっている。正しく名前空間が付いて再利用可能なspecとの接続をサポートするため、
keys
は :req
と :opt
の -un
変種をサポートしている。
(spec/keys :req-un [:my.ns/a :my.ns/b])
これは、非修飾のキー :a
と :b
を要求するが、それぞれ :my.ns/a
と :my.ns/b
という名前のspecを(定義されている場合に)利用してバリデーションと生成を行うマップを記述している。これは非修飾のキーワードが名前空間付きキーワードが持つのと同等の力を伝えることはできないことに注意しよう。結果として得られるマップは自己記述的ではないのだ。
シーケンス/ベクターのためのspecは、正規表現の標準的なセマンティクスで一連の標準的な正規表現演算子を利用する:
cat
- 述語/パターンの連結
alt
- 一連の述語/パターンから1つの選択
*
- 述語/パターンの0回以上の出現
+
- 1以上
?
- 1または0
&
- 正規表現演算子をとり、1個以上の述語でさらに制約する
これらは任意にネストして複合的な式を形成する。
cat
と alt
はすべての構成要素がラベル付けされていることを要求し、それぞれ戻り値はマッチした構成要素に対応するキーのマップであることに注意しよう。このように
spec の正規表現は分配束縛やパース処理のツールとして振る舞う。
user=> (require '[clojure.spec.alpha :as s])
(s/def ::even? (s/and integer? even?))
(s/def ::odd? (s/and integer? odd?))
(s/def ::a integer?)
(s/def ::b integer?)
(s/def ::c integer?)
(def s (s/cat :forty-two #{42}
:odds (s/+ ::odd?)
:m (s/keys :req-un [::a ::b ::c])
:oes (s/* (s/cat :o ::odd? :e ::even?))
:ex (s/alt :odd ::odd? :even ::even?)))
user=> (s/conform s [42 11 13 15 {:a 1 :b 2 :c 3} 1 2 3 42 43 44 11])
{:forty-two 42,
:odds [11 13 15],
:m {:a 1, :b 2, :c 3},
:oes [{:o 1, :e 2} {:o 3, :e 42} {:o 43, :e 44}],
:ex {:odd 11}}
specを定義するための主要な操作はs/def, s/and, s/or, s/keysと正規表現演算子だ。
述語関数または式、セット、もしくは正規表現演算子をとり、述語が暗示するジェネレータをオーバーライドするオプションのジェネレータをとることもできる
spec
関数がある。
しかし、 def, and, or, keys
のspec関数と正規表現演算子はいずれも述語関数とセットを直接とって使うことができ、
spec
でラップする必要はないことに注意しよう。 spec
はジェネレータをオーバーライドしたい場合やネストした正規表現が同じパターンに含まれるのではなく新しいパターンを開始するように記述したい場合にのみ必要となるはずだ。
specを名前で再利用できるようにするためには、 def
によって登録しなければならない。 def
は名前空間付きのキーワード/シンボルとspec/述語の式をとる。規約により、データのためのspecはキーワードに登録し、属性値はその属性名のキーワードに登録するべきだ。ひとたび登録すれば、specの名前はあらゆる
spec 操作でのspec/述語が必要なあらゆる場所で利用することができる。
関数は3つのspecで完全に記述することができる。引数のspec、戻り値のspec、引数を戻り値に対応付ける関数の操作のspecだ。
関数の引数のspecは常に引数がリスト、つまり apply
関数に渡すリストであるかのように記述する正規表現になる。これによって、単一のspecが複数のアリティを持つ関数を扱うことができる。
戻り値のspecは単一の値についての任意のspecだ。
(オプションの)関数のspecは引数と戻り値の関係、つまりその関数の機能についてのさらなる仕様記述だ。これは(例えばテスト時に) {:args
conformed-args :ret conformed-ret}
の入ったマップを受け取り、それらの値について説明する述語を一般に含むだろう。例えば入力のマップのすべてのキーが戻り値のマップに存在することを保証することができるだろう。
ある関数の3つすべてのspecを fdef
一度の呼び出しで指定することができ、それらのspecは fn-specs
によって取り出すことができる。
instrument
で関数と名前空間に選択的に組み込むことができる。これは関数のvarを :args
specをテストするラップされたバージョンの関数に差し替える。 uninstrument
は関数を元のバージョンに戻す。
gen/sample
でインタラクティブなテストのためにデータを生成することができる。
check
で名前空間全体に対して一連のspecによる生成的テストを実行することができる。 gen
を呼び出すことでtest.ckeck互換のspecのジェネレータを得ることができる。 clojure.core
のデータの述語の多くと対応するジェネレータとの間には組み込みで関連があり、 spec
の複合的な演算子はそこからどのようにジェネレータを組み立てるべきか分かっている。 specに対して gen
を呼び出して一部のサブツリーについてジェネレータを組み立てることができないと、その場所を示す例外がスローされる。
specに分からないものにジェネレータを提供するためにジェネレータを返す関数を
spec
に渡すことができ、また、specの1つ以上のサブパスで代わりになるジェネレータを提供するためのオーバーライドマップを gen
に渡すことができる。
spec APIの多くの部分で’predicates'(述語)つまり’preds’が必要となる。こうした引数は以下のもので満たすことができる:
述語(boolean)関数
セット
登録済みのspecの名前
spec(cat
, alt
, *
, +
, ?
, &
の戻り値)
正規表現演算子(cat
, alt
, *
, +
, ?
, &
の戻り値)
独立した正規表現の述語を正規表現の中にネストさせたい場合には spec
の呼び出しの中にラップしなければならず、そうしなければネストしたパターンとみなされることに注意しよう。
conform
はspecを利用する基本的な操作で、バリデーションと一致(conform)/分配束縛の両方を行う。conformは’deep’であり、すべてのspecと正規表現演算、マップのspecなどにわたることに注意しよう。
nil
と false
はconformした値として正当なものなため、値がconformできない場合には特別な
:clojure.spec.alpha/invalid
が返される。
ある値がspecのconformに失敗する場合には、同じspec+値で explain
または explain-data
を呼び出して原因を調べることができる。こうした説明は、追加の作業をすることになるかもしれず、失敗しない入力やレポートが望ましくない場合にまでそのコストを負担する理由もないため、
conform
時には生成されない。説明の重要な構成要素は パス だ。 explain
は例えばネストしたマップや正規表現パターンを通るにつれてパスを伸ばしていくため、単なる全体もしくは葉の値よりも良い情報が得られる。
explain-data
は問題箇所までのパスのマップを返す。
specにはほとんど何も新規なところはない。上で述べたライブラリや RDF 、また、コントラクトシステムについてなされてきた様々な業績、例えば Racketのコントラクト を参照。
specの有用性と強力さをぜひ知ってほしい。
Rich Hickey