Writing Racket Macros: define-syntax and phases
19 May 2023
There are a bunch of different ways of writing a macro in Racket. There are also some tricky things around phases to keep in mind. This is to help me keep them all straight.
3+1 ways to make a macro #
This form:
(define-syntax-rule (foo args ...) (use args ...))
is equivalent to:
(define-syntax foo
(syntax-rules ()
([foo args ...] (use args ...))))
Which, is in turn equivalent to:
(define-syntax foo
(λ (stx)
(syntax-case stx ()
[(gensymed-foo args ...) #'(use args ...)]))) ; gensymed-foo is like foo but doesn't match in the template
because syntax-rules
expands to syntax-case
with some fancy wrapping around it.
This makes syntax-case
the most powerful of them all, and it’s here that we’re treating syntax as data comes to the forefront: you can bind the syntax object directly (in our example, with the (λ (stx) ...)
part), pattern match on it, and finally return new syntax with the #'
notation.
define-syntax-rule
is the weakest of the three, but handles a common case: just a single form that you’d like to transform a little bit. This version doesn’t allow for writing multiple clauses.
define-syntax
with syntax-rules
is in the middle: the bodies of each of the rule match arms ((use args ...)
) are assumed to be syntax objects. This works well for the majority of cases I think. It’s only when you need to do really hairy stuff and manually generate code that can’t be put together with repeats (...
) that you need the full power of syntax-case
at your disposal.
Note that there are two forms of define-syntax
: (define-syntax (id stx) body ...)
is shorthand for (define-syntax id (λ (stx) body ...))
much like the shorthand for building functions with define
.
Bonus: more power #
A cousin of syntax-case
is syntax-parse
. I was confused about this one for a bit because the names are so close, and they share a lot of similarities. syntax-case
’s documentation is in the Racket Reference proper, while syntax-parse
’s documentation lives with the syntax module documentation.
Our previous example would be written like this:
(define-syntax (foo stx)
(syntax-parse stx
[(_ args ...) #'(use args ...)]))
or equivalently:
(define-syntax foo
(λ (stx)
(syntax-parse stx
[(_ args ...) #'(use args ...)])))
syntax-parse
supports keyword arguments like #:with
and #:when
to do some pattern matching and predicate checking. syntax-parse
will backtrack through match arms until it finds a matching and satisfying clause.
As far as I can tell, syntax-parse
is strictly the most powerful of the syntax manipulating forms that I’ve outlined here.
Phases #
It doesn’t seem like macros can use the functions in their current module by default. However, if you wrap your function definitions in begin-for-syntax
, this shifts the function definitions “up” a phase, and they can be used at the same level as functions.
(begin-for-syntax
(define foo (stx) (add-stx-prop stx 'bar 'baz)))
(define-syntax my-macro
(syntax-parse stx
#:with foo-ed (foo stx)
#'foo-ed))
You can also require
a module with the for-syntax
keyword:
(require (for-syntax "util.rkt"))
For more information on phases, see the Racket Docs on phase levels.