問題

docsでは十分ではない

Clojureは動的言語だ。何よりこれは型アノテーションがコードの実行に必要とされないことを意味する。Clojureは型ヒントをいくらかサポートしているが、型ヒントは強制のメカニズムではなく、網羅的でもなく、効率的なコード生成を支援するためコンパイラに情報を伝えることに限定されている。ClojureはJVM自身による豊富な型に対する実行時チェックの恩恵を受けている。

しかし、情報をデータとしてシンプルに表現することが常にコミュニティで広く評価され実践されてきたClojureの指針だった。したがって、Clojureシステムの重要なプロパティはデータの形状やその他の述語的なプロパティによって表現され伝達され、実行時の型は区別の付かない異種の型が混在するマップやベクターであるため、それ以外で捕捉されたりチェックされたりすることはない。

ドキュメンテーション文字列は人間の利用者とのコミュニケーションに利用可能だが、プログラムやテストで活用することはできない、つまり最小限の力しか持っていないのだ。ユーザーはより強力な仕様記述を得るために SchemaHerbert のような様々なライブラリを頼ってきた。

マップのspecはキーセットのみで構成されるべきだ

構造を記述するためのたいていのシステムではキーのセットに関する仕様(例えばマップのキーやオブジェクトのフィールドに関するもの)とそうしたキーが指し示す値に関する仕様を混同している。つまり、そうしたアプローチではあるマップに対するスキーマは:a-keyの型がx-typeで:b-keyの型がy-typeだと言うかもしれない。これは硬直性と冗長性の大きな原因だ。

Clojureではマップを動的に合成し、マージし、構築することによって力を得ている。日常的に選択的なデータや部分的なデータ、信頼できない外部ソースから生み出されたデータ、動的なクエリなどを扱っている。こうしたマップは同一のキーの様々な集合や部分集合、共通部分、和集合を表し、一般に同一のキーはどこで使われても同一の意味を持つ必要がある。あらゆる部分集合/和集合/共通部分に対する仕様を定義し、そして個々のキーの意味を冗長に述べるというのは、たいていの動的なケースでアンチパターンであり上手く機能するものでもない。

手動でのパース処理やエラー報告は十分に良いものではない

多くのユーザー、特に初心者は、とりわけ2つの実行コンテキストがあるマクロ(マクロのコンパイル時の実行と実行時の展開はどちらもユーザーエラーのために失敗しうる)において、手書きのパース処理やコードの分配束縛により生成されるエラーメッセージにフラストレーションを覚えたり困難に直面したりしている。これは「マクロ文法」の必要性につながっているが、実際にはマクロはデータ→データの関数にすぎず、データのバリデーションや分配束縛のためのあらゆる解決策はその他の関数と同様にマクロにも機能するはずだ。つまり、マクロは上記の問題の一例だといえる。

生成的テストと頑健性

最後に、動的でもそうでなくてもすべての言語において、テストは品質に不可欠なものだ。あまりにも多くの重要なプロパティが一般的な型システムでは捕捉されない。しかし手動でのテストは労力に対する効果の割合が非常に低い。 test.check でClojure向けに実装されているような、プロパティベースの生成的テストは手書きのテストよりもずっと強力であることが分かってきた。

それでも、プロパティベーストテストはプロパティの定義が必要となり、それにはそれを生み出すためのさらなる労力と熟練が必要となり、そうした定義は関数のレベルでは関数の仕様記述と大きな重複がある。関数のレベルでの多くの興味深いプロパティがすでに構造的+述語的なspecによって捕捉されているだろう。理想的には、specは生成的テストと統合し、ある種の生成的テストを「ただで」提供するべきだ。

標準的なアプローチが必要だ

要するに、Clojureには仕様記述とテストのための標準的で表現力のある強力で統合されたシステムがないのだ。

clojure.spec はそれを提供することを目指している。

目的

コミュニケーション

Species - appearance, form, sort, kind, equivalent to spec (ere) to look, regard
               + -iēs abstract noun suffix

Specify - species + -ficus -fic (make)

仕様(specification)とはあるものがどう「見える」かに関するものだが、最も重要なのは、見られるものだ。specは、読みやすく、プログラマがすでに使っている「単語」(述語関数)で構成され、ドキュメンテーションに統合されているべきだ。

あらゆるコンテキストにおける仕様記述を統一

データ構造や属性値、関数のためのspecはすべて同じであるべきであり、グローバルに名前空間の付いたディレクトリにあるべきだ。

仕様記述の労力を最大限に活用する

