Transducers

I recently stumbled when using transducers. It was mainly because I still treated them as a simple partial application of their full counterparts. I knew that this conception was wrong, but it had gotten me everywhere I wanted so far. The bit that made me stumble, can be summarized as follows:

(into
  []
  (comp
    (map inc) 
    (filter odd?))
  (range 10))
0.2s
Clojure

The corresponding partial application.

((comp
  (partial map inc)
   (partial filter odd?))
 (range 10))
0.1s
Clojure

If you think of transducers as partial application, you would expect a different result as (filter odd?) would be a applied before (map inc) . So it was time to properly understand transducers.

The following examples are to a big part adapted from Timothy Baldridge's Transducer Intro. To begin with let's start with the observation that many standard functions such as map and filter can be implemented via reduce.

(defn -map [f vs]
  (reduce
    (fn [acc v]
      (conj acc (f v)))
    []
    vs))
0.1s
Clojure
(defn -filter [p vs]
  (reduce
    (fn [acc v]
      (if (p v)
        (conj acc v)
        acc))
    []
    vs))
0.1s
Clojure
(->>
  (range 10)
  (-map inc)
  (-filter odd?))
0.0s
Clojure

You will realize, being an avid Clojure follower, that there are still a couple of things complected 😉. The first being the redundancy of reduce . Let's pull that logic out.

(defn -mapping [f]
  (fn [acc v]
    (conj acc (f v))))
0.1s
Clojure
(reduce (-mapping inc) [] (range 10))
0.0s
Clojure

The second assumption we are currently making is that our accumulated collection is conjoinable. Let's use another level of functions to get rid of that assumption. job is just something that creates a new accumulated value from an accumulator and a new value.

(defn -mapping [f]
  (fn [job]
    (fn [acc v]
      (job acc (f v)))))
0.0s
Clojure

The reason we do not put job into the signature of -mapping is that we might not want to complect our mapping logic with the logic of the underlying datastructure. This enables us to completely separate the logic of the data manipulation from the pipeline logic.

(def rfn (-mapping inc)) ;; data manipulation
(reduce (rfn conj) [] (range 10)) ;; pipeline
0.1s
Clojure

Let's also just write it for filter .

(defn -filtering [p]
  (fn [job]
    (fn [acc v]
      (if (p v)
        (job acc v)
        acc))))
0.1s
Clojure

Now comes the tricky part. Can we still compose -mapping and -filtering ?

(def rfn (comp
           (-filtering odd?)
           (-mapping inc)))
(reduce (rfn conj) [] (range 10))
0.0s
Clojure

Yes we can. Both transducers expect a job and the job just takes an accumulator and a value. This can be conj or some a function like (fn [acc v] (conj acc (inc v))) .

Coming back to our original problem: To understand why transducers work the other way around with comp, let's first write down the above example a bit more explicitly.

(reduce ((-filtering odd?) ((-mapping inc) conj)) [] (range 10))
0.0s
Clojure

The reason that one can think of comp with transducers more of a ->> is that the job gets injected backwards. In the above example the inner function of -mapping (the result of ((-mapping inc) conj)) ) gets passed as a job to the outer function of -filtering but this mapping-job gets applied inside the filtering job (inner function of -filtering ), hence the reason comp kind of works left to right for tranducers (or at least that's what I am thinking of when composing them).

Normally the term job is replaced by xf for xtransform in Clojure documentation and tutorials.

Runtimes (1)