April 28, 2019

Polymorphism à la carte

Clojure gives you two ways of doing polymorphism: multimethods and protocols. Years ago I was asked how you determine when to use one over the other. I admittedly gave a poor answer, but the question never really left my mind as it is a good question to ask yourself. When should I use x over y? This is my serious attempt at answering the question. I hope it provides help.

Expression problem

Both multimethods and protocols are answers to the expression problem. At its core I think the expression problem can be more loosely described as: How do I change the functionality of x that's buried deep in the layers of my application without having to rewrite the application to support the new functionality? We wish to express a different intent.

Dispatch

Both multimethods and protocols work by dispatching on a function. For protocols it's very easy as they dispatch on the type of the first argument. Multimethods instead have a dispatch function provided.

If the data you send in to the function is a map, it's very typical to use a keyword as the dispatch function, which I found very confusing as a beginner. A multimethod's dispatch function can take any number of arguments and then return the value to dispatch on.

With multimethods we express a different intent depending on the type of animal.

(defmulti say-hi :animal)
(defmethod say-hi :cat [_]
  (println "Meow"))
(defmethod say-hi :dog [_]
  (println "Woof"))
(defmethod say-hi :default [animal]
  (println (str "The ghost of " (:name animal) " says OooOoooooOoOooooOooh.")))

(say-hi {:animal :dog :name "Spot"})
(say-hi {:animal :cat :name "Cheshire"})
(say-hi {:animal :mouse :name "Cheese"})

And with protocols we can also express a different intent depending on the type of animal. Notice that we have no way of defining a default intent.

(defprotocol ISayHi
    (say-hi [animal]))
(defrecord Cat [name]
  ISayHi
  (say-hi [animal]
    (println "Meow")))
(defrecord Dog [name]
  ISayHi
  (say-hi [animal]
    (println "Woof")))

(say-hi (map->Dog {:name "Spot"}))
(say-hi (map->Cat {:name "Cheshire"}))

What to pick?

Given these two approaches to the same problem, how do we know when to pick one over the other? Unfortunately there are no strict rules here, but I think it's possible to give some rough guidelines.

Pick multimethods if you want to

  • transform values
  • have a dispatch on something other than the type of the first argument

Pick protocols if you want to

  • group together functionality
  • perform something inherently stateful

Transform values

Multimethods are great for transforming values where you want the flexibility to add future transformations without having to go through a re-implementation. Examples could be transforming data depending on the contents of a map.

(defmethod complicated-fn (fn [a b]
                            (cond
                              (and (number? a) (number? b)) :numbers
                              (and (every? vector? [a b])
                                   (every? number? (flatten [a b]))) :dot-product
                              :else :unknown)))
(defmethod complicated-fn :numbers [a b]
  (+ a b))
(defmethod complicated-fn :dot-product [a b]
  (dot-product a b))
(defmethod complicated-fn :default [a b]
  (throw (ex-info "Not implemented" {:a a :b b})))

Dispatch on other than type

If you wish to dispatch on something other than the type of a value, then multimethods work really well.

(defmulti transformer (juxt :faction :name))
(defmethod transformer [:autobot "Optimus Prime"] [t]
  (assoc t :attributes #{:leader :noble}))
(defmethod transformer [:decepticon "Megatron"] [t]
  (assoc t :attributes #{:leader :warlord}))
(defmethod transformer [:decepticon "Starscream"] [t]
  (assoc t :attributes #{:ruthless :cruel}))
(defmethod transformer :default [t]
  (throw (ex-info "Not implemented" t)))

Group together functionality

If you have functionality that belongs together, but it needs to be polymorphic, then protocols are a great choice.

(defprotocol IOps
  (add [this k data])
  (move [this from to])
  (delete [this k]))

(defn- storage-swap [data from to]
  (let [v (get data from)]
    (-> data
      (dissoc from)
      (assoc to v))))

(defrecord OpsStorage [data]
  IOps
  (add [this k data]
    (swap! data assoc k data))
  (move [this from to]
    (swap! data storage-swap from to))
  (delete [this k]
    (swap! data dissoc k)))

(defn ops-storage
  [] (map->OpsStorage {:data (atom {})})
  [data] (map->OpsStorage {:data data}))

The above can be switched out for a file based implementation without the rest of the program being any wiser.

Perform something inherently stateful

IO operations fall under this, and tend to be great candidates for protocols as the code snippet for IOps above shows.

A second candidate that is maybe not so easily spotted would be control structures. Any program that goes beyond a certain size benefits greatly from an implementation that controls behaviour. Changing out the implementation controlling the behaviour of the program without having to do anything else with the program makes protocols a good fit for this particular problem.

Closing thoughts

The lines have blurred somewhat with Clojure 1.10 that allows for greater control over protocol implementations for generic Clojure data structures such as maps. Still, I think the above broadly still holds true. The above are what I would call rule of thumbs, or guidelines, and you are encouraged use what's approriate for the situation.

Tags: clojure