specを書くことで以下のことが 自動的 に可能になるべきだ:

  • バリデーション

  • エラー報告

  • 分配束縛

  • specの組み込み(instrumentation)

  • テストデータ生成

  • 生成的テストの生成

侵入を最小化

例えば関数を異なる方法で定義することを要求してはならない。 docmacroexpand への小さな変更によって、独立して書かれたspecが再定義することなくfn/macroの振る舞いを修飾することが可能になる。

マップ、キー、値を分離する

マップ(キーセット)のspecを属性(キー→値)のspecから分けておこう。名前空間付きのキーワードから値へのspecという、属性の粒度でのspecを推奨しサポートしよう。(マップの仕様を記述するために)キーを組み合わせてセットにすることが直交的になり、完全に動的なケースでのチェックが可能になる、つまりマップのspecが存在しない場合でさえ、属性(キー-値)はチェックできる。

意味の変更と互換性に関する対話を可能にし、開始する

プログラマは名前を同じにしたままで再定義する時に大いに苦しめられる。変更のいくらかは互換性があるが、いくらかは破壊的で、たいていのツールはそれを区別することができない。互換性が判断できるようにセットの所属関係や正規表現のような構造を利用し、(一般的な述語による等価性は除外して)互換性チェックのためのツールを提供しよう。

ガイドライン

ミスは起こりうる

私たちはミスを起こしえない世界で生きているわけではない(そうすることもできないだろう)。その代わりに、私たちは定期的にミスしていないことをチェックするのだ。Amazonは UPS<Trucks<Boxes<TV>>> 経由でテレビを送ってくるわけではない。そのため時には電子レンジを受け取ることもあるかもしれないが、サプライチェーンが正確性を証明する責任を負っているわけではない。その代わりに私たちが終端でチェックしてテストするのだ。

表現力 > 証明

主に型システムがしているのはそれなのだが、仕様記述を証明可能なものに制限する理由はない。システムについてコミュニケーションを取り、正しさを確かめたいことはもっとたくさんある。これは、例えば定義域を狭めたり複数の入力の間の関係や入力と出力の間の関係を詳細に述べる、構造的/表象的な型や述語に対するタグ付け以上のものだ。加えて、私たちが最も気にするプロパティは多くの場合実行時の値に関するプロパティであり、静的な概念のようなものではない。そういうわけで spec は型システムではない。

名前は重要だ

あらゆるプログラムは(型システムが使わない場合でさえ)名前を使い、名前は重要な意味を捉えている。 Int x Int x Int は十分に良いものではない(長さ/幅/高さ なのか 高さ/幅/奥行き なのか?)。そのため、 spec にはラベルなしのシーケンス要素やタグ付けされていない和集合の束縛はない。このことの効用は、 spec がspecについてユーザーに何か言わければならない時、例えばエラー報告する場合、また逆に、例えばユーザーがspecのジェネレータをオーバーライドしたい場合に明らかになる。すべての分岐に名前が付いていれば、 paths を使ってspecの部分について言うことができる。

グローバルな(名前空間付きの)名前はより重要だ

Clojureは名前空間付きのキーワードとシンボルをサポートしている。ここでは名前空間で修飾された名前のことを言っているのであって、Clojureの名前空間オブジェクトのことを言っているわけではないことに注意しよう。これらは悲しいことにあまり活用されていないが、ディクショナリ/db/マップ/セットの中で衝突することなく常に共存することができるため、重要な利益をもたらす。 spec はspecを名付けるのに名前空間付きのキーワードとシンボル(だけ)を認めている。名前空間付きのキーを情報を持ったマップのために使う(広まってほしい習慣だ)と、マップの属性のためのspecを直接マップのキーの名前で登録することができる。このことは、とりわけ動的な状況において、マップというものの自己記述を絶対的に変えることになり、合成や一貫性を促進することになる。

Clojureの(実体化された)名前空間にさらに追加し/負担をかけすぎてはならない

varやメタデータなどには何も付加されない。すべての関数には名前空間付きの名前があり、それは別のどこかに格納された関連するデータ(例えばspec)へのキーとして働く。

コードはデータだ(逆ではない)

Lisp(したがってClojure)では、コードはデータだ。しかし、データはそれに関する言語を定義するまでコードではない。この領域での多くのDSLはスキーマのデータ表現に向かっている。しかし、述語的なspecには開かれた大きな語彙があり、便利な述語の多くがすでに存在し、coreやその他の名前空間の関数としてよく知られている、もしくはシンプルな式として書くことができる。こうしたあらゆる述語を「データ化」する、あるいは名前を付け直さなければならないことによって得られる価値はほとんどなく、正確な意味を理解するのに明確なコストがある。 spec ではむしろ、もともとの述語と式が最初からデータであるという事実を活用して、ドキュメンテーションやエラー報告でのユーザーとのコミュニケーションで利用するためにそうしたデータを捉えている。そう、 clojure.spec の表層領域の多くはマクロになるが、specは圧倒的に人によって書かれ、組み合わせる時も手で行うということだ。

