Back in the Before Times, I went to a Haskell-flavored FP conference, where one of the speakers said something that blew my mind. Sadly, it seems that I didn’t write this up at the time (although I swear I wrote it somewhere… maybe in an internal company memo) and I’ve lost the details of who said it. If by some quirk of odds, it was you, dear reader, please let me know!
The speaker posed a question: Given the following function
signature–and no additional information at all–how many possible
implementations of f
are there?
f :: a -> a
For those who don’t read Haskell, it says “the function f takes an
argument of type a
and returns a value of type a
”. We have no
information about a
whatsoever, and that’s the key to the
koan. Without any information about type a
, f
cannot apply any
operation to the value it receives. It cannot create a new instance of
a
because it doesn’t know how to construct it. It cannot modify the
value because it doesn’t know any other function to apply. In fact,
the only possible implementation of f
is id
, the identity
function. If you give me an a
, all I can do is give it back to you.
At first glance, this seems trivial or even tautological. But there’s something profound under the surface. If, like me, you come from the dynamic side of FP or the world of OOP where you always have a base class with some operations available, this might need a little bit of unpacking. I don’t want to turn this into a Haskell tutorial (I would certainly get a lot of it wrong!) but a couple of ideas are necessary.
Suppose we wanted to say that a different function g
could operate on any integer. We
would then have a different signature:
g :: Integer -> Integer
In this case, g
can take any possible integer into any other
possible integer. Now we know way less about the implementation. It
could increment or decrement its argument. It could add 42 to odd
values and subtract 99 from even ones. If we’re working with 64 bit
integers, there are 18,446,744,073,709,552,000 implementations of this
function that just ignore their argument and return a constant!
We could also use typeclasses to indicate partial information about the values. Suppose we wanted to create a function h
that accepts values which can be compared and placed in an ordering. That would look something like :
h :: (Ord a) => a -> a
(I probably have the syntax wrong, but bear with me… it’s the idea that is important here.)
This says that h
can take any value of any type, as long as that
type is known to fulfill the requirements of the Ord
typeclass. Ord
is fairly minimal, it just requires that the type
implements a few operations like “less than” and “greater than”. (It
also brings in Eq
, but we don’t need that at the moment.)
Let’s get back to our original f :: a -> a
: By using the type
variable a
with zero additional information about a
, the compiler
enforces constraints on the implementer of f
. f
must accept any
value of any type whatsoever. f
is therefore not allowed to know
anything about the arguments. It cannot make any assumptions about the
values it will be called with. In a sense, f
is maximally
constrained because it has the least information possible about its
arguments.
On the flip side, a caller of f
is maximally liberated. Because the
implementation cannot make any assumptions, it also means the
implementation cannot impose any constraints on the caller. The caller
can pass any value it wants to. The caller can use f
in contexts
and situations that the implementer of f
never dreamed of.
I think this generalizes to a principle: Constrain the provider to liberate callers.
In my experience, elegant systems emerge when we have functions or modules that can be used in multiple contexts. So we would like to have multiple callers. But whatever functionality the provider offers can only be used in contexts that meet its assumptions. Callers are restricted to meeting the provider’s assumptions. Therefore assumptions in the provider constrain the caller. We should invert this: limit the provider’s ability to make assumptions thereby allowing callers to use it in various situations.
This extends to services across an enterprise, too. The more a provider is allowed to know about how it will be used, the narrower the ability to reuse or recompose the services will be. Extend that forward and backward along call chains in a distributed services environment, and you will see inflexibility set in as every caller of some service is itself a provider to others.
I think this is also linked to the phenomenon of single-use API definitions, where an API is written for a specific point-to-point interaction. The implementation inevitably makes too many assumptions about how it will be used. So you get an environment with a proliferation of APIs each with their own payload types.
Another related idea is selective amnesia. When designing an API to offer, you can choose to temporarily forget what you know about the caller. Instead think about how a second or third caller might want to invoke your service. This leads to an API that is “one notch more abstract” than you might otherwise design. (Before you shout YAGNI, please recall that we must weaken YAGNI across service boundaries.) Selective amnesia can help constrain the provider from making assumptions.