Introducing clojure.spec

A presentation by Arne Brasseur

for ClojuTRE 2016.

10 September 2016

Especially for #ClojuTRE, sign up this weekend and get your first month free with this coupon link https://t.co/HkHoSxnkjP

— Lambda Island (@lambdaisland) September 9, 2016

 

https://lambdaisland.com/coupon/CLOJUTRE2016

clojure.spec

New library in Clojure 1.9 (now in alpha)

Somebody please think of the types!

Clojure is a dynamic language, for better or worse

Specs give you some of the benefits of a type system (+ more)

Interesting precedent for how a dynlang handles these concerns

Somebody please think of the types!

Main difference: runtime vs compile time checks

Specs are checked at runtime = overhead = only in dev env

But: macro expension checked at compile time

Clojure 1.9

Currently in alpha

(defproject robochef "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.9.0-alpha12"]])
(set-env!
  :dependencies '[[org.clojure/clojure "1.9.0-alpha12"]])

An example (bear with me)

We have a Robot Chef which works with recipes

(def tomato-sauce-recipe
  {:robochef/ingredients [250 :g "peeled tomatoes"
                          3 :clove "garlic"
                          5 :g "pepper"]
   :robochef/steps ["heat a pan"
                    "throw everything in"
                    "stir"]})

An aside: Namespaced keywords

user> :greetings/kiitos
:greetings/kiitos

A keyword containing a slash

Namespace doesn’t have to be loaded or even exist

Why namespaced keywords?

Avoid collissions

{:http/method :get
 :robochef/method :stir}

Stable semantics

{:robochef/recipe {,,,}}

Syntactical Sugar

robochef.core> :robochef.core/ingredients
:robochef.core/ingredients

Syntactical Sugar

robochef.core> :robochef.core/ingredients
:robochef.core/ingredients
robochef.core> ::ingredients
:robochef.core/ingredients

Syntactical Sugar

robochef.core> :robochef.core/ingredients
:robochef.core/ingredients
robochef.core> ::ingredients
:robochef.core/ingredients
user> (require '[robochef.core :as rc])
nil
user> ::rc/ingredients
:robochef.core/ingredients

Namespaced maps

Very common to have the same prefix for all keys

{:robochef/recipe-name "..."
 :robochef/ingredients [,,,]
 :robochef/steps [,,,]
 :robochef/cooking-time 30}

Namespaced maps

Very common to have the same prefix for all keys

{:robochef/recipe-name "..."
 :robochef/ingredients [,,,]
 :robochef/steps [,,,]
 :robochef/cooking-time 30}

New syntax for this in 1.9 \o/

#:robochef{:recipe-name "..."
           :ingredients [,,,]
           :steps [,,,]
           :cooking-time 30}

Namespaced maps

Destructuring support in 1.9 \o/

(def recipe #:robochef{:recipe-name "..."
                       :ingredients [,,,]
                       :steps [,,,]
                       :cooking-time 30})

