dispacio implements polymethod
s which provide a very simple predicate dispatch system for Clojure and Clojurescript.
It’s similar to multimethod
s but lets each polymethod
add its own dispatch function, rather than the multimethod
strategy of adding static values to be matched against the return value of a single dispatch function.
Unlike more advanced predicate dispatch systems, dispacio
doesn’t provide much inference between different predicates, instead opting for the simplest predicative dispatching system possible - a stack of predicates tested in the opposite order they are defined.
However, dispacio
does provide multimethods
’s ability to use isa?
hierarchies and to prefer
one over the other. (see defmethod hierarchy documentation here)
A Canonical Example
Let’s look at an 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)
As you can see, as opposed to defmethod
s, we have the liberty here to build dispatches on arbitrary data shapes. defmethod
requires that either all your data is consistent or that you account for all possible inconsistencies in the dispatch function provided to the multifn
.
So 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 same species condition, (= (:Species %1) (:Species %2))
, will catch all cases where the species is the same, causing them to :mate
.
The :fight
ing 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.
polymethod
s 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.
Takeaway 1: Prior polymethod
s can potentially be shadowed by the logic of later polymethod
s
We may want a more general case to short-curcuit the rest of the stack, like we did with the :mate
ing example. We could have defined the :mate
example 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.
Takeaway 2: Therefore, more general polymethod
s should be defined before more specific polymethod
s so that more specific one’s don’t become completely shadowed
While
polymethod
s provide more liberty to the method definition, with that liberty comes greater responsibility - you have to be aware of the logical scope of yourpolymethod
s throughout your app. In the future, we may optionally provide a warning to callers when a given set of arguments catches on more than onepolymethod
, to help the developers keep their predicates logically exclusive, if that’s a desired feature.
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.
I’d be interested in hearing about any novel algorithms you could think up using this method.
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.
Note that, as with
multimethod
s, some namespace must:require
in those namespaces implementingpolymethod
s for them to be registered. And, if your predicates are not logically exclusive, they must be:require
d in the right order that you want them to be tried in. Therefore, for a large app with lots ofpolymethod
s, it might be useful to have an auxiliary namespace dedicated to requiring in namespaces in the desiredpolymethod
definition order, loaded early in one of yourcore
ormain
namespaces.
Spec Validation Dispatch
You can also dispatch off of data validity via 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 :cat/breed string?)
(s/def :animal/dog (s/merge :animal/common
(s/keys :req [:dog/tail? :dog/breed])))
(s/def :animal/cat (s/merge :animal/common
(s/keys :req [:cat/breed])))
With specs defined, make your polymethods:
(defp make-noise #(s/valid? :animal/dog %)
[animal]
(println "A" (:dog/breed animal) "barks" (:animal/says animal)))
(defp make-noise #(s/valid? :animal/cat %)
[animal]
(println "A" (:cat/breed animal) "meows" (:animal/says animal)))
And then you can dispatch on valid data:
(make-noise
{:animal/kind "dog"
:animal/says "woof"
:dog/tail? true
:dog/breed "retriever"})
;#_=> A retriever barks woof
(make-noise
{:animal/kind "cat"
:animal/says "brrr"
:cat/breed "siamese"})
;#_=> A siamese meows brrr
Fun for when you want to experiment with different dispatch strategies based on the truthiness of arbitrary functions.
If your system boundaries are already validating inputs, using data validation for dispatch further down your call chain might be an interesting approach. I haven’t tried it yet, but if I do I’ll report back here.
When to use this?
If you have a problem that can be solved by using multimethod
s, I’d still recommend using them over these polymethod
s. Due to the higher liberty/responsibility quotient of polymethod
s, you should only reach for them when you have a dispatch problem that doesn’t fit into multimethod
’s single dispatch function constraint.
Conclusion
So that’s a general intro. Some of this tutorial was taken from another answer here on Clojureverse, before I realized I’d never introduced the library here officially.
More details are in the readme at the repo. Give it a spin and kick the tires and let me know what you think. I’ll probably be taking the alpha
tag off in the next few weeks if no major issues crop up, after I get the kondo linting cleaned up, release it on clojars and get an official announcement up.