Yet Another Threading Macro
Pipelines With Pleasant Exits
In which a threading macro is demonstrated that provides syntax for early exit, per-step variable bindings, and arbitrarily formed steps.
Here's What I Made
Don't as me why. I think I was looking at discord and somebody asked for a way to, essentially, get Rust/Haskell's Result
-like composition in Common Lisp. This isn't quite how to do that, but it gets you some of the way there.
What I made instead is a threading macro, called >>
, that gives you a handy exit early syntax. Here are some examples:
;; define a function
(defun div-random (n r)
(>> ()
(random r) ; start with a random number
:? (not (zerop _)) ; check that it is not zero
(/ n _))) ; and divide by it
;; run it a few times
> (div-random 10 3)
10
> (div-random 10 3)
5
> (div-random 10 3)
NIL
Obviously this isn't a very interesting example, but you can already see two neat tricks.
First, the use of the :?
syntax. This treats the next form as a predicate on the output of the previous form. If that predicate returns NIL
, the whole thing exits, returning the value of a keyword argument :fail
that defaults to NIL
.
Next, the use of _
to create a variable that receives the value of the previous expression in the pipeline.
It should be noted that, when a :?
form returns true, then it simply passes the value it tested on to the next form.
Variable Tweaks
In the above, only _
was used as a "substitution variable" to receive the output of the previous step. But there is more to variables than that.
Naming Substitution Variables
First, you can name variables. So _x
, and _my-var
are also variables. This feature is supported to improve legibility of pipelines when appropriate.
Custom Variable Prefixes
In fact, the general format for a variable is PREFIX[NAME]
, and you can set the prefix to whatever you like. For example:
;; here you set variables to be prefixed by ?
(>> (:prefix ?)
1
1+
(* ?x 3))
6
In the above, you set the variable prefix to ?
. This lets you choose a prefix in the off chance that _
will cause some kind of conflict, possibly treating an unwanted symbol as a substitution variable.
In the above example, you also snuck the
1+
function into your pipeline. In general, your pipelines can use any bare symbols for whichfboundp
isT
. You CANNOT, however, use symbols bound to a function in the variable name space.
Repeated Variables
Within any single pipeline form, you can repeat the variable it receives as many times as you like. E.g.
;; a contrived example
(>> ()
(random 10)
(list _r :+ _r := (+ _r _r)))
(4 :+ 4 := 8)
Under the hood, the macro detects repeated variables. When it finds them, it wraps the expression in a let
form and binds the value of the previous expression to a temporary hidden variable. That temporary hidden variable is then substituted into the current form wherever the substitution variable appears.
Some threading macros bind every expression in the pipeline to such a hidden variable, all inside a big
let*
form. Other threading macros simply substitute the entire previous form into a specific spot in the following form. This macro combines the two approaches: it only useslet
bindings when it needs to, and if it never does, it substitutes the entire expression into the next form at the right spot.
Custom Failure
When a :?
form returns NIL
, by default so does the entire expression. However, you can customize the "failure expression" using the :fail
keyword.
Consider the following:
;; custom failure return
(>> (:fail (list :ohno (random 1.0)))
(random 2)
:? evenp)
The above expression will return 0
half the time. The other half of the time it will return a list that looks like (:ohno 0.84352255)
.
Controlled Early Returns
If for some bizarre reason you want to return early without using a :?
check, you can do so using the :block
keyword argument and an good ole return-from
form.
;; a little silly
(>> (:block wut)
10
write-to-string
(concatenate 'string _ " is a number")
(if (< (length _s) 100)
(return-from wut)
_s))
NIL
In the above, NIL
is returned b/c "10 is a number" is shorter than 100 characters in length.
But Really Why?
Some people have bizarrely strong opinions about threading/piping macros. I think they're neat. They're especially great for fetching nested data.
Suppose you've got a CLOS object with slots that are other CLOS objects with plist slot values whose values are themselves other CLOS objects.
Threading macros are handy for what would otherwise be illegible and onerous slot access.
For example:
;; nice?
(>> ()
a-foo-instance
bar-reader :? bar-ok-p
zoo-reader
(getf _zoo :animal) :? (animal-heaver-than-p _ 100)
blar-reader)
Might be easier to read than
;; it is a matter of preference
(let ((bar
(a-bar-reader a-foo-instance)))
(when (bar-ok-p bar)
(let ((animal
(getf (zoo-reader bar) :animal)))
(when (animal-heaver-than-p animal 100)
(blar-reader animal)))))
That Is All
So yeah. I'm not totally sure why I made this. I like the innovation of using the :?
syntax, and it makes me think the macro could be adapted to include other odd syntaxes.
You can find the source code here.
As ever, I hope you have enjoyed this thoroughly self-indulgent episode of Parenthetical Curios.