セット(マップ)は所属関係に関するもの、それだけだ

上述の通り、キーの値についての詳細を定義したマップは、根本的な関心の絡まり(complecting)であり、支持できないものだ。マップのspecは必須/オプションのキー(つまりセットの所属関係)について詳しく述べるが、キーワード/属性/値の意味は独立している。マップのチェックは、必須のキーの存在、そしてキー/値の一致という2フェーズだ。後者は、実行時に存在する(ネームスペース修飾された)キーがマップのspecにない場合でさえ行うことができる。これは合成と動的な性質のために極めて重要なものだ。

情報的 vs 実装的

いつでも、人は仕様記述システムを実装の決定について詳述するのに利用しようとするが、そうすることによって自らに損害を与えている。最も良く最も有用なspec(とインターフェース)は純粋に情報の側面に関するものだ。情報のspecだけがネットワークを越えてシステムを横断して機能する。私たちは情報のアプローチを常に優先し、対立がある場合にはそちらを好むだろう。

K.I.S.S.

この領域には基礎的な概念が非常に少ないが、私たちはそれらにこだわる努力をする。固有の構造的な概念は少ない。少数のアトミックな型、シーケンシャルなもの、セット、マップだ。驚くに当たらないことだが、これらはClojureのデータ型であり、基礎的なオペレータはこれらに対してのみ提供される。同様に、これらについて言うための数学的なツールがあり(マップに対する集合論理、シーケンスに対する正規表現)、価値のある性質を備えている。私たちはアドホックな解決策よりもこれらを好むだろう。

test.checkの上に構築するが、その知識は要求しない

spec を土台とした生成的テストは test.check を活用し、再発明はしない。しかし、specのユーザーは、自分自身でジェネレータを書きたかったり、さらなるプロパティベーストテストで spec が生成したテストを補いたかったりするのでない限り、 test.check について何も知る必要がないようにするべきだ。

特徴

概要

述語的なspec

基本的なアイディアは、specは単なる述語の論理的な組み合わせにすぎないというものだ。根底では、 int?symbol? 、自分で組み立てた式 #(< 42 % 66) のような、慣れたシンプルなbooleanの述語について言っているのだ。 specspec/andspec/or のような論理演算子を追加しており、論理的な方法でspecを組み合わせ、深いところまでの報告、生成、conformのサポート、 spec/or のケースではタグ付きの戻り値を提供する。

マップ

マップのキーセットのためのspecは、必須とオプションのキーのセットについての仕様記述を提供する。あるマップに対するspecは、キーの名前のベクターに対応付けた :req:opt キーワード引数で keys を呼び出すことで生成される。

:req のキーは論理演算子 andor をサポートしている。

(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を(定義されている場合に)利用してバリデーションと生成を行うマップを記述している。これは非修飾のキーワードが名前空間付きキーワードが持つのと同等の力を伝えることはできないことに注意しよう。結果として得られるマップは自己記述的ではないのだ。

シーケンス(sequence)

シーケンス/ベクターのためのspecは、正規表現の標準的なセマンティクスで一連の標準的な正規表現演算子を利用する:

  • cat - 述語/パターンの連結

  • alt - 一連の述語/パターンから1つの選択

  • * - 述語/パターンの0回以上の出現

  • + - 1以上

  • ? - 1または0

  • & - 正規表現演算子をとり、1個以上の述語でさらに制約する

これらは任意にネストして複合的な式を形成する。

catalt はすべての構成要素がラベル付けされていることを要求し、それぞれ戻り値はマッチした構成要素に対応するキーのマップであることに注意しよう。このように 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}}

conform/explain

上で見た通り、specを使うための基本操作は conform で、specと値をとって一致した値、もしくは値が一致しなかった場合には :clojure.spec.alpha/invalid を返す。値が一致しない時には explain または explain-data を呼び出してなぜ一致しなかったのか調べることができる。

specを定義する

specを定義するための主要な操作はs/def, s/and, s/or, s/keysと正規表現演算子だ。 述語関数または式、セット、もしくは正規表現演算子をとり、述語が暗示するジェネレータをオーバーライドするオプションのジェネレータをとることもできる spec 関数がある。

