Functional Threading “Macros”
By Artyom Bologov
I love Common Lisp. But my dayjob is in Clojure (and TypeScript, ugh.) I can’t help but notice the convenience of threading macros.
Threading Readability #
Compare these two pieces of code:
;; No threading
(* (1+ (* significand (flt (/ (expt 2 significand)))))
(expt 2 (- exponent (flt bias)))
sign)
;; Threading
(->> significand
(expt 2)
/
flt
(* significand)
1+
(* sign (expt 2 (- exponent (flt bias)))))
Threading code is much more readable. It shows the sequence of actions in the order they happen in. It omits the obvious parentheses. It highlights the patterns in function applications.
One problem with threading macros though: they are macros. Lisps are good at sophisticated syntax transformations. Other languages—not so much. So we need other ways to thread functions together. Like... combinators?
Threading Combinators #
The idea is simple: we need several functions that’d pass closures around. Bubbling outward and storing the inner functions for until the outer ones run. (Reversing the applicative inner->outer evaluation order, thus the need for extra closures.) Must look something like:
piping 3 : pipe (* 2) : pipe 1+ piped .
;; or, abbreviated
--> 3 : -> (* 2) : -> 1+ >-- .
;; or, with colons expanded to parens
--> 3 (-> (* 2) (-> 1+ >--)) .
Note on Lamber syntax
The language I’m using in this post is
Lamber, my Lambda Calculus compiling language.
It features a minimalist syntax with only functions, values, if
-s, and operators like
Wisp’s colon nesting operator
and terminating period (similar to Lua’s end
.)
First, let’s add a function that’ll initiate the piping. Nothing fancy, just take the initial value and a curried function. And then apply the function to the value.
def piping fn (x f)
f x .
;; also known as T combinator
alias piping T .
Now to the workhorse pipe
function:
def pipe fn (f g x)
g : f x .
The way this magic works is:
- We take a function to pipe and close over it
- Then we take a “continuation” to apply to this function
- And then we do the closed-over action on the value and “continue” the computation
So pipe (* 2) : pipe 1+ piped
means
- closing over
(* 2)
and - taking a function closed over
1+
- and then applying this
1+
function to result of(* 2)
applied to the data.
Nice reversal, huh?
But what does this piped
thing does?
It acts as a piping terminator, essentially returning what’s passed to it:
def piped fn (x) x .
;; or
alias piped identity .
We accept second function into pipe
.
And then apply it to the result of the first one.
And the best way to stop the computation is to simply return the data passed into this second function.
Thus identity
.
Not sure if I explain it well enough. So here’s an expansion process:
piping 3 : pipe (* 2) : pipe 1+ piped .
piping 3 : pipe (* 2) : pipe 1+ identity .
piping 3 : pipe (* 2) : (fn (f g x) g : f x) 1+ identity .
piping 3 : pipe (* 2) : (fn (g x) g : 1+ x) identity .
piping 3 : pipe (* 2) : (fn (x) identity : 1+ x) .
piping 3 : pipe (* 2) (fn (x) identity : 1+ x) .
piping 3 : (fn (f g x) g : f x) (* 2) (fn (x) identity : 1+ x) .
piping 3 : (fn (g x) g : (* 2) x) (fn (x) identity : 1+ x) .
piping 3 : (fn (x) (fn (x) identity : 1+ x) : (* 2) x) .
(fn (x f) f x) 3 (fn (x) (fn (x) identity : 1+ x) : (* 2) x) .
(fn (f) f 3) (fn (x) (fn (x) identity : 1+ x) : (* 2) x) .
(fn (x) (fn (x) identity : 1+ x) : (* 2) x) 3.
(fn (x) identity : 1+ x) : (* 2) 3 .
(fn (x) identity : 1+ x) : (* 2 3) .
(fn (x) identity : 1+ x) : 6 .
identity : 1+ 6 .
identity 7 .
7 .
This threading is still relatively wordy and noisy, even when using ->
aliases.
But that’s mostly due to Lamber’s minimalism and colon reliance.
Other languages might even introduce special operators behaving this way.
And it’ll work just fine without colons and nesting!
The implementation in this post is thread-last, which rhymes well with Lamber’s philosophy: functions should be tail-heavy, putting the data to act on as last argument. So I only need thread-last. I leave thread-first combinator (and multi-arg ones) as an exercise to the reader.