My new favorite way of writing point-free Racket
Published on
Here I am after a long hiatus of not knowing what to write. It was not until today that I have found something write-worthy in my opinion, that at least talks about programming.
I am here today to talk about point-free syntax and improving my code slightly using it. I write Racket at work for data projects, and I simply have not found a better language suited for this job. However, it lacks a lot of language features that I would totally dream about using, but wouldn't even really know how to implement it in my code in any meaningful way. So, I stick to my guns and keep using Racket.
Racket has taught me how to write decent looking code that performs well, but the one thing Racket has been painstakingly avoiding teaching me is how to use compose
. compose
is a function that joins together in a mathematical way, such that if we were to compose the results of function g(x)
inside that of the function f(x)
, we could do that with compose
, and the intermediary function would become f(g(x))
.
(define (f x) (add1 x))
(define (g x) (* x 2))
(define (fog x) (f (g x)))
(define (gof x) (g (f x)))
; same thing using compose
(define fog (compose f g))
(define gof (compose g f))
Okay, easy enough. If you've used Haskell, then you know that this tacit-style of programming is pretty common-place. We want to avoid bloated code by constantly re-declaring program arguments and definitions all over the place, especially when it can instead be written in a point-free way. Notice how in my defintions using compose
, I didn't have to write out any variables, compose
simply passes the result onwards to the next function after evaluating the first function.
Haskell takes a more natural approach to function writing. All functions return curried functions, which means a function call simply passes on a curried lambda function.
-- regular function definition, with a variable
f :: Int -> Int
f x = succ x
-- same function, minus writing out the variable
f :: Int -> Int
f = succ
-- combining an addition plus a multiplication
fog :: Int -> Int
fog = succ . (*2)
Simple functions like succ
are a binding to a lambda that accepts one argument, so if we call it with zero arguments, it refers to the top-level function. But doing (*2)
captures the *
function for multiplication with exactly one argument, so this creates (\x -> ((* 2) x))
. This can then be composed with other functions as easily as we have above.
Racket, as great as it is, doesn't really have the same effect. We do have a curry function that is able to capture arguments and create curried versions of Racket code, but that isn't really what I'm looking for at this moment. In fact, I want something a little bit better, because my pain isn't about Racket not being curried, it's about how much of a pain in the butt it is to actually use the compose
function because of the lack of currying.
In modern languages like C#, F#, and even some dialects of JavaScript, you may see what's known as a pipe operator. Pipe is a common operation in F# and also C# because of LINQ. It's a way of passing data through a series of functions and retrieving the full result.
// store 3 numbers, add1 to all, then sub1 from all
let result = [1,2,3]
|> List.map add1
|> List.map sub1
This is mildly similar to chaining iterators in Rust, except in F# it works for any type as opposed to a single iterator.
// store 3 numbers, double them, collect results
let nums = vec![1,2,3];
let nums2 = nums.iter().map(|x| x*2).colect();
In Racket or other Lisp likes, achieving this effect would be like:
; Generate N numbers, double all of them, then filter the evens
(define evens-up-to
(compose
(lambda (lst) (filter even? lst))
(lambda (lst) (map (lambda (x) (* x 2))))
range)))
I think you can see the issue here... it's a bit backwards. The data source is at the very bottom, while the the intermediary functions are on top of it. Kind of hard to read, but in some cases this is fine. We shouldn't use compose
as if it were a pipe operator, we need to instead remember that it has one job to compose multiple functions. I would go crazy if I had to write nested lambdas for functions this way.
How compose
should be used to create complex functions from what I would call "baby" functions. Baby functions, or predicates, however you want to think about or use, build up to advanced logic when used properly that can be passed onto filter
and map
calls in cool ways. Once you get used to writing with compose,
you will tend to prefer using it a bunch over normal Racket definitions.
; my item definition for demonstration sake
(struct item (name qty bin))
; Compare this
(define (valid-bin-item? item)
(non-empty-string? (string-trim (filter-bins (item-bin item)))))
; to this
(define valid-bin-item?
(compose non-empty-string? string-trim filter-bins item-bin))
Look at that, such a massive reduction in pointless parentheses, and we don't need that one reference to an argument anymore. Maybe it becomes less clear as to this being a function, but it simplifies the way we can write code.
However, I have one major gripe with this: it's still a pain in the ass to do things this way. We got lucky that a function like non-empty-string?
exists, because previously, I was doing this in a much worse way before. And if we didn't have baby functions like those before, it can get real messy real quick.
(define valid-bin-item?
(compose (lambda (s) (string=? s ""))
string-trim
filter-bins
item-bin))
One single function that accepts multiple values instead of one can really put a damper on things, because it then must be wrapped into a procedure that can be composed. A lot of Racket functions simply don't have unary operating functions that do things, and instead must be wrapped around this kind of logic. It's boilerplate-y and I don't like it. I sometimes want to do regular expressions and it's kind of a pain as well.
; option 1
(define (valid-item? item)
(not (regexp-match? #rx"^ItemQ(C|D|F|X)"
(string-trim (item-name item)))))
; option 2
(define valid-item?
(compose
not
(lambda (s)
(regex-pmatch? #rx"^ItemQ(C|D|F|X)" s))
string-trim
item-name))
Eugh, both are extremely ugly and unsatisfying.
I have been writing code with both compose
and without, but never once did it come across my brain to do something a LOT differently.
First let's think about how we can use compose
. Since compose
is a function and not a syntax binding, we can use it with apply
.
; create our own compose
(define (my-compose . args)
(apply compose args))
((my-compose add1 sub1) 1)
> 1
This means we can technically do whatever we want with a list of functions.
; map a display-id function over the args
(define (my-compose . args)
(apply compose (map (lambda (x) (displayln x) x) args)))
((my-compose add1 sub1) 1)
#<procedure:add1>
#<procedure:sub1>
> 1
Now what if we started piping in values that weren't necessarily functions? We could use that info to create lambdas that do something with the values we pass. Like say we receive a bunch of numbers instead of functions, we could make addition functions like add1
.
; transform numbers into anonymous adding lambdas
(define (my-compose . args)
(apply compose (map (lambda (num)
(lambda (other)
(+ other num))) args)))
((my-compose 1 2 3) 4)
> 10 ; expanded to (+ (+ (+ 4 3) 2) 1)
So we can transform values into intermediary anonymous functions long before it gets transformed into a compose
function. This is starting to look like some macro-level transformations, but let's think of ways we can get clever with creating better composable tools.
This way we avoid having to write intermediary lambdas ourselves for doing all this basic work, and we can go about writing our point-free syntax even better. So here is where I will show you how I began writing my new compose
method, which I gave the symbol >>=
, inspired by Haskell's monadic bind (nothing similar though).
; Convert a function to a lambda
; if it's a procedure, do nothing but return
(define (thing->function x)
(cond
[(procedure? thing) thing]
[(or (boolean? thing) (number? thing))
(lambda (other) (= thing other))]
[(string? thing)
(lambda (other) (string=? thing other))]
[(list? thing)
(lambda (other) (equal? thing other))]
[(regexp? thing)
(lambda (other) (regexp-match? thing other))]
[else (error "Invalid thing passed")]))
(define (>>= . args)
(apply compose (map thing->function args)))
We can now re-write some of our code using this much-healthier composition. It will now create the intermediary lambdas for us, allowing us to get a little more creative with creating point-free functions.
; before - regular nesting
(define (valid-bin? bin-string)
(not (regexp-match #rx"^BadQ(C|D|F)" (string-trim bin-string))))
; after - using our new compose structure
(define valid-bin? (>>= not #rx"BadQ(C|D|F)" string-trim))
; before - needs to use non-empty-string? to compare empty string
(define valid-bin-item?
(compose non-empty-string? string-trim filter-bins item-bin))
; after - creates a (lambda (s) (string=? s "")) for us now
(define valid-bin-item? (>>= not "" string-trim
filter-bins item-bin))
I have been using this as a drop-in replacement for compose, on top of removing longer, unneeded functions now and using simple data types to create my new composed functions. I find it to be a lot more fun and interesting, and it really cleaned up a lot of code in places where I was using compose a lot.
It's interesting to me that throughout all my reading of Racket code and documentation, not once have I ever seen anywhere in the official docs ever recommend the use of compose
. I get that it's a reference guide and they have developed a sense of "style" they want to share for beginners who come in to contribute code, but for personal projects like this where code needs to not feel stale after time, I'm shocked to see a lack of compose
being recommended.
There exists a Racket macro package called threading which employs a way of re-writing code to become a piping operator a la F#. However, there are two macros ~>
and ~>>
because of how values get inserted into expressions, either as the first value or in the tail position. My expression works almost exactly like compose and doesn't really have any confusion about which macro is the right one. If I wanted to I could write a compose that reverses the arguments, that might make it more readable, but 🤷♀️.
Anyways, that was a lot of typing, but that's it for now. I will continue to use my >>=
in my own personal Racket code for the time being, as it has really helped me shrink down stale code in my work project. 🙂