21 December 2015
Encapsulation and Clojure
Encapsulation is a mainstay of object orientated programming, but in Clojure it’s often avoided. Why does Clojure steer clear of a concept that many programming languages consider to be best practice?
Why shouldn’t we use encapsulation?
In object orientated programming, the internal state of an object is hidden behind methods. This protects the object from being put into an invalid state, and allows the object can be treated like a black box; we care only about the interface it presents to the world.
So this is good, right?
Unfortunately, the advantages of encapsulation come with a steep price. If we hide the underlying data structures, then we lose access to their respective API. We need to reimplement any functionality we want to retain.
For instance, let’s say we want to represent HTTP headers in an object orientated language like Ruby. We might start off with a class like:
class Headers
def initialize
@env = {}
end
def [](key)
@env[key]
end
def []=(key, value)
@env[key] = value
end
end
Here we’ve written a getter and setter function for the underlying
@env
instance variable. All this code is just boilerplate to get
around the encapsulation of @env
, but it’s a necessary starting
point to produce an class with custom behaviour.
Clojure discourages encapsulation of data, so we can avoid this initial boilerplate. In Clojure, we can just use a raw map.
A world without encapsulation
Let’s expand the Ruby example a little. We’ll add in some new functionality to ensure that when a header is added, the header name is both a string and only contains valid characters:
class Headers
def initialize
@env = {}
end
def [](key)
@env[key]
end
def []=(key, value)
raise IndexError unless header_name?(key)
@env[key] = value
end
private
def header_name?(s)
s.is_a?(String) && s =~ /^[A-Za-z0-9!#$%&'*+._`|~^-]+$/
end
end
In Clojure, data structures are immutable, so unlike Ruby we don’t need to check whether an update is valid. A map is either a valid header map when it is created, and will always be valid, or else it was never valid to begin with.
This means that we can achieve equivalent functionality by writing a predicate function that will return true if a map is a valid header map, or false otherwise:
(defn header-name? [s]
(and (string? s) (re-matches #"[A-Za-z0-9!#$%&'*+._`|~^-]+" s)))
(defn headers? [m]
(and (map? m) (every? header-name? (keys m))))
You may point out here that running headers?
requires iterating
through every key in the map, and therefore may not be as efficient as
the Ruby implementation, where each key is checked only when it’s
set. But if performance does become an issue in Clojure, we can solve
it with a memoization strategy.
Ruby is not a language known for boilerplate, but because it’s an object orientated language, it cannot entirely avoid the repetition caused by encapsulation. In Clojure we can avoid this redundancy completely.