Once again continuing
previous lines of thought
let’s consider validations beyond :pre
and :post
conditions. This
time we’ll see how ClojureScript’s specify
construct allows us to
trivially build a form of lazy contracts similar to those described by
Findler et al..
Note: the following approach can be duplicated with deftype
or
reify
on Clojure JVM, but specify
is more succinct.
Lazy contracts are useful because they allow us to lazily validate a large
data structure. All the previous examples with :pre
and :post
validated eagerly which is quite impractical when dealing with large amounts
of data.
For some time now ClojureScript has supported extension of individual
values to protocols via specify
. Again protocols are analogous to
Java interfaces, Go interfaces, Haskell typeclasses, and Objective-C
protocols. Clojure has had protocols since 1.2.0 and they are commonly
leveraged via extend-type
and extend-protocol
. However specify
is only currently available in ClojureScript as the semantics of
JavaScript and the performance of modern JavaScript engines permit
practical efficient implementation.
Imagine that we want to ensure that a vector contains only
even numbers. Fortunately there are only two ways to get an updated vector
in Clojure, conj
and assoc
. So given an existing vector we simply
need to specify
new implementations of the ICollection
and
IVector
protocols. The only other protocols we care about are those
involved in iteration - for simplicity’s sake we’re only going to
bother with ISeqable
, however for completeness you should implement
IReduce
and IKVReduce
. They are trivial to do and involve the same
simple delegation approach.
We will provide a function called add-contract*
that takes 3
arguments: a vector, a Var and a map of source location information
that informs us where the contract was asserted. This is important for
debugging as this allows us to prune our search - the contract
violation had to occur before the contract. In a mutable context
this wouldn’t be very useful but because of immutability we can laser
in on the origin of the issue.
As to why we take a Var for the second argument, the Var holds useful
reflection information if we want to print verbose error messages. We
can also just deref the function from the Var to get the validating
function. A first attempt at add-contract*
might look like the
following:
(ns lazy-contracts.core)
(enable-console-print!)
(defn add-contract*
([v cvar src-info] (add-contract* v cvar @cvar src-info))
([v cvar f src-info]
(specify v
ISeqable
(-seq [_]
(map #(do (assert (f %)) %) v))
ICollection
(-conj [_ x]
(assert (f x))
(add-contract* (-conj v x) cvar f src-info))
IVector
(-assoc-n [_ i x]
(assert (f x))
(add-contract* (-assoc-n v i x) cvar f src-info)))))
This won’t be very fun to use since the assertion doesn’t
print an informative string. Let’s fix that by adding
contract-fail-str
:
(defn contract-fail-str [x {:keys [ns name]} src-info]
(str x " fails vector contract " (symbol (str ns) (str name))
" specified at " (:file src-info) ":" (:line src-info)))
Now let’s rewrite add-contract*
:
(defn add-contract*
([v cvar src-info] (add-contract* v cvar @cvar src-info))
([v cvar f src-info]
(specify v
ISeqable
(-seq [_]
(letfn [(check [x]
(assert (f x)
(contract-fail-str x (meta cvar) src-info))
x)]
(map check v)))
ICollection
(-conj [_ x]
(assert (f x) (contract-fail-str x (meta cvar) src-info))
(add-contract* (-conj v x) cvar f src-info))
IVector
(-assoc-n [_ i x]
(assert (f x) (contract-fail-str x (meta cvar) src-info))
(add-contract* (-assoc-n v i x) cvar f src-info)))))
Much nicer!
Now the only problem is providing source location information. Fortunately this trivial through a little macro sugar:
In a lazy-contract.core
macro file we write the following:
(ns blog.contracts.core)
(defmacro add-contract [v cvar]
`(blog.contracts.core/add-contract*
~v ~cvar ~(select-keys (meta &form) [:file :line])))
That’s it!
Now we can start a REPL and try to create a contract constrained vector:
To quit, type: :cljs/quit
ClojureScript Node.js REPL server listening on 54977
ClojureScript:cljs.user> (require '[blog.contracts.core :as c :include-macros true])
ClojureScript:cljs.user> (c/add-contract [2 4 6] #'even?)
[2 4 6]
This works fine. But if we try this we’ll get a sensible error:
ClojureScript:cljs.user> (conj (c/add-contract [2 4 6] #'even?) 7)
Error: Assert failed: 7 fails vector contract cljs.core/even? ...
Voila!
We’ll also get a sensible error even in simple cases like this:
ClojureScript:cljs.user> (c/add-contract [1 2 3] #'even?)
Error: Assert failed: 1 fails vector contract cljs.core/even?
This is because printing needs to traverse the vector!
Again if a program with a lazy contract fails the error will always tell you precisely where the contract was asserted.
Of the course the above could benefit from generalization but we already have a powerful lazy contract system without bothering with wrappers or having to dig into the implementation of any of the core data structures.
With very little effort ClojureScript programs can leverage powerful forms of runtime validation with precise provenance.
Happy hacking!