Writing Racket Macros: define-syntax and phases

Writing Racket Macros: define-syntax and phases

19 May 2023
tutorials
computer-science, macros, racket

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.

Mastodon