Clojure.Spec Workshop
Part 3 - How to write defn-like macros

qr code
ClojureRemote - February 2017
Yehonathan Sharvit

		  (str (js/Date.))
              

Who am I?

  • A mathematician
  • A pragmatic theorist
  • A coder
  • A freak of interactivity
  • Founded Audyx - an Audiology Startup with 30K LOCs in Clojurescript
  • Author of KLIPSE
  • A Clojure consultant

Agenda

  • specs for defn args
  • Conform/Unform loop
  • defn-like macros

KLIPSE - Code interactivity

Open this presentation in your computer for a collaborative live coding session: http://slides.klipse.tech/clojure-spec-cr17/part1.html#slide-3

(It works also on mobile)

qr code

The interactive code snippets are powered by KLIPSE. 🤗


	    (map inc [1 2 3])
	

defn args

Here is the structure of the args to defn:

Let's code it:


	      (s/def ::defn-args
	      (s/cat :name simple-symbol?
              :docstring (s/? string?)
              :meta (s/? map?)
              :bs (s/alt :arity-1 ::args+body
              :arity-n ::multi-args+body)))
	  

What is ::args+body?

  • A list of args - required
  • A pre and post map - optional
  • A body - required (might be empty)

		(s/def ::args+body
		(s/cat :args ::arg-list
		:prepost (s/? map?)
		:body (s/* any?)))
	    

What is ::multi-args+body?

A list of bodies


	      (s/def ::multi-args+body (s/cat :bodies (s/+ (s/spec ::args+body))))
	  

::arg-list

What is ::arg-list?

A vector made of:

Let's code it:


	      (s/def ::arg-list
	      (s/and
	      vector?
	      (s/cat :args (s/* ::binding-form)
              :varargs (s/? (s/cat :amp #{'&} :form ::binding-form)))))
	  

What is ::binding-form?

It could be one of:

  • Any symbol (except &)
  • A sequential destructuring form
  • A map destructuring form

	      (s/def ::binding-form
	      (s/or :sym ::local-name
              :seq ::seq-binding-form
              :map ::map-binding-form))
	  

::binding-form (cont.)

::local-name is any symbol except &


	    (s/def ::local-name (s/and simple-symbol? #(not= '& %)))
	

::seq-binding-form is a vector made of:

  • A sequence of ::binding-form (recursive definition)
  • A & followed by a ::binding-form - optional
  • A :as followed by a ::local-name - optional

Examples:

  • [x y z]
  • [x y & z]
  • [x y :as args]

Not so hard to code


	      (s/def ::seq-binding-form
	      (s/and vector?
              (s/cat :elems (s/* ::binding-form)
              :rest (s/? (s/cat :amp #{'&} :form ::binding-form))
              :as (s/? (s/cat :as #{:as} :sym ::local-name)))))
	  

::map-binding-form

::map-binding-form is a hybrid spec. It is a map with a repetition of either:

Regular map destructuring

It's a 2-element vector (a.k.a. a tuple) made of:

  • a ::map-binding (pay attention to the recursion!)
  • anything

Easy to to code


	      (s/def ::map-binding (s/tuple ::binding-form any?))
	  

Test (we have to wait until :map-binding is fully defined)


	      (s/conform ::map-binding '[abc "abc"])
	  

::map-binding-form (cont.)

Special map destructuring

The optional keys are keys, syms, strs, as and or

A bit hard to code


	      (s/def ::keys (s/coll-of ident? :kind vector?))
	      (s/def ::syms (s/coll-of symbol? :kind vector?))
	      (s/def ::strs (s/coll-of simple-symbol? :kind vector?))
	      (s/def ::or (s/map-of simple-symbol? any?))
	      (s/def ::as ::local-name)

	      (s/def ::map-special-binding
	      (s/keys :opt-un [::as ::or ::keys ::syms ::strs]))
	  

Test


	      (s/conform ::map-special-binding {:keys '[a b c] :or '{a 1 b 2}})
	  

::map-binding-form (cont.)

Namespaced keywords map destructuring

It must have either keys or syms - but qualified


	      (s/def ::ns-keys
	      (s/tuple
	      (s/and qualified-keyword? #(-> % name #{"keys" "syms"}))
	      (s/coll-of simple-symbol? :kind vector?)))
	  

Test


	      (s/valid? ::ns-keys [:foo/keys '[a b c]])
	  

::map-binding-form (last)

Combining all the pieces together

We need to combine the three kinds of specs

And prevent keys other thant the allowed ones


	      (s/def ::map-bindings
	      (s/every (s/or :mb ::map-binding
              :nsk ::ns-keys
              :msb (s/tuple #{:as :or :keys :syms :strs} any?)) :into {}))

	      (s/def ::map-binding-form (s/merge ::map-bindings ::map-special-binding))
	  

Test


	      (s/conform ::map-bindings '{a "aa"})
	  

	      (s/conform ::map-bindings '{:syms [a b c]})
	  

	      (s/conform ::map-bindings '{a "aa"
              {:xx/keys [a b x]} "x"})
	  

Invalid keys are not allowed


	      (s/conform ::map-bindings '{:kseys [a b x]})
	  

Testing defn-args

Simple function


	    (s/conform ::defn-args '(foo [x y _] (+ x y)))
	

A bit of destructuring


		(s/conform ::defn-args '(bar [[x y] {:keys [a b]}] (+ x y a b)))
	    

Multi-arity function - with a docstring and metadata


		(s/conform ::defn-args '(baz
                "bar is a multi-arity variadic function"
                {:private true}
                ([a b & c] (+ a b (first c)))
                ([] (foo 1 1))))
	    

Conform and unform

With conform you parse your data


	    (s/def ::my-spec (s/cat
            :first (s/alt :str string?
            :kw  keyword?)
	    :second number?))
	
	
	    (s/conform ::my-spec '(:a 1))	    
	

With unform you get your data back:


	      (s/unform ::my-spec {:first [:kw :a], :second 1})
	  

Usually, you can close the loop


	      (->> (s/conform ::my-spec '(:a 1))
	      (s/unform ::my-spec))
	  

Conform and unform - bugs and tricks

Sometimes conform and unform are not aligned:


	    (s/def ::a-tuple (s/and vector? (s/* any?)))
	    (->> (s/conform ::a-tuple [:a 1])
	    (s/unform ::a-tuple))
	

Fix with custom conformer:


	      (s/def ::my-spec-vec (s/and vector?
              (s/conformer vec vec)
              ::my-spec))
	  

Now they are aligned:


	      (->> (s/conform ::my-spec-vec [:a 1])
	      (s/unform ::my-spec-vec))
	  

spec for defn args - unform friendly

Don't be afraid by the length of the code 😱

Modifying the code of a function

3 stages:

  1. conform the args
  2. modify the conformed data
  3. unform back

A simple example


	      (defn foo [[a b]] (+ a b))
	  

We are going to add a docstring using spec

Adding a docstring

Let's conform the defn args:


	      (def conformed-args (s/conform ::defn-args '(foo [[a b]] (+ a b))))
	      conformed-args
	  

Modify them - adding a docstring:


	      (def modified-args (assoc conformed-args :docstring "foo is a cool function"))
	      modified-args
	  

Unform the modified args:


	      (s/unform ::defn-args modified-args)
	  

Put back into defn:


		(defn foo "foo is a cool function" [[a b]] (+ a b))
	    

defndoc

Let's write our own defndoc macro.

defndoc is like defn but it prepends to the docstring a sentence that contains the name of the function


		(defmacro defndoc [& args]
		(let [conf (s/conform ::defn-args args)
		name (:name conf)
		new-conf (update conf :docstring #(str name " is a cool function. " %))
		new-args (s/unform ::defn-args new-conf)]
		(cons `defn new-args)))
	    

Let's see it in action.

When no docstring is provided


	      (my.m/defndoc fooz [a b] (+ a b))
	      (:doc (meta #'foo))
	  

When a docstring is provided


	      (my.m/defndoc baz "sum of a and b." [a b] (+ a b))
	      (:doc (meta #'baz))
	  

Too easy for you?

Let's do something much more fun.

defnlog

defnlog is like defn but it prints a log each time it is called

Now we have to modify the body of the original function

No worries! In clojure, code is data 😊

First piece: prepend-log

prepend-log receives a body and a function name and prepends to the body a call to (println func-name "has been called"):


	      (defn prepend-log [name body]
	      (cons `(println ~name "has been called.") body))
	  

Test


		(prepend-log 'foo '((print a) (+ a b)))
	    

Second piece: update-conf

update-conf updates the body of a conformed ::defn-args.

This is a bit tricky because the shape of the confomed object is different if the function has single-arity or multi-arity.


	      (s/conform ::defn-args '(foo [a b] (* a b)))
	  

	      (s/conform ::defn-args '(bar 
              ([] (* 10 12))
              ([a b] (* a b))))
	  

But we can do it 💪


	      (defn update-conf [{[arity] :bs :as conf} body-update-fn]
	      (case arity
	      :arity-1 (update-in conf [:bs 1 :body] body-update-fn)
	      :arity-n (update-in conf [:bs 1 :bodies] (fn [bodies]
              (map (fn [body] (update body :body body-update-fn)) bodies)))))
	  

Test


	      (update-conf (s/conform ::defn-args '(bar 
              ([] (* 10 12))
              ([a b] (* a b)))) (partial prepend-log 'bar))
	  

The Grand Finale

The Grand Finale

And now, for the grand finale, please welcome defnlog!!!!


	    (defmacro defnlog [& args]
	    (let [{:keys [name] :as conf} (s/conform ::defn-args args)
            new-conf (update-conf conf (partial prepend-log  (str name)))
            new-args (s/unform ::defn-args new-conf)]
	    (cons `defn new-args)))
	

Let's see it in action

First, with a single arity function


	      (my.m/defnlog trivial-multiplication "Multiplies `a` and `b`" [a b] (* a b))
	  

Are you ready?


	      (trivial-multiplication 13 4)
	  

And now, with a multi-arity function


	      (my.m/defnlog special-multiplication
	      "Multiplies `a` and `b` with a tweak"
	      ([] (* 12 56))
	      ([a] (* a 42))
	      ([a b] (* a b)))
	  

		(special-multiplication)
	    

References

Questions?

Want more?

Come back next year!

powered by KLIPSE /