Dispacio: Predicate Dispatch for Clojure/Script 🚉

dispacio implements polymethods which provide a very simple predicate dispatch system for Clojure and Clojurescript.

It’s similar to multimethods 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 defmethods, 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 :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.

Takeaway 1: Prior polymethods can potentially be shadowed by the logic of later polymethods

We may want a more general case to short-curcuit the rest of the stack, like we did with the :mateing 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 polymethods should be defined before more specific polymethods so that more specific one’s don’t become completely shadowed

While polymethods provide more liberty to the method definition, with that liberty comes greater responsibility - you have to be aware of the logical scope of your polymethods throughout your app. In the future, we may optionally provide a warning to callers when a given set of arguments catches on more than one polymethod, 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 multimethods, some namespace must :require in those namespaces implementing polymethods for them to be registered. And, if your predicates are not logically exclusive, they must be :required in the right order that you want them to be tried in. Therefore, for a large app with lots of polymethods, it might be useful to have an auxiliary namespace dedicated to requiring in namespaces in the desired polymethod definition order, loaded early in one of your core or main 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 multimethods, I’d still recommend using them over these polymethods. Due to the higher liberty/responsibility quotient of polymethods, 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.

5 Likes

(coming from Choosing the right kind of function dispatch :grinning:)

Wow, nice library @John_Newman! I particularly appreciate the good documentation (especially useful for alpha versions of libraries, I find).

One thing I love about dispacio is how defp accepts a higher-order function; it seems to fit really well into the dominant Clojure style and the function programming paradigm in general.

One question about the project’s licence - did you intend it to be under the MIT license or the EPL-1.0? I can find the text of the EPL-1.0 in the repository, but it’s (mis?) labelled as MIT. My preference is for the MIT one :wink:

Thanks for the kind words @seabass!

Hmm, that’s a good question. I don’t even remember why I had the MIT license there - honestly, I don’t put a lot of thought into the licensing. Perhaps I read this article from Juxt: Prefer the MIT License about preferring MIT over EPL for random projects. I basically just want it to be usable anywhere. One question I might have is about what they say in the article here:

MIT is a poor default for anyone who knows in advance they will definitely borrow or modify functions from existing EPL-licensed projects, like Clojure Core, babashka/sci, or Medley.

Dispacio tries hard to have a similar feel as using defmethods, allowing you to reuse your memory-muscles there. For that purpose, it also reuses some constructs in Clojure that defmethods use, like hierarchies. Am I “borrowing” from clojure core there? I’m not copying code, but I’m using public interfaces to provide a similar feel to an existing interface - I’d think all Clojure library and application code does that to some extent though.

Pending any objections, I’ll just switch it to MIT as that seems to have a wider superset of usability and, from what I understand, EPL code can freely use it and/or copy it.

2 Likes

Brilliant; thanks @John_Newman!

In answer to your question, merely having a similar interface to another program doesn’t make it a derivative work, so you still hold all the copyright as its author. Thus, using the same function signature as Clojure core does not automatically make your program subject to Clojure’s licensing terms. Since you’re the author and copyright holder, you could choose to make it subject to the same terms if you want, but you’ve chosen the MIT terms instead, which is a much better choice in my opinion! :grinning:

If you ever do need to copy code directly, it’s still not the end of the world - you can relicense to EPL-1.0 after all. [1]

You might want to read up on the Oracle vs Google case in the USA, if you haven’t already:

The result of this lawsuit means interfaces might still be copyrightable (it wasn’t covered in the ruling) but if Google’s use can be considered ‘fair use’, it’s pretty likely than any other API or function signature compatibility would also pass. In my opinion, interfaces aren’t copyrightable in my country; for free/open source software’s sake I’ll just hope that the judiciary agree should it ever come up in a case :slight_smile:

Also, there’s still some light controversy as to whether ‘linking’ code forms a derivative work (linking in the sense of natively-compiled languages like C). This is because linking requires the actual copyrighted, third-party header file to be processed by the compiler. The general consensus of the development Linux community is not to support any GPL violation lawsuit based on this alone, I understand.

Alright, I’m going to pull myself away from this box now - I could write about licensing stuff for days :smiley: Always happy to answer any more questions you might have!

[1] Since I see @borkdude has made a commit to dispacio and is thus a co-author, to relicense away from the MIT license, either 1: he must agree to any licensing changes for them to be valid or 2: you retain the MIT text and any of his MIT copyright statements (he doesn’t seem to have included any though).

1 Like

Ay caramba! lol, well, I actually only accidentally had an EPL label set on the repo in github, while I had a (longer) MIT license already contained in the repo here: Delete LICENSE · johnmn3/dispacio@fd1b770 · GitHub

So hopefully I didn’t step on any license toes :sweat_smile:

I’ll ping @borkdude soon just to make sure. Thanks for explaining that.

1 Like

You have my blessing :slight_smile:

3 Likes

Such a dick move by Oracle, there!

2 Likes

This topic was automatically closed 182 days after the last reply. New replies are no longer allowed.