しかし、 def, and, or, keys のspec関数と正規表現演算子はいずれも述語関数とセットを直接とって使うことができ、 spec でラップする必要はないことに注意しよう。 spec はジェネレータをオーバーライドしたい場合やネストした正規表現が同じパターンに含まれるのではなく新しいパターンを開始するように記述したい場合にのみ必要となるはずだ。

データspecの登録

specを名前で再利用できるようにするためには、 def によって登録しなければならない。 def は名前空間付きのキーワード/シンボルとspec/述語の式をとる。規約により、データのための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 によって取り出すことができる。

specを使う

ドキュメンテーション

fdef によって定義された関数specは関数名に対して doc を呼び出すと現れる。specに対して describe を呼び出すと、説明がフォームとして得られる。

パース処理/分配束縛

conform を実装の中で分配束縛/パース処理/エラーチェックのために直接使うことができる。 conform は例えばマクロの実装やI/Oの境界で利用できる。

開発中に

instrument で関数と名前空間に選択的に組み込むことができる。これは関数のvarを :args specをテストするラップされたバージョンの関数に差し替える。 uninstrumentは関数を元のバージョンに戻す。 gen/sample でインタラクティブなテストのためにデータを生成することができる。

テストのために

check で名前空間全体に対して一連のspecによる生成的テストを実行することができる。 gen を呼び出すことでtest.ckeck互換のspecのジェネレータを得ることができる。 clojure.core のデータの述語の多くと対応するジェネレータとの間には組み込みで関連があり、 spec の複合的な演算子はそこからどのようにジェネレータを組み立てるべきか分かっている。 specに対して gen を呼び出して一部のサブツリーについてジェネレータを組み立てることができないと、その場所を示す例外がスローされる。 specに分からないものにジェネレータを提供するためにジェネレータを返す関数を spec に渡すことができ、また、specの1つ以上のサブパスで代わりになるジェネレータを提供するためのオーバーライドマップを gen に渡すことができる。

実行時に

上述の分配束縛のユースケースに加えて、 conformvalid? を実行時のチェックを行いたい箇所でどこでも呼び出すことができ、また、プロダクションで実行することを想定したテストのための内部利用限定の軽量なspecを作ることができる。

さらなるサンプルと利用方法の情報は specガイドAPI docs を参照。

用語集

predicates (述語)

spec APIの多くの部分で’predicates'(述語)つまり’preds’が必要となる。こうした引数は以下のもので満たすことができる:

  • 述語(boolean)関数

  • セット

  • 登録済みのspecの名前

  • spec(cat, alt, *, +, ?, & の戻り値)

  • 正規表現演算子(cat, alt, *, +, ?, & の戻り値)

独立した正規表現の述語を正規表現の中にネストさせたい場合には spec の呼び出しの中にラップしなければならず、そうしなければネストしたパターンとみなされることに注意しよう。

specs (仕様記述)

spec, and, or, keys の戻り値。

regex ops (正規表現演算子)

cat, alt, *, +, ?, & の戻り値。ネストするとこれらは単一の式になる。

conform (一致させる)

conform はspecを利用する基本的な操作で、バリデーションと一致(conform)/分配束縛の両方を行う。conformは’deep’であり、すべてのspecと正規表現演算、マップのspecなどにわたることに注意しよう。 nilfalse はconformした値として正当なものなため、値がconformできない場合には特別な :clojure.spec.alpha/invalid が返される。

explain (説明する)

ある値がspecのconformに失敗する場合には、同じspec+値で explain または explain-data を呼び出して原因を調べることができる。こうした説明は、追加の作業をすることになるかもしれず、失敗しない入力やレポートが望ましくない場合にまでそのコストを負担する理由もないため、 conform 時には生成されない。説明の重要な構成要素は パス だ。 explain は例えばネストしたマップや正規表現パターンを通るにつれてパスを伸ばしていくため、単なる全体もしくは葉の値よりも良い情報が得られる。 explain-data は問題箇所までのパスのマップを返す。

paths (パス)

例えばマップの keysoralt の選択肢、(省略されうる) cat の要素のようにspecのすべての 分岐 点はラベル付けされていることから、specのあらゆる部分式はその部分に名前を付ける パス (キーのベクター)によって指し示すことができる。こうしたパスは explaingen のオーバーライド、様々なエラー報告に利用されている。

先行技術

specにはほとんど何も新規なところはない。上で述べたライブラリや RDF 、また、コントラクトシステムについてなされてきた様々な業績、例えば Racketのコントラクト を参照。

specの有用性と強力さをぜひ知ってほしい。

Rich Hickey