Separation of Concerns

Recent wisdom” has it, that protocol functions should be a low-level interface. Of course I didn't go with this statement in my ignorance. Luckily there is always a Christophe around to enlighten me.

This wisdom actually isn't “recent.” It is quite old and you can find it in many object-oriented language libraries. And as one additional level of indirection solves every problem, this boils down to the one cause of bad design: lack of separation of concerns.

Separation of concerns in Clojure …

Take for example the reference types. They are basically boxes where you can put stuff in. A given reference type is only concerned how you put stuff in. But not what that stuff actually is. A ref simply doesn't care whether you put a vector or a stringin it. But please only in a dosync block!

A vector on the other hand just cares for storing some elements in the sequential order of their insertion. Whether it is put into an atom or an agent... the vector doesn't care.

This culminates in Clojure's time model which clearly shows the orthogonality of the references type and the contained data. The key is of course immutability. If one was allowed to modify the vector in place, you would need different flavours like a TransactionalVector or an AtomicVector to cater for the different concurrency scenarios.

… and API design

Now, how does this apply to the original problem of whether protocol functions should be an implementation detail?

First of all, protocol functions are completely transparent to the user. So he doesn't see a difference when calling a protocol function compared to calling a normal function.

So we could go ahead and define the API for a hypothetical collection type.

(ns our.collection)

(defprotocol OurCollection
  (count [this])
  (get [this k not-found]))

(defn empty?
  [this]
  (zero? (count this)))

So we have three functions in our public API. empty? is a normal function implemented by virtue of count. The latter and get are protocol functions. For the user there is no visible difference in terms of usage of these functions.

Third-parties may now implement our collection abstraction by extending the protocol OurCollection to their specific types used for implementation. empty? will work automagically then.

So all is fine. Where is the issue?

Concerns of “the” user

The issue is the user of our API. She is annoyed to always have to pass nil to get. Many times this is sufficient and the extra parameter does clutter the code unnecessarily.

Being customer oriented we take the wish as order and modify our API to allow also a shorter call to get.

(ns our.collection)

(defprotocol OurCollection
  (count [this])
  (get [this k] [this k not-found]))

(defn empty?
  [this]
  (zero? (count this)))

And since the old call doesn't go away no old code will break. So everything is ok, right? Wrong! We just modified a protocol function. But the implementations have to change now also! A user calling the short form of get will get an exception when using an implementation targeted at the previous API version.

There is more than one user: there is the consumer and there is the implementor. The former is now happy, because her code gets clearer. But the latter gets mad at us! Not only does she have to add the new get arity, she also has to specify some default value everywhere, which she doesn't even care about.

We comforted one user on the cost of another one.

Note: Simply renameing the protocol function and replace get with a normal function as below shown doesn't help either. Then old implementations won't work with our new version at all. Thusly we break backwards compatibility in any case. We are hosed.

Separate things

The problem is, that we conflate the concerns of both types of users into one thing. Then changing one side will inevitably influence the other. We should have kept both sides separate from the very beginning. I've even put the protocol in its own namespace to make the separation clear.

(ns our.collection.protocols)

(defprotocol OurCollection
  (count [this])
  (get [this k not-found]))

(ns our.collection
  (:require [our.collection.protocols :as impl]))

(defn count
  [this]
  (impl/count this))

(defn get
  [this k not-found]
  (impl/get this k not-found))

(defn empty?
  [this]
  (zero? (count this)))

So what happens now, if we introduce the same change as before? All we have to do is change get.

(defn get
  ([this k]
   (get this k nil))
  ([this k not-found]
   (impl/get this k not-found)))

What did we gain?

  • Old consumer code is still compatible with the new API version, because the old arity of get is still functional.
  • The protocol didn't change. So there is no need for any implementor to change anything. All implementations will still work with the new version and magically profit from the new get.
  • There is now only one place where the default is named. It does not have to be repeated in completely unrelated implementation code.

So both users – consumers and implementors – are now completely decoupled; their concerns are handled separately.

Upshot

Think very carefully about separation of concerns. Not only when thinking about the actual functionality of a single function, but also when considering such meta information like concerns of the different stake holders of your product.

And in my case: start thinking at all. Christophe, thank you for enlightening me once more.

Post Scriptum

I wrote, that this wisdom isn't actually so “recent.” In fact you will find this used quite often in the OO world. You have an abstract base class, which implements an interface. You derive from this class and implement only a few required abstract methods and get the rest of the interface for free.

Translations

Thanks to Ogino-san there is now a japanese translation of this article.

Published by Meikel Brandmeyer on .