Should we prefer dynamic behaviour?

Clojure is a dynamic programming language. This is a huge plus in my opinion. It really makes fast development easy. Especially when fishing in troubled waters for a rapid prototype (or when you are a lone fighter).

However dynamic behaviour usually comes at a runtime cost. Clojure provides several means to gain speed at cost of its dynamic nature. One example are type hints. Another – not so obvious – example are inline definitions of protocol functions in a defrecord. What are the trade-offs? And what should be the default?

All roads lead to extended protocols

There are basically two different way to extend a protocol. Let's consider an (as of now hypothetical) seq protocol of Clojure's sequences.

(defprotocol Sequable
  (seq [coll] "Returns a sequence view on the collection"))

(defprotocol Sequence
  (first [s] "Returns the first item of the sequence")
  (rest  [s] "Returns the rest of the sequence, () in case there is no rest")
  (next  [s] "Returns the rest of the sequence, nil in case there is no rest"))

The first way to extend this protocol is to specify the corresponding functions inline in the defrecord definition.

(defrecord RecordSeq [rec ks]
  Sequence
  (first [this] ((first ks) rec))
  (rest  [this] (or (next this) ()))
  (next  [this] (when-let [rest-ks (next ks)]
                  (RecordSeq. rec rest-ks))))

(defrecord Record [a b c]
  Sequable
  (seq [this] (RecordSeq. this [:a :b :c])))

The second is to provide a map of functions, which implement the functions of a protocol for a given type.

(defrecord RecordSeq [rec ks])

(extend ISeq
  RecordSeq
  {:first (fn [this] ((first (:ks this)) (:rec this)))
   :rest  (fn [this] (or (next this) ()))
   :next  (fn [this] (when-let [rest-ks (next (:ks this))]
                       (RecordSeq. (:rec this) rest-ks)))})

So. What are actually the differences? Well, the second is much more dynamic than the first variation, but the first is faster because it directly calls the methods via the protocol interface. So what do we gain by specifying the methods in a map and not inline? Since it is slower, that can only be bad, can't it?

Default method implementations

Well, actually no. We gain actually a lot by staying dynamic. For example we can provide default implementations for the methods.

Have a look at rest. In this example it is (as in c.l.ASeq) defined by virtue of next. However we would have to repeat its definition every time defining a new seq type if there was no more clever way to handle rest. *Assuming the new seq type would be happy with the default, of course.*

Using extend – however – we can define default implementations and merge in our specialised variants. We can even go the route of Haskell and provide several default implementations defined in terms of each other. The actual type just has to provide one of them to make things work.

(def sequence-default-methods
  {:rest (fn [this] (or (next this) ()))
   :next (fn [this] (seq (rest this)))})

(extend ISeq
  RecordSeq
  (merge sequence-default-methods
         {:first (fn [this] ((first (:ks this)) (:rec this)))
          :next  (fn [this] (when-let [rest-ks (-> this :ks next)]
                              (RecordSeq. (:rec this) rest-ks)))}))

Mutually recursive types

Another point we get for free using the dynamic extend is mutually recursive types. If we specify the functions inline we need a factory for at least one type.

(defprotocol Flipable
  (flip [thing]))

(declare new-Right)

(defrecord Left [x]
  Flipable
  (flip [this] (new-Right x)))

(defrecord Right [x]
  Flipable
  (flip [this] (Left. x)))

(defn new-Right
  [x]
  (Right. x))

Depending on whether you use factory functions or not this might be annoying. With extend there is no need for factory functions since we can define the types separately from the implementation of the protocols.

(defrecord Left  [x])
(defrecord Right [x])

(extend-protocol Flipable
  Left
  (flip [this] (Right. (:x this)))
  Right
  (flip [this] (Left. (:x this))))

Other aspects

Protocols are not the only place where we have a trade-off of static vs. dynamic. There are several places where such a trade-off can be spotted.

  • type hints: So after type hinting everything with A, you notice, that you want to plugin in a B.
  • AOT compilation: since „we did it always that way“ everything gets AOT compiled. But suddenly a user of your module requires a different clojure version.

Upshot

There is a trade-off in static vs. dynamic. static is faster. But is it necessary? Most people won't be able to answer this question, because they never did the necessary research. They just assume it. Clearly one should answer this thoroughly before jumping through hoops just to gain speed, which is eaten up by a bad algorithm.

I think one shouldn't be afraid to use the dynamic features if it makes code more concise or easier to understand. If concerns like above don't apply or if the speed gain is really worth the trouble one has always the option of deploying static features.

Published by Meikel Brandmeyer on .