Friday, 8 August 2014

OO, invariants and Clojure

In an object-oriented language if we'd like to implement a "concept", we create a class. Concept is too abstract (and probably there are better words out there for what I tried to say), so let's see a practical example. I want to implement a game of cards and therefore I'd like to deal with "decks". My OO-language of choice is Scala. Assuming that the operations I want to do on a deck are pulling a card from top, shuffle, and adding cards on the top, the code would look like this: (ignore Scala's awkward way of simulating enumerations - why is that so bad in this otherwise beautifully concise language is beyond me).
I chose to implement the deck as an immutable objects, all functions on it creating a new instance and leaving the original intact. I could have done the more OO-way of mutable state, but it's irrelevant for the case and I'd like to move the solution close to the one of Clojure discussed later.
See the check in the "constructor" and the isValidDeck function. This ensures that our Deck is always "valid". All the code using this class can safely assume that it doesn't contain duplicate cards. It's the simplest invariant I could come up, but serves the purpose of the post well. If I try to get out of my default OO-mindset acquired in the last 10 years and look at it with fresh eyes, what I see that we have a "raw" data structure, a list, and certain constraints attached to it. The data structure and the constraints together are the class.

Class = raw data structure + constraints

The state-space of the Deck object obviously couldn't be bigger than that of the list, the raw data. So building a class around a raw data structure is simply imposing restrictions on how the underlying data can be modified by the clients of the class. Of course the methods of a class can hide side-effects, too, but again, in the aspect of invariants, it's not relevant.
The benefit of the OO approach is that the Deck stands guard over its invariants, so the constrains and the data "travel together". The disadvantage is that we lose all the already existing data-manipulating functions the language would provide out of the box. Let's demonstrate it with the Clojure solution.

Delightfully simple. We didn't have to write any of the required functions, the language gives us them all and plenty more. What if we want to remove all the Clubs from the Deck? Or halve it? Or order by some specific rule? Clojure would offer a million different data manipulation choices. So would Scala anyway if you choose to use it that way, but the language philosophy prefers encapsulating data and behaviour in one unit (class).
The disadvantage of Clojure's lightweight way is that we now lost the guard on the invariants. You can pass an arbitrary list to any functions expecting a valid deck, opening the door for hard to detect bugs. However I think there is a remedy for this and quite an easy one of that.

Using the 'with-invariant' function we can apply any validation with any function operating on any data structure. We can extend it easily to deal with multiple inputs, too, or different validations for the input and the output. Object-oriented programming ties validation and data together, offering very strong safeguards, but constraining reusability immensely.

Classes vs (raw data + validator functions) = safety vs reusability

Imagine now we want to extend our game of cards program by allowing different kind of games, including ones where the deck can contain duplicates. Or requires different operations on the deck (like pull from the bottom). Can we reuse the Deck class? Not in this current form. Maybe subclassing it or extract some traits? Either way the code starts to get complicated.
With Clojure we just use the same basic data structure (list) a new validator function with with-invariant or build it up in one single function for brevity's sake. The Clojure implementation dissected the class and cleaved off the constraints from the data. The data structure can processed in almost every imaginable way with the rich set of functions built-in the language, and the constraints can be plugged-in the computation whenever needed.

No comments :

Post a Comment