(let [{:robochef/keys [steps serves]} recipe]
  (doseq [s steps]
    ,,,)

clojure.spec

Let’s get started!

Load spec in your namespace, aliased to s

(ns robochef.core
  (:require [clojure.spec :as s]))

An example Spec

;; keep in mind ::recipe == :robochef.core/recipe

(s/def ::recipe (s/keys :req [::ingredients]
                        :opt [::steps]))

(s/def ::ingredients (s/* (s/cat :amount number?
                                 :unit   keyword?
                                 :name   string?)))

(s/def ::steps ,,,)

This registers specs in a global registry

Basic usage

(s/valid? :robochef/ingredients [5 :g "tea"])
;;=> true

(s/conform :robochef/ingredients [5 :g "tea"])
;; [{:amount 5, :unit :g, :name "tea"}]

Invalid & explain

(s/valid? :robochef/ingredients ["10" :g "tea"])
;;=> false

(s/conform :robochef/ingredients ["10" :g "tea"])
;;=> :clojure.spec/invalid

(s/explain :robochef/ingredients ["10" :g "tea"])
;; In: [0] val: "10" fails spec:
;;   :robochef/ingredients at: [:amount] predicate: number?

Generators!

(s/exercise :robochef/ingredients 2)
;; ([() []]
;;  [(0 :Hi "0") [{:amount 0, :unit :Hi, :name "0"}]])

Spec types

Predicate

map?

Spec object

(s/or :s string?, :n number?)
(s/coll-of number? :kind vector?)

Name of a registered spec

:robochef/ingredients

New predicates \o/

bigdec?             any?
double?             seqable?
int?                indexed?
nat-int?            ident?
neg-int?            qualified-ident?
pos-int?            qualified-keyword?
boolean?            qualified-symbol?
bytes?              simple-ident?
uri?                simple-keyword?
uuid?               simple-symbol?

Including generators \o/

Creating specs

Two “advanced” types of specs:

Spec’ing maps

Done with s/keys

(def tomato-sauce-recipe
  {:robochef/ingredients [250 :g "peeled tomatoes"
                          3 :clove "garlic"
                          5 :g "pepper"]
   :robochef/steps ["stir"]})

(s/def :robochef/recipe (s/keys :req [:robochef/ingredients]
                                :opt [:robochef/steps]))

(s/def :robochef/ingredients ,,,)

(s/def :robochef/steps ,,,)

Spec’ing maps

Most systems for specifying structures conflate the specification of the key set with the specification of the values designated by those keys. This is a major source of rigidity and redundancy.
— Rich Hickey

 

s/keys will look at every key in a map, try to find a spec with that name, use it to validate the corresponding value

s/keys “naturally extensible”

(s/def :robochef/recipe (s/keys))

(def recipe {:robochef/ingredients [,,,]
             :robochef/steps [,,,]
             :dinnerparty/serves 6})

(s/def :dinnerparty/serves pos-int?)

Spec’ing sequences

Clojure data structures all share an underlying “sequence” abstraction.

clojure.spec contains full regular expression engine for dealing with these.

Regexp specs

Five “Regex” operators: *, +, ?, cat, alt

(s/conform (s/* keyword?) [])      ;;=> []
(s/conform (s/* keyword?) [:a])    ;;=> [:a]
(s/conform (s/* keyword?) [:a :b]) ;;=> [:a :b]

(s/conform (s/+ keyword?) [])      ;;=> :clojure.spec/invalid
(s/conform (s/+ keyword?) [:a])    ;;=> [:a]
(s/conform (s/+ keyword?) [:a :b]) ;;=> [:a :b]

(s/conform (s/? keyword?) [])      ;;=> nil
(s/conform (s/? keyword?) [:a])    ;;=> :a
(s/conform (s/? keyword?) [:a :b]) ;;=> :clojure.spec/invalid

Regexp operators: cat

cat “concatentate” sequence items (first this, then that)

(s/conform (s/cat :num number?, :key keyword?) [5 :b])
;;=> {:num 5, :key :b}

Each item gets a name

The conformed result is a map that can easily be consumed with Clojure’s destructuring

Regexp operators: alt

alt distinguishes “alternatives” (either this, or that, like |)

(s/conform (s/alt :num number?,:key keyword?) [5])
;;=> [:num 5]
(s/conform (s/alt :num number?,:key keyword?) [:b])
;;=> [:key :b]

Each alternative gets a name

The conformed result is a MapEntry which can be used with core.match pattern matching

Regexp spec

(s/def :robochef/ingredients (s/* (s/cat :amount number?
                                         :unit   keyword?
                                         :name   string?)))

{:robochef/ingredients [250 :g "peeled tomatoes"
                        3 :clove "garlic"
                        5 :g "pepper"]}

[{:amount 250, :unit :g, :name "peeled tomatoes"}
 {:amount 3, :unit :clove, :name "garlic"}
 {:amount 5, :unit :g, :name "pepper"}]

Instrumenting functions

fdef lets you set specs on the arguments, return value, and the relationship between them.

(defn cook! [recipe]
  ,,,)

(s/fdef cook! :args (s/cat :recipe ::recipe)
              :ret  number?)

(s/instrument-all)
;; (s/unstrument-all)

Instrumenting functions

A macro is just a function that takes “code as data” and returns “code as data”

We can instrument it just like function

Macro-expension happens at compile time, so we get compile-time checks!

Since alpha11 macros let, if-let, when-let, defn, fn, ns are checked.

Test.check

(require '[clojure.test.check :as tc])
(require '[clojure.test.check.generators :as gen])
(require '[clojure.test.check.properties :as prop])

(def positive-recipe-prop
  (prop/for-all [r (s/gen ::recipe)]
                (>= (cook! r) 0)))

(tc/quick-check 100 positive-recipe-prop)
;; {:fail [{:robochef/ingredients (-2.0 :+.j/l*4 "")}],
;;  :smallest [{:robochef/ingredients (-1.0 :A "")}}}

Recap

FIN