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 our first post, we discussed the game, described its rules and came up with a basic target architecture. In our second post, we tested this architecture on a Proof-Of-Concept. In our last 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.
Preambule
As Clojure Spec user, you might have heard repeatedly (or read in the rationale and overview) that Spec is not a type system.
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.
Expressing commitment
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).
Avoid over-committing
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.
(s/def ::turn | |
(s/keys :req-un | |
[::board/board | |
::player/player | |
::scores/scores])) |
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 spec:
(s/fdef new-init-turn | |
:ret ::turn) | |
(s/def ::turn-data | |
(s/keys :req-un | |
[::board/board | |
::player/player | |
::scores/scores])) | |
(defn new-init-turn [] | |
(with-next-player | |
{:board (board/new-board) | |
:player (rand-nth player/all) | |
:transitions {} ;; No commitment on keeping it | |
:scores (scores/initial-scores cst/init-block-count)})) |
Simple enough. Maybe too simple.
Annoying Symmetries
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:
(s/fdef next-turn | |
:args (s/cat | |
:turn ::turn | |
:transition ::transition/transition) | |
:ret ::turn) |
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.
Limited Precision
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.
The ::turn 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?
Increasing Precision
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:
(defn next-turn-gen | |
"Generator for a valid next turn from a previous valid turn" | |
[turn] | |
(gen/fmap | |
#(turn/next-turn turn %) | |
(gen/elements (vals (turn/transitions turn))))) | |
(gen/sample (next-turn-gen (turn/new-init-turn)) 1) | |
=> ;; Output not displayed since is a bit long |
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:
(defprotocol ITurn | |
(-next-turn [turn transition] "Move to the next turn of the game") | |
(-transitions [turn] "Provides the list of transitions to next turns") | |
(-turn-status [turn] "Provides the status of the board, player and scores")) | |
;; The turn that is being manipulated in the API | |
(s/def ::turn | |
#(satisfies? ITurn %)) | |
;; Instantiate a new turn (for example with reify) | |
(s/fdef new-init-turn | |
:ret ::turn) | |
;; Moving from one turn to the next | |
(s/fdef next-turn | |
:args (s/cat :turn ::turn | |
:transition ::transition/transition) | |
:ret ::turn) | |
;; Getting the transitions to the next turns | |
(s/fdef transitions | |
:args (s/cat :turn ::turn) | |
:ret (s/map-of ::transition/destination ::transition/transition)) | |
;; The data that we can retrieve from a turn | |
(s/def ::turn-data | |
(s/keys :req-un | |
[::board/board | |
::player/player | |
::scores/scores])) | |
;; A way to retrieve the current state information | |
(s/fdef turn->info | |
:args (s/cat :turn ::turn) | |
:ret ::turn-data) |
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.
Thinking Differently
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.
Binary Tree
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.
[1 {:left [2 {}] | |
:right [3 {:left [4 {}]}] | |
}] |
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:
(s/def ::left ::bad-binary-tree) | |
(s/def ::right ::bad-binary-tree) | |
(s/def ::bad-binary-tree | |
(s/cat | |
:value any? | |
:children (s/keys :opt [::left ::right]))) |
Neither :left nor :right 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 instead:
(s/def ::good-binary-tree | |
(s/cat | |
:value int? | |
:children (s/map-of #{:left :right} ::good-binary-tree))) |
Scores
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:
(s/def ::scores | |
(s/map-of #{:blue :red :green} int?)) |
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.
(s/def ::blue int?) | |
(s/def ::red int?) | |
(s/def ::green int?) | |
(s/def ::scores | |
(s/keys :req-un [::blue ::red ::green])) |
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.