Liking cljdoc? Tell your friends :D

dispacio

dispacio is an "predicate stack dispatch system" for Clojure/Script.

Table of Contents

What

dispacio is a very simple predicate dispatch system for Clojure and Clojurescript.

Predicate dispatch systems usually offer a comprehensive mechanism that helps users efficiently match against different aspects of arguments being passed to polymorphic functions.

In predicate stack dispatch, on the other hand, dispatch predicates are tried in the opposite order that they are defined and the first to return a truthy value wins.

While dispacio provides no sophisticated matching conveniences out of the box, you do get most of what multimethods provides you: isa? hierarchies and prefering one polymethod over another explicitly.

Getting Started

:deps  {net.clojars.john/dispacio {:mvn/version "0.1.0-alpha.4"}}

For the purposes of this tutorial, require dispacio in a REPL and refer defp.

(require '[dispacio.alpha.core :refer [defp]])

Polymethods

Polymethods are similar to Clojure's multimethods. In Clojure, multimethod definitions take static values to be matched against the return value of a single dispatch function, provided in a defmulti.

(defmulti my-inc class)
(defmethod my-inc Number [n] (inc n))

With polymethods each method can have its own dispatch function.

(defp my-inc number? [x] (inc x))
(my-inc 1)
;#_=> 2

1 is passed directly to the number? function.

Unlike with defmethods, all params are passed to each polymethod's dispatch function.

A Canonical Example

Let's look at a more involved example, inspired by the Clojure documentation on multimethods.

First, a helper function:

(defn are-species? [& animals-species]
  (->> animals-species
       (partition 2)
       (map (fn [[animal species]] (= species (:Species animal))))
       (into #{})
       (= #{true})))

Then, a series of encounters based on heterogeneous conditions:

(defp encounter #(are-species? %1 :Bunny %2 :Lion)
  [b l]
  :run-away)

(defp encounter #(and (:tired %1)
                      (are-species? %1 :Bunny %2 :Lion))
  [b l]
  :hide)

(defp encounter #(are-species? %1 :Lion %2 :Bunny)
  [l b]
  :eat)

(defp encounter #(and (:tired %1)
                      (are-species? %1 :Lion %2 :Bunny))
  [l b]
  :play)

(defp encounter #(= (:Species %1) (:Species %2))
  [b1 b2]
  :mate)

(defp encounter #(and (or (:angry %1) (:angry %2))
                      (are-species? %1 :Lion %2 :Lion))
  [l1 l2]
  :fight)

Then let's try it out:

(def b1 {:Species :Bunny :tired true})
(def b2 {:Species :Bunny :other :stuff})
(def l1 {:Species :Lion :tired true})
(def l2 {:Species :Lion :angry true})

(encounter b1 b2)
;#_=> :mate
(encounter b1 l1)
;#_=> :hide
(encounter b2 l1)
;#_=> :run-away
(encounter l1 b1)
;#_=> :play
(encounter l2 b1)
;#_=> :eat
(encounter l1 l2)
;#_=> :fight
(encounter l1 (assoc l2 :angry false))
;#_=> :mate

Notice that a polymethod's predicate functions are evaluated in the opposite order they are defined. In the example above, the condition (= (:Species %1) (:Species %2)) will catch all cases where the species is the same, causing them to :mate. The :fighting lions condition is then defined, which shadows the same species logic of the prior condition, but only when a lion is angry.

This is an important distinction between a polymethod and a defmethod: The static values associated with a defmethod, checked against the return value of a defmulti, make a set that will match exclusively of one another. polymethods on the other hand can be defined disjointly or their predicative scope can overlap. A more generally defined predicate can shadow a more specifically defined predicate if it was defined after the more specifically defined predicate. Therefore, it is probably best to define your more general, catch-all predicates earlier rather than later. That is, unless we want a more general case to short-curcuit the rest of the stack, like we did with the :mateing example - we could have defined that one first, but this way we don't have to check all the other predicates before deciding to :mate. Useful for when you're in a rush. However, our :tired scenarios had to be defined after their more general :run-away and :eat scenarios, otherwise they would have been fully shadowed and prevented from catching the condition.

Mutual Recursion

There's lots of interesting things you can do with predicate dispatch. Here's a cool recursive definition of zipmap I copped from this paper on predicate dispatch:

(defp zip-map #(or (empty? %1) (empty? %2))
  [_ _]
  nil)

(defp zip-map #(and (seq %1) (seq %2))
  [a b]
  (apply merge
         {(first a) (first b)}
         (zip-map (rest a) (rest b))))

(zip-map (range 10) (range 10))
;#_=> {0 0, 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 5 5, 8 8}

Now, you wouldn't want to actually do that to replace actual zipmap, as it'll run slower and you'll blow your stack for large sequences. But the point is that you can construct mutually recursive definitions with polymethods to create interesting algorithms.

Across Namespaces

Imagine you want to expose a low-level email function and a high level polymethod from a namespace called emails:

(ns emails
  (:require [dispacio.alpha.core :refer [defp]]))

(defn post-email! [email]
  (println :sending-email :msg (:msg email)))

(defp send! :poly/default
  [email]
  (println :don't-know-what-to-do-with-this email))

You can then implement the polymethods across namespaces of different domains:

(ns promotion
  (:require [dispacio.alpha.core :refer [defp]]
            [emails :as emails]))

(defp emails/send! #(-> % :email-type (= :promotion))
  [email]
  (emails/post-email!
   (assoc email
          :msg (str "Congrats! You got a promotion " (:name email) "!"))))
(ns welcome
  (:require [dispacio.alpha.core :refer [defp]]
            [emails :as emails]))

(defp emails/send! #(-> % :file-type namespace (= "welcome"))
  [email]
  (emails/post-email!
   (assoc email
          :msg (str "Welcome! Glad you're here " (:name email) "!"))))
(ns confirmation
  (:require [dispacio.alpha.core :refer [defp]]
            [emails :as emails]))

(defp emails/send! #(-> % :file-kind (= :confirmation))
  [email]
  (emails/post-email!
   (assoc email
          :msg (str "Confirmed! It's true " (:name email) "."))))

And then you could just call the email namespace from jobs namespace or whatever:

(ns jobs
  (:require [emails :as emails]))

(def files ; <- dispatching on heterogeneous/inconsistent data
  [{:file-kind  :confirmation :name "Bob"}
   {:file-type  :welcome/new  :name "Mary"}
   {:email-type :promotion    :name "Jules"}])

(->> files (map emails/send!))

;#_=> :sending-email :msg Confirmed! It's true Bob.
;#_=> :sending-email :msg Welcome! Glad you're here Mary!
;#_=> :sending-email :msg Congrats! You got a promotion Jules!
;#_=> (nil nil nil)

This gives you the cross-namespace abilities of defmethods with the flexibility of arbitrary predicate dispatch.

Troubleshooting

Let's go back to our my-inc example.

Imagine we pass in some mysterious data.

(my-inc "1")
;#_=> Execution error (ExceptionInfo) at dispacio.core/poly-impl (core.clj:75).
;No dispatch in polymethod user/eval253$my-inc for arguments: "1"

We can see the error is thrown by poly-impl because the poly my-inc has no method for the argument "1".

Let's give my-inc some default behavior so that we can diagnose this anomoly.

(defp my-inc :poly/default [x] (inc x))
;#_=> #object[user$eval253$my-inc__254 0x2b95e48b "user$eval253$my-inc__254@2b95e48b"]
(my-inc "1")
;#_=> Execution error (ClassCastException) at user/eval268$my-inc>poly-default>x (REPL:1).
;java.lang.String cannot be cast to java.lang.Number

Mmmm, we're passing a string to something that expects a number...

Notice that reference to user/eval268$my-inc>poly-default>x attempts to inform us which polymethod threw the error. Specifically, it was the one named my-inc, with a predicate of :poly/default, translated to poly-default, and an argument of x.

With this information, we can tell that the default implementation we just created is passing the error java.lang.String cannot be cast to java.lang.Number.

Let's add a new implementation for strings.

(defp my-inc string? [x] (inc (read-string x)))
;#_=> #object[user$eval253$my-inc__254 0x2b95e48b "user$eval253$my-inc__254@2b95e48b"]
(my-inc "1")
;#_=> 2

That's better.

But what about multiple arguments? Just make sure your dispatch function conforms to the manner in which you're passing in arguments.

(defp my-inc
  #(and (number? %1) (number? %2) (->> %& (filter (complement number?)) empty?))
  [x y & z]
  (inc (apply + x y z)))
;#_=> #object[user$eval253$my-inc__254 0x2b95e48b "user$eval253$my-inc__254@2b95e48b"]
(my-inc 1 2 3)
;#_=> 7
(my-inc 1 2 3 "4")
;#_=> Execution error (ArityException) at dispacio.core/poly-impl (core.clj:73).
;Wrong number of args (4) passed to: user/eval268/my-inc>poly-default>x--269

Because we are not catching strings on more than one argument, the last call took the default path, which we can see takes only one argument, x.

Deriving isa? Dispatch

Similar to multimethods, we can use Clojure's isa? hierarchy to resolve arguments.

(derive java.util.Map ::collection)
;#_=> nil
(derive java.util.Collection ::collection)
;#_=> nil

NOTE: Always put predicate parameters in a vector when you want arguments resolved against isa?.

(defp foo [::collection] [c] :a-collection)
;#_=> #object[user$eval301$foo__302 0x3f363cf5 "user$eval301$foo__302@3f363cf5"]
(defp foo [String] [s] :a-string)
;#_=> #object[user$eval301$foo__302 0x3f363cf5 "user$eval301$foo__302@3f363cf5"]

Ad hoc hierarchies for dispatch a la carte!

(foo [])
;#_=> :a-collection

(foo "bob")
;#_=> :a-string

Prefer Dispatch Functions

As with multimethods, we can prefer some dispatch functions over others.

(derive ::rect ::shape)
;#_=> nil
(defp bar [::rect ::shape] [x y] :rect-shape)
;#_=> #object[user$eval325$bar__326 0x366ef90e "user$eval325$bar__326@366ef90e"]
(defp bar [::shape ::rect] [x y] :shape-rect)
;#_=> #object[user$eval325$bar__326 0x366ef90e "user$eval325$bar__326@366ef90e"]
(bar ::rect ::rect)
;#_=> :rect-shape

We didn't pass in an exact match but, because ::rect derives from ::shape, we could match on both. The first dispatch function we find, in the order we made them, that matches the arguments will return its implementation. But what if, for this poly, we wanted the :shape-rect implementation?

To override the default behavior of the hierarchy, use prefer.

(prefer bar [::shape ::rect] [::rect ::shape])
;#_=> nil
(bar ::rect ::rect)
;#_=> :shape-rect

See the official docs on multimethods for more context.

Spec Validation Dispatch

Let's try an example from Clojure's docs on spec.

(require '[clojure.spec.alpha :as s])
(s/def :animal/kind string?)
(s/def :animal/says string?)
(s/def :animal/common (s/keys :req [:animal/kind :animal/says]))
(s/def :dog/tail? boolean?)
(s/def :dog/breed string?)
(s/def :animal/dog (s/merge :animal/common
                            (s/keys :req [:dog/tail? :dog/breed])))

We can leverage spec hierarchies to do very complex dispatching.

(defp make-noise (partial s/valid? :animal/dog)
  [animal]
  (println (-> animal :dog/breed) "barks" (-> animal :animal/says)))
;#_=> #object[user$eval373$make_noise__374 0x2b491fee "user$eval373$make_noise__374@2b491fee"]
(make-noise
  {:animal/kind "dog"
   :animal/says "woof"
   :dog/tail? true
   :dog/breed "retriever"})
;#_=> retriever barks woof
;nil

Function Extension

In Clojure, we usually extend data types to functions. With polymethods, we can shadow extend functions to arbitrary data types.

(defp inc string? [x] (inc (read-string x)))
;WARNING: inc already refers to: #'clojure.core/inc in namespace: user, being replaced by: #'user/inc
;#_=> #object[user$eval231$inc__232 0x75ed9710 "user$eval231$inc__232@75ed9710"]

Here, we are shadowing the #'clojure.core/inc function, while storing that original function as the default polymethod implementation.

(inc "1")
;#_=> 2
(inc 1)
;#_=> 2

Let's extend assoc to associate by index on strings:

(defp assoc string? [s i c] (str (subs s 0 i) c (subs s (inc i))))
;WARNING: assoc already refers to: cljs.core/assoc being replaced by: cljs.user/assoc at line 1 <cljs repl>
;#_=> #object[cljs$user$assoc]
(assoc "abc" 2 'x)
;#_=> "abx"

For now, you can :exclude core functions when referring Clojure in order to suppress var replacement warnings.

Choose Your Own Adventure

You could bring in core.logic, core.match, Datomic's datalog queries or any other number of inference systems to define your resolution strategy. The world's your oyster.

Bugs

If you find a bug, submit a Github issue.

Help

This project is looking for team members who can help this project succeed! If you are interested in becoming a team member please open an issue.

License

Copyright © 2018 John M. Newman III

Distributed under the MIT License. See LICENSE

Can you improve this documentation? These fine people already did:
John Newman & John Michael Newman III
Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close