Making Incisions in Clojure Published 4 June 2017
It's common to make deep incisions in a collection of nested data while retaining the shape of the data, often to service a longer data transformation pipeline.
Let's say we want to adjust every employee's salary to compensate for inflation by 5%:
(def employees [{:name "Alice" :salary 5000 :bonus 500}
{:name "Bob" :salary 4000 :bonus 100}])
We can write a simple function, give-raise
that adds inflation:
(defn give-raise [salary inflation]
(* salary (+ 1 inflation)))
(give-raise 1500 0.05M)
=> 1575.0M
(map #(give-raise % 0.05M) (map :salary employees))
=> (5250.00M 4200.00M)
How do we apply this function inside the nested structure under the :salary
key? We can use update-in
for this:
(map #(update-in % [:salary] give-raise 0.05M) employees)
=> ({:name "Alice", :salary 5250.00M, :bonus 500}
{:name "Bob", :salary 4200.00M, :bonus 100})
That works, but let's add some sugar to make it more readable:
(defn lens [f ks & args]
(fn [m]
(apply update-in m ks f args)))
Now we can transform the give-raise
function to act as a lens into our map:
(def lensed-raise (lens give-raise [:salary] 0.05M))
(map lensed-raise employees)
=> ({:name "Alice", :salary 5250.00M, :bonus 500}
{:name "Bob", :salary 4200.00M, :bonus 100})
Neat. This is functionally equivalent to the call above to update-in
. But it has a major advantage: lensed functions can be composed.
While we're doling out money, let's increase everyone's bonus by $100:
(map (lens + [:bonus] 100) employees)
=> ({:name "Alice", :salary 5000, :bonus 600}
{:name "Bob", :salary 4000, :bonus 200})
Don't forget about the salaries! How do we combine these changes?
We can use assoc
:
(map (fn [employee]
(assoc employee
:salary (give-raise (:salary employee) 0.05M)
:bonus (+ (:bonus employee) 100)))
employees)
Or use two update-in
calls:
(map (fn [m]
(-> m
(update-in [:salary] give-raise 0.05M)
(update-in [:bonus] + 100)))
employees)
Hmm. We seem to spend a lot of time packing and repacking our data to make simple changes. At least it's all in one place.
Is there a better way? Why, yes. We can compose give-raise
and +
into a single transformation:
(def transform (comp (lens give-raise [:salary] 0.05M)
(lens + [:bonus] 100))
(map transform employees)
=> ({:name "Alice", :salary 5250.00M, :bonus 600}
{:name "Bob", :salary 4200.00M, :bonus 200})
The advantage here is that no one piece of our composition has any knowledge of the shape of the data. This may not seem like a big advantage, but what if every employee record is wrapped in some other key like :deep
:
(def deep-employees
[{:deep {:name "Alice" :salary 5000 :bonus 500}}
{:deep {:name "Bob" :salary 4000 :bonus 100}}])
We could easily pull out these records with (map :deep deep-employees)
, but then we have to repackage them later. Is it possible to apply the same transformation to the nested structure without changing our code?
Lets refocus our lens. We add another lens to our initial transformation:
(map (lens transform [:deep]) deep-employees)
=> ({:deep {:name "Alice", :salary 5250.00M, :bonus 600}}
{:deep {:name "Bob", :salary 4200.00M, :bonus 200}})
By composing lenses, we have sucessfully decoupled the shape of our data from the mechaism of transformation. As a result, our code is more terse and reusable.
In Haskell, lenses are first-class concepts that subsume getters and setters in object-oriented programming languages.
This is the power of functional programming.