This post is the fourth in the series of posts focused on the design and implementation of a port in ClojureScript of the game named Tribolo
In ourfirst post, we discussed the game, described its rules and came up with a basic target architecture. In oursecond post, we tested this architecture on a Proof-Of-Concept. In ourlast post, we implemented the full game logic.
This post is an interlude on Clojure Spec
before going through the remaining of the implementation of the game (Artificial Intelligence, Game Loop, Rendering).
This post is a discussion and not a tutorial. In particular, it aims at discussing how to use Spec correctly but does not aim at providing answer more than questions. It will share my short experience using it, my struggles using it and voice some questions I felt I had no answer to.
As such, any comments or answers to these questions would be greatly appreciated. You can shared in the reddit post linked at the end of the post.
But for those like me coming from strongly typed languages, the temptation is there to make it work like a type system anyway. Spec will resist this attempt: trying to do so might prove very frustrating.
One of the best resource available to understand the philosophy behind Clojure Spec is the Spec-ulation keynote
from Rich Hickey. In the first 5 minutes of the presentation, Rich Hickey provides us with the motivations of Spec:
- Providing someone something they can use (not just rules to follow)
- Making a commitment to deliver some services against some requirements
- Ultimately helping managing changes in software evoluation
This post will explore these different concerns in the context of the development of a small Clojurescript game like Tribolo.
The first section will talk about the concept of commitment and share some thoughts on my understanding of it. The second section will discuss some problems I encountered trying to use Spec to express commitments. The last section will talk about some tips that helped me understand Spec.
The first aspect of Clojure Spec has a lot to do with the Design by Contract approach. It stresses commitment to provide some service:
- We commit to provide outputs and effects (and commit never to provide less)
- We commit to require some given inputs (and commit never asking for more)
This ressembles Design by Contract
in which the pre-conditions (requirements on the inputs) are the terms under which a function promises to deliver its post-conditions (commitment to deliver specific outputs and effects).
The notion of commitment has some interesting consequences. Since a commitment is for ever, we have to think about what might change and will not.
Expressing no commitment whatsoever is obviously not very useful for the user. At the other end of the spectrum, excessive commitments can remove interesting degrees of freedom. The resulting rigidity might be a real problem to accommodate for future changes in an API.
There is a trade-off there, a right balance to find. In particular, and for those coming from strongly typed languages, we have to resist the temptation of systematically Spec-ing every keys in a data structure, or automatically enriching the Spec of a map every time we add a new key in it.
It is about the client
Since specifications are about commitment to provide a service, Specs are fundamentally oriented toward the client, the user of our code (which might be us).
Again, the temptation is really great to use specs as types and add all of our keys in a keyset specification. Since specs support instrumentation, spec’ing every single detail of our data structures might even help us finding bugs as implementer.
But we have to be careful and think about the promises we make through the specs exposed at the boundaries of our APIs. Coming from a strongly type language past, I felt this is a pretty hard to do.
Example of commitment
In the context of Tribolo
, we made sure the specification of a turn does not mention the presence of the key :transitions
. It was tempting to enrich the map with this key, in particular to get better instrumentation during development.
But considering that this key is an artefact of the implementation, its absence in the specification of ::turn
acts as an indication for our user that it cannot be relied on forever.
Note: Clojure Spec can ensure that namespace qualified keys do comply to their associated spec even when not listed in a map specification
. This clear distinction of membership from compliance allows to have quite good instrumentation of the implementation details but not commit to the presence of the key.
Lost in Spec-ulation
Up until now, it makes perfect sense. We can use specs as a way to express an exchange of services with the user of our code. The pre-conditions are the requirements for the post-conditions to be fulfilled.
For example, new-init-turn
instantiate the first turn of a Tribolo
game and promises to deliver a ::turn
. Since we do not want to commit on the presence of the :transitions
key in the turn, we omit it from the ::turn
Simple enough. Maybe too simple.
Problems arise when dealing with functions such as next-turn
that feature the same spec as input argument and as output. Indeed, and based on the notion of pre-condition and post-condition:
- Input specs should contain at least
what the function require (complying arguments should lead to the service being delivered)
- Output specs should contain at most
what the function outputs (the function can deliver more than the promise service)
In our example, and because we choose to keep :transitions
as an implementation detail, satisfying the ::turn
spec is not enough for our user to successfully use next-turn
. The transitions are a mandatory (but not committed forever) part of the current implementation.
The pre-conditions expressed in the following spec are therefore incomplete, and a client satisfying them might be quite surprised not to get his service in exchange:
Improving on the pre-conditions to include the :transitions
in the keyset of the ::turn
would directly impact the level of commitment on the output, which is something we do not desire. This seems like an impossible deal.
One solution could be to break the symmetry, and require a different spec as input and output. Somehow, it does not seem like the most elegant solution to the problem. We will later propose another solution.
Let us say that we finally commit to the presence of the :transitions
key inside the ::turn
spec. We avoid the problem raised in the previous section, but there are still some remaining issues.
spec is still unable to capture all the pre-conditions needed for next-turn
to provide the service it promises. The reason is that there are hidden dependencies between the data inside the turn:
- The scores are proxy for the number of cells in the board
- The transitions are based on the state of the board and the player
Generating a sample ::turn
, satisfying all the pre-conditions expressed in the keyset, is not enough for the next-turn
function do its job properly. Can we refine the pre-conditions with enough precision to circumvent this? And if it was possible, should we even try to?
Instead of trying to refine our previous spec, it would be much easier to define a valid turn as being the result of a succession of transitions starting from an initial turn
. It would avoid duplicating the game logic inside the turn spec.
A valid turn is obtained by chaining next-turn
calls on a turn initially created by new-init-turn
. In terms of test.check
, we could generate a turn from a previous valid turn using the following generator:
We could then chain calls to this generator, using for example the bind
combinator from test.check
, to randomly generate valid turns that a user could use and rely on.
Increasing Precision with specs
We can find ways to express that a valid turn is obtained from a succession of transitions on an initial turn. In particular, type systems are great at ensuring this kind of invariants, by forcing upon the user a desired workflow.
We could for example combine the use of a protocol with Clojure Spec:
- We create a ITurn
protocol with the appropriate functions
- The ::turn
spec checks if the object implements ITurn
- The ::turn-info
spec describes the turn data (board, player and scores)
- We add a function turn->info
to retrieve the ::turn-info
from a ::turn
- We make new-init-turn
the only way to create a turn
The following code illustrate how this approach could look like in terms of specs. Since protocols do not support specs directly, we use wrapper functions around the protocol:
Precision gain – Observability Loss
Using the protocol design for our turn, we get to keep the symmetry
of the next-turn
function by introducing a level of indirection. We however lost the property that our turns are just data, degrading the observability of our system.
We might also wonder if this usage of spec is desirable. In particular, there is a section named Informational vs implementational
in the Clojure Spec overview that looks to me as a warning which may really well apply here.
One thing that is clear from using Clojure Spec is that it requires its own way of thinking. It is amazing how some concepts that are hard to express with Spec can be slightly reworked in order to fit with Spec much better.
One clear distinction that I found is pretty useful when thinking about map containing keywords, is deciding whether the keywords have an associated intrinsic meaning to them, or only serve to be associated some data.
In case they do have an intrinsic meaning, it seems best to define a spec for the keyword and use keysets. In case they do not have a meaning, we can use map-of
instead. Mixing both approaches almost always lead to mayhem, so there is a choice to make. Let us be more explicit by taking some examples.
Among the many possible representations for binary trees in Clojure, we will choose the following one:
- A node is a vector containing a value followed by a map of children
- Inside the map of children:
- The :left
key will be associated the left tree
- The :right
key will be associated the right tree
This would be an example of valid binary tree in this representation.
Note: This example is a bit contrived. We could have used another representation where a vector would hold as first element the value, the indices 1 and 2 matching the left and right sub-trees respectively.
The wrong way to provide a spec for this binary tree is to think in terms of a type system. In such a type system, we would define two members, left and right, and give them the type BinaryTree. Here is a spec (that does not work) which illustrates this:
have any intrinsic meaning by themselves. Their goal is only to designate which tree corresponds to left or right sub-tree. We could have used positions in a vector to identify the left and right sub-trees instead. So we can try to use map-of
A less contrived example is the representation of the scores inside the Tribolo
. We use a map associating an integer value to the player keywords. The role of the keywords are to associate values, and have no intrinsic meaning, hence the use of map-of
Another possibility would have been to introduce specs for each of the player. We could then have defined ::scores
as a keyset with all the player keywords in it.
One drawback of the first implementation with map-of
is that we will not ensure that each of the keys is available in the map. The advantage it has is that it makes use of keywords without having to provide them a meaning individually.
Conclusion and what’s next
We are done with this small interlude on Spec.
Reading and experimenting with Clojure Spec made it pretty clear that its usage are quite different from those of a type systems. There is clearly a great deal of experimentation to do to combine efficiently both techniques.
I hope some of these thoughts could serve the reader. I would be especially interested in having some comments from anyone having done experiences with Specs in the following Reddit post
In particular, handling the symmetry of some methods (like the case of next-turn
which I described above) is really much a hard problem to me.
The next posts will resume the implementation of the Tribolo
game where we left it, starting with the rendering.