Transients in Clojure
Yesterday I spent an hour in the morning reading through some of the Clojure code in the Graph library by Prismatic. I do recommend having a look at Jason Wolfes presentation about Graph, it is very interesting. The code itself is very readable, although the nitty-gritty of the graph manipulations would require more work to understand fully.
The thing I wanted to focus on here, was one of the utitily functions I stumbled on that forms a basic building block for many of the higher level operations on graphs, namely the for-map
.
The for-map
is basically a for comprehension but for creating maps instead of lists. In Clojure you can use for comprehensions like this:
(for [i (range 2)
j (range 3)]
[i j (even? (+ i j))])
;; ([0 0 true] [0 1 false] [0 2 true] [1 0 false] [1 1 true] [1 2 false])
Similarly you can create a map using for-map
with:
(for-map [i (range 2)
j (range 3)]
[i j] (even? (+ i j)))
;; {[0 0] true, [0 1] false, [0 2] true, [1 0] false, [1 1] true, [1 2] false}
Remember that in Clojure maps, your keys can be much more than strings or keywords: anything that supports hashCode and equals can be a key. In this example we have a list with two integers as keys.
Now, we can easily implement this functionality using for comprehensions to create the values and Clojures polymorphic sequence functions:
(into {}
(for [i (range 2)
j (range 3)]
[[i j] (even? (+ i j))]))
;; {[0 0] true, [0 1] false, [0 2] true, [1 0] false, [1 1] true, [1 2] false}
And turning it into a macro for generic use:
(defmacro for-map
[seqs keys vals]
`(into {}
(for [~@seqs]
[~keys ~vals])))
So, let’s look at the actual definition of for-map
, from plumbing/core.clj
:
(defmacro for-map
([seq-exprs key-expr val-expr]
`(for-map ~(gensym "m") ~seq-exprs ~key-expr ~val-expr))
([m-sym seq-exprs key-expr val-expr]
`(let [m-atom# (atom (transient {}))]
(doseq ~seq-exprs
(let [~m-sym @m-atom#]
(reset! m-atom# (assoc! ~m-sym ~key-expr ~val-expr))))
(persistent! @m-atom#))))
Clearly there’s something else going on here, so let’s dig into that a bit. For simplicity we can ignore the optional symbol (It gives a way to reference the partial collection from the keys and values). The code then becomes:
(defmacro for-map
[seq-exprs key-expr val-expr]
`(let [m-atom# (atom (transient {}))]
(doseq ~seq-exprs
(reset! m-atom# (assoc! @m-atom# ~key-expr ~val-expr)))
(persistent! @m-atom#)))
The reason for having this complex implementation is performance. Our naive implementation first creates an immutable list and iterates through the list to create a map.
The code uses something called transients to achieve this performance gain. Rich Hickey is explaining transients in the previous link much better than I can, but the point of transients, is to allow local mutability. The input and output of the for-map
are still immutable data - but internally in the function, the structure is created using mutations.
It is done, as we can see in the code, in three steps.
1. We obtain a mutable reference of the collection using the transient
function
2. Mutations as being performed on the transient collection (using assoc!
).
3. An immutable reference of collection is returned as the result by calling persistent!
.
The important point here is that the reference to the mutable collection never leaves the function, but is purely local.
← Go Back