My mental model of Clojure transducers
blog.danieljanus.pl
blog.danieljanus.pl
Metabase is a BI tool, backend written mostly in Clojure. Like basically all BI tools they have this intermediate representation language thing so you write the same thing in "MBQL (metabase query language)" and it theoretically becomes same query in like, Postgres and Mongo and whatever. End user does not usually write MBQL, it's a service for the frontend querybuilding UI thing and lots of other frontend UI stuff mainly in usage.
Whole processing from MBQL -> your SQL or whatever is done via a buncha big-ass transducers. There's a query cache and lots of other stuff, you need state, but you also need it to be basically fast. Metabase is not materially faster than other BI tools (because all the other BI tools do something vaguely similar in their langs and because the limiting factor is still the actual query running in most cases) but it's pretty comparable speed and the whole thing was materially written by like 5 peeps.
https://github.com/metabase/metabase/blob/master/src/metabas...
(nb: I used to work for Metabase but currently do not. but open core is open core)
Once you get used to them, they become a very natural tool — in my case, almost every time I do something to a sequence, I'll start with `into`. Even if I'm just applying a single transformation, this lets me quickly change the resulting collection and easily add more transformations if needed.
On a higher level, I found myself thinking about business logic (model) in terms of transducer pipelines and ended up with a number of reusable transformations, and clearly specified pipeline logic.
One gripe I have with transducers is that writing stateful transducers is hard. As in, well, really hard (hope you remembered to flush using `unreduced` in your single-arity version!). I still write them sometimes, but it's never a walk in the park (it does provide satisfaction when done, though). But I guess that's what you need to go through in order to get good performance.
https://github.com/johnmn3/injest
However writing your own indeed doesn't sound fun :))
I have had many people tell me that the SRFI document [0] made transducers click for them, which is always a nice thing to hear.
Can you give me an example of a classic problem (since we're talking about transformations, raytracers and compilers come to mind) where if you involve a transducer vs a map you get an interesting difference?
Edit: 'How state is kept is not specified': I assume that there are limitations to state keeping, particularly with the composability and performance pillars of transducers, but I'm just having a really hard time synthesizing everything.
This is mostly an API thing. In clojure you can pass a transducer when you create a channel. That way you can make a channel do just about anything. Send data in chunks of N. Filter Odd numbers. Or just do arbitrary transformations.
It is a protocol for composable transformations of data being passed in one direction. It is not fancy. Not really hard to understand. A generalization of map, filter, and friends.
I believe I've submitted my own blog post on transducers in the past. See <https://www.thatgeoguy.ca/blog/2023/01/04/reflections-on-tra...>
To bjoli: Have you seen my library? Any intentions to update the SRFI and incorporate more types?
I don't really understand what you mean by types (my implementation stays monomorphic so new types are easily introduced by TYPE-transduce) , but I have thought about generalising things like numerical ranges by having something like unfold-transduce.
This is more or less what I was wondering about. Numerics, ports, SRFI-41 streams, etc. There's a lot of stuff that isn't in e.g. r7rs-small but is more or less expected in most Scheme implementations.
Maybe this variant of explanation will suit your context better:
https://www.evalapply.org/posts/n-ways-to-fizzbuzz-in-clojur...
I wrote the post as a way to explore Clojure's standard library using FizzBuzz as a device.
Your comment sharpened that for me. I don't want FizzBuzz; I want someone taking a reasonable toy problem, such as a trad+photon+quadtree raytracer and demonstrating advantage by applying the concept.
So rather than processing the collection, passing it to the next function that processes the collection, passing it to the next... etc.. consuming all the CPU and memory that involves, you can define steps that are applied for each item in the collection thereby having the iteration through the collection happen once.
These steps (transducers) are also composable and reusable.
I suspect you know this, consider this a basic explanation for other people reading.
This is a much better and clearer explanation than the entire article.
What you've written is just the Haskell list monad or Java8 Stream.flatmap.
foldl (+) 0 (take n myList)
reduce (takeNPlus 10) 0 myList
I assume it should be (takeNPlus n)? (or (take 10 myList))This is 100% correct. It's amazing how over hyped they are.
If you have ever used C#'s LINQ you are using transducers. The fact that LINQ works item at a time instead of collection at a time is all that's being discussed. That if you say take the first two in a 1,000,000 long collection LINQ will only enumerate the first 2 items and not all 1,000,000 is the other behavior. And the way to do this is by composing operations using a "." operator into a "query" to run.
C# had had this since 2007. It doesn't require a fancy name, it doesn't require streams, it doesn't require "thought pieces" every few months for over a decade for people to "master thinking in linq". Just sequence your operations and get back to coding.
And Clojure is a really great language, but the amount of mental space occupied by transducers is a bad look for the language. Via analogy it's like watching people be amazed by for loops for over a decade. And I know they're are a lot of really smart people in the clojure community, so I honestly put it on Rich. Either on hyping or up so much when he released it like he just invented sliced bread and it's a deep advanced topic, or for how it's presented in the language that people have to understand so much beneath the abstraction layer to use it correctly.
Your average blub enterprise programmer has been using LINQ for 15 years and never needed 100 thought pieces in how to use it and reason about it. Yes it's a monad, yes it let's your short circuit, yes it's item at a time, but a user doesn't need to know lots of detail to use it. It's like watching a language community that can do calculus be continuously hypong on the fact it can do long division.
Clojure is an amazing language, transducers are not that special, figure out why they are so hyped in clojure.
Or maybe I have it backwards and linq/transducers really are partial differential equations and C# snuck it into the 4th grade curriculum and nobody noticed.
Isn't that just function composition to build the map function? I guess that where the magic can come in is that function composition is associative, which allows for some really interesting opportunities for runtime optimization that, so far as I know, haven't been seriously explored.
If IIRC technically it's a bit more. It's a monad just like function composition, but it's bind has to handle data threading and short-circuit behavior. If you squint it's not too differently than than a parser combinator over Applicative matching a sequence of characters. Composing operations to correct thread sequential data while handing short circuiting.
I'm not certain about the optimizations due to associativity. While yes function composition is associative, that just builds the query. Running the query itself must be sequential as each operation depends on the data from the prior, leaving I believe little room for optimization.
The difference with transducers is that the transformation logic is completely decoupled from the fact that this is happening in collections. That is what makes them usable outside of the context of collections, most famously in core.async where you can map/filter/mapcat/flatten/take/drop channels just like you do with collections. This only works because transducers are completely decoupled from either the fact that elements are coming from, or going into, a collection. I think it is a really fascinating and creative achievement in decoupling. Java Streams and Scala view/iterator/iterable transformations can never be reused in other contexts since they are fundamentally about the collections. Whatever kind of Observable/Async Stream/Future or other places where you might want map/filter/reduce functionality has to reimplement the whole set of these operations anew (see for example Akka Streams, RxJava)
Iterators are generic over some iterable type `U` in `E, R, T: Iterator<E, R, U|T>, U: Iterable<E>` that they either directly or indirectly capture some nested Iterator.
However that means that code composing iterators must also be generic over that iterable `U`, so you can't simply take two transformation pipelines and concatenate them, because they will both provide an element source already.
Transducers separate the transformation part and the processing part. So you can have a `E, R, T: Transform<E, R>` which doesn't have a generic type parameter for the Iterable, and a function `transduce<E, R, S: Source<E>, I: Sink<R>, T: Transform<E, R>>(source: S, tx: T) -> I` which contains all the transformation application machinery, be it iterator style `next()` operations, or stream based `pull/push` operations, and a function `comp<E, R, S>(left: Transform<E, R>, right: Transform<R, S>) -> Transform<E, S>` that composes transformations. The way transformers are implemented, this `comp` operation is actually simply function composition.
Still I think just the “next()” context is quite enough because you can have everyone use it by convention for everything, even things that aren’t collections. Like a fluent tensor library for example. This is based on a quick understanding of transducers…
```js
let pipelineA = map(i => i+1); // or makeComplexPipelineA();
let pipelineB = filter(i => i.isEven()); // makeComplexPipelineB();
let pipeline = pipelineA.combine(pipelineB);
let result = [1,2,3,4,5].transform(pipeline);
```It also reinforces my view that you should try to separate logic and control flow as much as possible. At work, we use awful callback chains. There must be a better way to express the logic and hide the callback logic, even in C++ (lots of rope to … find alternative solutions.)
Think generics in Go or concurrency (effects) in OCAML or smart pointers in Rust. Not at all unique things, but having them in the language with other benefits is worth some discussion as it may provide extra leverage in context.
(map fn1 (map fn2 (map fn3 collection)))
into
(map (fn [x] (fn1 (fn2 (fn3 x)))) collection); Is that correct?
More idiomatic clojure:
(map (comp fn1 fn2 fn3) collection)
Funnily enough, Julia is where I came across the term transducers too, via the Transducers.jl package [2]. The article and the comments here now make me wonder what the difference is, between broadcast fusion and transducers.
[1] https://julialang.org/blog/2017/01/moredots/ [2] https://github.com/JuliaFolds2/Transducers.jl/
This work predates the term "transducer" and I was happy to see Rich Hickey defining it, so I use the term retroactively.
Note that I don't see much use for transducers as a general programming technique. This one use is very specific, but I have never before or since needed one.
IIRC Hickey did not define this term (nor claimed to have done so), he found it in existing compsci research papers that approached them from a mathematical proof angle and realized they solved a problem he needed to solve (basically not allocating intermediate collections and doing wasteful work on parts of the data that will be immediately discarded).
I think one of the authors of the papers he cited even gave a talk at a Clojure conference once.
However, it's worth noting that transducers aren't strictly about sequence-to-sequence transformations. From its inception, the Clojure standard library was built upon the `seq` protocol, and all its collection functions were constructed using this protocol. If it were possible to implement the `seq` protocol on channels, it would address the issue. However, it's not feasible. When querying a channel about its next element, the only honest answer is, "I can't determine that without potentially waiting indefinitely until the next message comes in or the channel closes." Turning the `seq` protocol into an asynchronous IO-bound process would be problematic. This is why transducers are essential.
# The Belt.
def belt():
# return itertools.cycle([1,2,3,4,5])
x = 1
while True:
yield x
x += 1
if x > 5:
x = 1
# The +1
def add1(iterable):
for x in iterable:
yield x + 1
# The +y
def add(y):
def _(iterable):
for x in iterable:
yield x + y
return _
# The Oddifier
def oddonly(iterable):
for x in iterable:
if x % 2 == 1:
yield x
# The Reaper
def partition(n):
assert n > 0
def _(iterable):
batch = []
for x in iterable:
batch.append(x)
if len(batch) == n:
yield batch
batch = []
if batch:
yield batch
return _
# The Composer
def compose(iterable, t1, t2):
yield from t2(t1(iterable))
# The Plumber
def pipe(iterable, p):
for xform in p:
iterable = xform(iterable)
yield from iterable
# The Press.
def printall(iterator):
import time
for x in iterator:
print(x, end=", ", flush=True)
time.sleep(0.3)
# printall(pipe(belt(), [add(1)])) # 2, 3, 4, 5, 6, 2, 3, 4...
# printall(pipe(belt(), [add(1), oddonly])) # 3, 5, 3, 5, ...
printall(pipe(belt(), [add(1), oddonly, partition(3)])) # [3, 5, 3], [5, 3, 5], ...I like the concept so much that I built a JavaScript version using generators.
Unfortunately, I do am not a fan of JavaScript at all and it appears that clojure now requires that I install a JVM. I realize that this is a very personal issue, but I am a "just say no to Java" person after using it for 10 years.
My other reference is just as personal and perhaps just as irrational. Java was free to use. Then after many people and companies used this free tool, Oracle came along and wanted to exploit the fact that people had an investment in this. Yes. I know. Oracle can do what they like. They own it. But I can do what I like and that is not to use anything associated with Oracle. Ever. (EDIT: and yes I do know there are open source JVM's)
So, no. I guess I am not professional.
The only way that might be even remotely true is if you were doing some very over-abstracted, bureaucratic complex bullshit app, but that would have be similarly longer no matter the language.
As mentioned, Oracle itself offers that very free, libre same-license-as-linux OpenJDK, like they are responsible for 90+% of commits there. Their own support license sits basically on top the free offering for the very few gigacorps that need it, so basically almost every java implementation is open source as they just redistributed the OpenJDK codebase (yes, there are a few niche ones as well no one ever heard of).
Like something that receives myiterator and returns myiterator.map(|x| x + 2) in rust
But other than focusing on the map rather than on the iterator, I think it's the same thing
So, you can consume a vector and produce a subset of the vector as a map.
Finally, transducers separate the element-wise processing from both the construction of the final sequence and the details of iterating over the input.
But can it produce more than one item as well?
So it's like, for each item of the iterator/stream/sequence, it can produce 0, 1, or any number of items?
Then it's like Haskell's bind (the >>= of the Monad instance) for lists, or Rust's flat_map for iterators, and flatMap in javascript as well
(into {}
(map (fn [k] [(/ k 2) k]))
[1 2 3 4])
;; {1 2, 2 4}
(A difference between fold and transducers is that transducers can work in infinite input types like core.async channels)Here’s a bit of code I put together to show the relation to fold: https://github.com/fiddlerwoaroof/lisp-sandbox/blob/58098023...
The other way I think of this is “explicit stream fusion”: transducers work by passing an explicit continuation around and you call that continuation to add results to the output: but, crucially, you don’t have to know what the output type is, you just say “add this to it”.
That's interesting. But every such type must have the same "shape" - a sequence of things. So it's the same as bind, plus an optional conversion at the end.
Which is the same as Rust's flat_map, because in Rust when you operate with iterators you can end up collecting into another type, like, myvec.into_iter().flat_map(|x| ..).collect() converts from a Vec<T> into SomeType<U>, where SomeType is any type that represents a sequence, and T to U is the type conversion from the map
> (A difference between fold and transducers is that transducers can work in infinite input types like core.async channels)
Ok so transducers are just lazy folds (I mean folds can work with infinite data in Haskell as well)
The mapcat transducer (flatMap in other languages) is equivalent to bind. The “optional conversion at the end” would defeat the whole point of transducers because it would force you to materialize an intermediate sequence before building up the result. The result type is a superset of monads, I’m fairly sure: it’s anything that can be the result of a two argument function with the signature `a -> b -> a`: there’s nothing in theory preventing using a transducer to sum up a list of integers.
The nice thing about this is that you can implement all the non-terminal operations on Stream in terms of this. Mapping is passing each input element through a function before passing it on. Filtering is testing each element with a predicate, and only passing it on if it passes. Flat mapping is passing the element through a function to get a number of new elements, then passing them all on one by one.
But you can do more! You can write a transducer which batches elements, a bit like the inverse of flat mapping. You can't do that with the existing Streams API at all!
The things transducers operate on, which Clojure calls reducing functions, end up being a bit different to Collectors: their accumulate operation returns a boolean to control early stopping, which lets them implement take while, limit, and so on, and that means they can't be parallel. But the vibes are exactly the same.
But parallelism is not compatible with early stopping at any stage - early stopping is inherently serial! - and transducers choose allowing any stage to stop early over being parallel.
Note that the Emacs Lisp variant is still under development. Once things looks good, we'd like to circle back around to Scheme's SRFI-171 (mentioned elsewhere here) and possibly redo it to include more fundamental primitives.
When you have a Unix pipeline, let's say "cat file | grep -v '^#' | sed 's/\/t//' | wc -l", or something, each step must specify where the input and output comes from. i.e., you can't "pull out" the grep, sed, and wc parts, and then tell it that it's coming from a network socket, or web service instead. It _must_ come from a file descriptor (stdin, stdout). And you can't (easily) multi-thread just the sed piece. Or (easily) add a logger between cat and grep. Or a retry between two parts. Or have it read from a network socket instead. And it's not simple to combine these pipelines with other complex pipelines.
Anyway, take that concept, run with it for a while, and you get transducers.
We use this technique in trading systems. It works very well for certain class of problems. It looks like this [0].
Expected nothing less from the legendary Clojure linguistics aficionados!
If you want to interleave IO into it then probably a library like conduit.
Given transducers, we can compose mutually independent parts at will:
- Data source (sequence, stream, channel, socket etc.)
- Data sink (sequence, stream, channel, socket etc.)
- Data transformer (function of any value -> any other value)
- Data transformation process (mapping, filtering, reducing etc.)
- Some process control (we can transduce finite data (of course) as well as streams, and also have optional early termination in either case. I'm not sure about first-class support for other methods like backpressure.)
e.g. read numbers off a Kafka topic, FizzBuzz them, and send them to another Kafka topic, OR slurp numbers from file on disk, FizzBuzz them, and push into an in-memory queue. But each time, you don't have to rewrite your core fizzbuzz function, nor your `(map fizzbuzz)` definition.
cf. https://www.evalapply.org/posts/n-ways-to-fizzbuzz-in-clojur...
I view this as a good sign. When two independent parties arrive at the same design it is usually an indication that they have discovered a universal and principled solution.
I consider the "conduit" library to be one of Haskell's "killer features", and sorely miss having something like it when working in other languages.
Maybe when Haskellers dismiss clojure transducers as being "just like conduit" it comes from a place of jealousy? I've seen several articles and discussions over the years of clojure transducers that take place outside of clojure communities and are aimed at the wider programming public, praising the benefits of it. But I've never seen conduit discussed outside of Haskell communities.
Blammo! Our gnome is now packaging together incoming items into bundles of three, caching them in the interim while the bundle is not complete yet. But if we close the input prematurely, it will acknowledge and produce the incomplete bundle:
(>!! b 4)
(>!! b 5)
(close! b)
; Value: [4 5]In this case `(map f)` notices that it was told to map a function but not told what to map it over, and so it decides to give you a new function. If this were a partially applied function the signature would be `[x] -> [y]` where `f: x -> y` would pick out the specifics.
But this is actually a totally different signature, isomorphic to `x -> [y]`, the signature of generators. Specifically `(map f)` generates what in Haskell would be `\x -> [f x]`.
However the type is not quite that straightforward for historical and compositional reasons; it is actually
∀z. (y -> z -> z) -> x -> z -> z
With the implementation being here \handle x -> handle (f x)
This is a sort of enhanced map that can do filtering because it uses concatMap (also known as >>=, “bind in the list monad”) to combine. So to `(filter pred)` you would have the isomorphic versions, \x -> if pred x then [x] else []
\handle x rest -> if pred x then handle x rest else rest
This also leads to an important nitpick for the article in question, a strictly better mental model of a transducer is not that it maps conveyor belts to conveyor belts, since that has more power than transducers do. (For instance, reverse is not a transducer.) But rather that it maps individual items on a conveyor belt, to their own conveyor belts on the first conveyor belt, then mashes them all together into one effective conveyor belt.So filter will either map an object to a singleton conveyor belt containing that thing, or an empty conveyor belt. You can implement `dupIf pred x = if pred x then [ x, x] else [x]` as a transducer too, `handle x (handle x rest)`. Conveyor belt that either has one or two elements on it. You can potentially put an infinite conveyor belt inside your conveyor belts and make a chunk of the input unreachable, although Clojure is strict so I have the feeling this will just run out of memory?
(defn transformed-belt [xf]
(let [ch (chan 1 xf)]
(thread
(loop []
(when-some [value (<!! ch)]
(println "Value:" (pr-str value)))
(recur)))
ch))
No clue what it means, but I'm convinced it can be written in 3 lines with a for loop in a way that 100% of people looking it will understand it, and not 5%. Probably even in Clojure!The number of exclamations in <!! means something, but I forget what, I think something about non/blocking.