“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.
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
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
AtomicVector to cater for the different concurrency scenarios.
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
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
empty? will work automagically then.
So all is fine. Where is the issue?
The issue is the user of our API. She is annoyed to always have to
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
(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
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
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.
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
(defn get ([this k] (get this k nil)) ([this k not-found] (impl/get this k not-found)))
What did we gain?
getis still functional.
So both users – consumers and implementors – are now completely decoupled; their concerns are handled separately.
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.
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.
Thanks to Ogino-san there is now a japanese translation of this article.
Published by Meikel Brandmeyer on .