Using `reduce` to Implement Other Clojure Functions
In
yesterday’s
post, we took a look at reduce, one of the true workhorses of
functional programming. In today’s post, we’ll see how we can use
reduce to implement some other functions in the Clojure standard
library.
As we learned previously, reduce takes three arguments: a reducing
function, an optional starting value, and a collection. If you
don’t supply reduce with a starting value, it will use the first
item in the collection as the starting value and start processing from
the second item.
We also learned that a reducing function takes two arguments. The
first argument is the current state of the reduction, and the second
is the input value to be used to compute the new state, which is the
return value. In yesterday’s post, I said that there are other Clojure
standard library functions that can act as a reducing function. One of
the most important is conj.
Often, we think of conj as just adding an item to a vector, like so
user> (conj [1 2] 3)
[1 2 3]
But conj also works on other data types such as maps and sets.
user> (conj {:a 1} [:b 2])
{:a 1, :b 2}
user> (conj #{:a :b} :c)
#{:c :b :a}
It turns out that conj is also a reducing function. It takes a
collection (the state) as the first argument and an item to add to the
collection (the input) as its second argument.
We can use reduce with conj as our reducing function to create our
own Clojure Crazy version of into.
user> (defn cc-into [to from]
(reduce conj to from))
#'user/cc-into
user> (cc-into {:a 1 :b 2} [[:c 3]])
{:a 1, :b 2, :c 3}
user> (cc-into #{} [1 2 3 4 5 6])
#{1 4 6 3 2 5}
It’s fairly easy to re-implement other Clojure standard functions,
too. Here’s a re-implementation of filterv, which is basically just
a non-lazy version of filter which returns its results in a vector
instead of a lazy sequence.
user> (filterv odd? (range 10))
[1 3 5 7 9]
user> (defn cc-filterv [pred coll]
(reduce (fn [state input]
(if (pred input)
(conj state input)
state))
[]
coll))
#'user/cc-filterv
user> (cc-filterv odd? (range 10))
[1 3 5 7 9]
Here, our reducing function applies the predicate to the input value
and if it returns “truthy,” the reducing function adds the input to
the state via conj. If the predicate returns “falsey” (either false
or nil), then the reducing function returns the state
unchanged. This has the effect of not including that particular input
item in the output vector.
Finally, here’s a re-implementation of mapv.
user> (defn cc-mapv [f coll]
(reduce (fn [state input]
(conj state (f input)))
[]
coll))
#'user/cc-mapv
user> (cc-mapv (partial * 5) (range 10))
[0 5 10 15 20 25 30 35 40 45]
It’s amazingly simple. Our reducing function applies f to every
input and then adds the result to the state using conj. That’s
it.
We could go on and re-implement similar replacements for lots of other
collection functions such as distinct, group-by, keep, remove,
replace, etc. They’re all variations on this. If you want some
exercises to help these ideas really stick, try implementing them all
yourself. Start with keep and remove. Then, move on to distinct,
group-by, and replace.
It’s important to note that all of our replacement functions have been
“greedy” like mapv and filterv, not “lazy” like map or
filter. According to its nature, reduce processes the full input
collection before it returns a result.
Also, we’ve used conj to build the output vector since it adds items
to the back of the vector, not the front like cons. Thus, it
preserves the order of the items in the collection as it processes
them. If you implement a replacement for group-by, you’ll need to
think about using functions other than conj to build up the output
value.
Next time, we’ll see how reduce and these ideas about
re-implementing functions like filterv and mapv using reduce
start to set the stage for understanding Clojure transducers.