Skip to content
Opinionated threading macro for Racket
Racket
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.gitignore
DEV.org
README.org
info.rkt
main.rkt
tilda.scrbl

README.org

Motivation

Skip to Examples

tilda package implements the so called threading macro that simply lets you compose function invocations left-to-right from the first one to compute, then the next one that uses the result from the first, and so on. Contrast this with the typical way you would have to nest your function calls in a Lisp of your choice.

;; typically you would have to write something like this, where assuming eager
;; execution semantics range would compute first, then filter, then map, and
;; finally apply.
(apply + (map add1 (filter odd? (range 1 6))))

;; threading macro lets you express the same computation in a more natural order
(~> 6
    (range 1 ~)
    (filter odd?  ~)
    (map add1 ~)
    (apply + ~))

In the above example the latter expands exactly into the former expression.

Popularized by Clojure threading macros typically come in three flavors -> propagates result as the first argument in the following clause, ->> threads the result as the last argument and there’s usually the third macro that lets you assign the hole-marker - identifier to be replaced in each clause.

Since -> has already been taken for contract declarations, Racket usually uses tilda to name the same macros. That is to readily admit this isn’t the first Racket package to provide threading macros. If you want battle tested threading library I suggest installing Alexis King’s threading. I believe Greg Hendershott’s rackjure also ships them and comes with a lot of goodies popularized by Clojure. You can’t go wrong with either of them.

This implementation is merely a reflection of my taste when it comes to threading as well as a decent exercise in Racket macrology. If you are new to Racket, rolling out your own pre- and post-threading macros is a really good exercise, one that you could push even further, as I have, and take Racket’s rich macro-system for a test drive.

Speaking of taste:

  • I find naked function-id clauses, although sometimes cleaner, mostly inconsintent and really prefer to have every clause wrapped in parenthesis,
  • -> and ->> macros are awkward to use when some of the clauses need to take the first argument, while others expect the last; if you start with ->, then find you need to switch to post-threading you could simply use ->> as a clause in its own right, sadly the same trick doesn’t work the other way; this is when you would normally consider explicitly naming your hole-marker; I say this ought to be the default;
  • sometimes you want to perform intermediate computation to use in the clauses further along; at this point you have no choice but to split the threading; I think we could allow binding declarations between clauses.

So, the threading macro in tilda does exactly those things.

I sincerely invite veteran Racketeers to suggest improvements to my macro code. To a large extend this is an exercise, after all.

Installation

tilda package although appears seperate here is part of the prelude collection that is in heavy development atm and has not been released, yet. Once prelude is in good enough shape to be published tilda may or may not remain a separate package. It is, for the time being, to solicit advice from my betters in the Racket community. It is for that same reason I’ve not published it to the Racket package repository, plus I’d hate to cause fragmentation where you, the user, have tough time picking among the libraries that do more or less the same thing.

Simplest thing you can do is clone this repo and install it locally:

cd tilda
raco pkg install -u

which will link the cloned directory from your local Racket installation. This will also let you hack on the library and have your changes propagate to wherever you require tilda (most of the time). Assuming you’ve forked the repo you can also easily send pull requests via Github interface.

You can also do the entire clone, install, link dance in one go by passing --clone to raco. Please consult the docs here.

Knowing that in no way this README can replace proper Racket documentation I kindly ask the reader to make do for the time being as I learn how to scribble my way to perfection. Apologies.

Examples

~> macro

(~> expr clause ...)

       clause = thread-option
              | (expr ...)
              | (pre-expr ... hole post-expr ...)

         hole = ~
              |~id

thread-option = #:with pat expr/hole
              | #:do (expr/hole ...)
              | #:as id
              | #:when expr/hole expr/hole
              | #:unless expr/hole expr/hole

    expr/hole = (pre-expr ... hole post-expr ...)
              | (expr ...)
              | expr

Every clause must be wrapped in parenthesis and explicit hole-marker needs to be supplied unless you want to ignore the result of the previous clause, which you can do. Omitting a hole amounts to starting to thread from the current clause, however any bindings created with keyword thread-options would persist for the dynamic extent of the entire ~> computation.

