Combinator-style API
26.08.2013
Permalink
Over the last few months I unconsiously applied a new style of API
design to programs I wrote. I guess I adopted it because I've read
quite a lot of functional code and papers that uses this 'combinator
style'.
But only today, while discussing an API with a colleague, I became
fully aware that this style may seem unfamiliar to most Java people. I
can illustrate the situation with some snippets of Java code.
For a customer we had to implement a data validation library. An
important requirement was that the constraints can be selected very
individually by runtime configuration and that it must be very easy to
add or remove single constraints. JSR-303 annotations were no good fit
because the rules are statically attached via annotations to class
getters, so we had to find a more flexible approach on our own.
A constraint is basically only a function, so we started with an
interface similar to this:
public interface Constraint {
String check (Object o);
}
Now, you can implement a dozen of simple constraints like NotNull,
NotBlank, GreaterThan, Matches etc, which all work on scalar
values. If the class implementing Constraint needs a parameter (like
Matches would need a regular expression) you can pass it to its
constructor.
The normal use case is to apply a validator to a complex object, let's
assume an Address containing street, zipcode and city. So let's
introduce a Validator class...
At this point, one of the rules that I learned to value very highly,
kicked in: Minimize the number of concepts! Because each concept must
be understood by itself and may interact with others, it potentially
raises complexity and cost far beyond its benefit.
We decided that a validator is only a Constraint that works on an
object graph and applies other constraints to do the actual
validation, so the Validator class implements Constraint. (Compare
that to
javax.validation API: Constraints are -- from an API standpoint -- only annotations and there is no conceptual link between such an annotation and the Validator interface.)
But our Validator cannot simply take Constraint instances, it must
know which Constraint must be applied to which bean property. We
needed something that is able to get the property value and then apply
the Constraint. This gave birth to another subtype of Constraint, that
we called Property. An instance of Property takes a bean property name
and a Constraint and applies the Constraint to the value it retrieved
via reflection.
Because Property is a Constraint we can directly pass it to a
Validator. With some additional factory functions the resulting API
can be used like so:
Validator addressValidator = new Validator(
property("street", stringLength(1, 50)),
property("zipcode", regEx("[0-9]{5}")),
property("city", notNull()));
I hope that you can see the principle here: we define and encapsulate
combinable pieces of functionality. Looking from the outside, we only
introduced pieces of code that validate data. Due to usage of the
Constraint interface as parameter as well as contract the pieces are
composable, although underneath they act very differently.
That style of API design is in fact around for a long time:
- There is a solid foundation in combinatory logics.
- Linux command line programs that support piping form a well-known powerful 'API' for system administration tasks.
- It is something that you can see in FRP event stream processing.
- Or how graphical elements are created in ELM.
- Or how map, reduce, filter and all those sequence based functions work in Clojure or Scala.
- My library parsargs follows that same style:
(def mapping-parser
(p/some
(p/sequence :data-path (p/alternative
(p/value vector?)
(p/value keyword?))
:signal-path (p/alternative
(p/value #(and (vector? %) (string? (last %))))
(p/value string?))
:formatter (p/optval fn? str)
:parser (p/optval fn? identity))))
It seems that there are two flavours in practice: there are
combinators that compose functionality (like parsers or validators)
and those that step-wise transform data (like sequences or graphical
elements). The essence is the same: functions yield something that is
compatible with what other members of the same 'family' of functions
expect as input.
And as a nice goody: if the combinators are named with great care then
programs that are based on those read much like a
domain specific language.