(require prelude/tilda
         rackunit)

(check-eq? (~> 'foo
               (symbol->string ~)
               (format ":~a" ~str)
               (string->symbol ~))
           ':foo)

(check-eq? (~> 'foo
               (symbol->string ~)
               (format ":~a" ~str)
               ;; threading can be split by expr that ignores the result
               (list 42)
               (car ~))
           42)

Notice, that any unbound identifier that starts with tilda can be a hole-marker, so you can use either ~ or e.g. ~key interchangably, with the latter simply hinting to the reader of your code what sort of thing it’s supposed to be, making hole-markers essentially self-documenting.

You can interleave clauses with thread-options that let you perform and bind intermediate computations so that you may avoid having to split your thread just to let-bind something. This is of course nothing new to someone who’s used beautiful syntax-parse and friends:

(check-equal? (~> 'foo
                  (symbol->string ~)
                  #:with bar "-bar"
                  #:with baz "-baz"
                  (string-append ~foo bar baz)
                  (format ":~a" ~str)
                  (string->symbol ~)
                  #:do ((define l (list 1 2))
                        (set! l (cons 0 l)))
                  (cons ~sym l))
              '(:foo-bar-baz 0 1 2))

(check-equal? '(0 1 2) (~> 0
                           #:do ((define foo ~))
                           (add1 ~)
                           #:do ((define bar ~))
                           (add1 ~)
                           (list foo bar ~)))

;; note that bound ~id is not treated as a hole so isn't replaced
(check-equal? '(6 1) (let ((~foo 1))
                       (list (~> 2
                                 ;; with LHS takes a match pattern
                                 #:with (list a b) (list ~foo ~)
                                 (+ ~foo ~ a b))
                             ~foo)))

(check-equal? '(5 6) (~> 6
                         #:as num
                         #:when (even? ~) (set! num (sub1 num))
                         (list num ~)))

Note that #:with keyword allows a match pattern in its LHS.

~> is implicitly wrapped in an escape continuation bound to <~, so you can cut your thread short at any time and return any intermediate result:

(check-eq? 6 (~> 6
                 #:unless (odd? ~) (<~ ~)
                 (range 1 ~)))

;; #:as and short-circuit with or
(check-equal? (list 6 (range 1 6)) (~> 6
                                       #:as upper-limit
                                       (range 1 ~)
                                       #:as seq
                                       (filter odd?  ~)
                                       (findf even? ~)
                                       (or ~num (<~ (list upper-limit seq)))
                                       (* 2 ~)))

define~> and lambda~> macros

define~> lets you define functions whose body effectively threads whatever formal argument you specify as a hole-marker. Otherwise the grammar for its formal parameters is exactly that of Racket’s define. Note, however, that you may not use ~ as a formal parameter, that is because hole-markers are required to be unbound but of course the whole point of function parameters is to bind them in the body:

;; pick a parameter and prepend its name with ~ to thread its value in the body,
;; you can use that argument in the body as usual, however it won't be treated as
;; a hole-marker but as a bound identifier whose value will be used as expected
(define~> ((foo~> . ~arg) b #:c [c 3])
  (list* b c ~)
  #:as all
  (last ~)
  #:when (even? ~) (<~ 'even)
  (+ ~ (car all)))

(check-eq? ((foo~> 0 1) 2 #:c 3) 3)
(check-eq? ((foo~> 0 1) 2) 3)
(check-eq? ((foo~> 0 2) 3) 'even)

;; binding ~ however is not allowed
(check-exn #rx"attempt to bind hole-marker"
           (thunk
            (convert-compile-time-error
             (lambda~> ~ (car ~)))))

(check-eq? ((λ~> (a b . ~rest) (map add1 ~) (list* ~) (last ~)) 1 2 3 4) 5)
(check-eq? ((λ~> ~args (cdr ~) (last ~)) 1 2 3) 3)

Being unable to use ~ as a formal parameter above is somewhat unsatisfying. I could remedy that if I knew how to temporarily disable its binding for the dynamic extent of the body, which, I believe, should be possible by manipulating its set of scopes. If you happen to know how to do this in Racket, do tell.

You can’t perform that action at this time.