Macros in Racket: an Outline
Racket has a very powerful macro system, and a flexible one, too. Learning about macros can be a little intimidating, especially if you just dive into the Racket Reference chapter on the subject! One of the best-regarded tutorials on Racket’s macro system is Greg Hendershott’s, which nods to this with the title Fear of Macros. You should read it either before or after this outline.
Macros are procedures that transform a program’s code. One thing to keep in mind is that macros in Racket are basically normal Racket code, but instead of operating on typical data structures or values they operate on syntax objects. You can think of a syntax object as an s-expression or identifier, bundled up with some information about where it’s found in source.1
The other key difference between macros and normal Racket code is that macros perform their operations in a separate phase of program execution. One part of working with macros is indicating to Racket what to do at syntax phase (or “compile time”) and what to leave for run time.
Headings may be accompanied by links to the Racket [R]eference or [G]uide.
Racket’s primary method for introducing a macro to a program is the
define-syntax is a compile-time version of
define, and there are some notable similarities between the two. When we
define something, we’re telling Racket to substitute a value (or computation) wherever it sees a keyword – for example,
(define PI 3.14). Similarly,
define-syntax tells Racket how to substitute syntax for other syntax wherever it sees a keyword.
Here’s how we write a function in runtime-world:
(define id-to-recognize (lambda (args) "return this string"))
And here’s how we might write a function to transform syntax:
(define-syntax id-to-recognize (lambda (label-for-the-syntax-object) (syntax "the-syntax-object was replaced with this")))
They look awfully similar, because a macro is really just a function that accepts and returns syntax objects. In both cases, Racket will look for the specified identifier, and will replace it with the result of the procedure we specify.
One difference is in what the function expects to receive: the normal run-time method call passes its arguments – the remainder of the contents of the s-expression – to the
lambda. The macro passes the entire syntax object – the whole s-expression, bundled up with information about its source and scoping. We can then access the s-expression, and pass it around to be manipulated, using the label we defined. Here, though, we’re just replacing the s-expression with a pre-defined string.2
Just like the regular
define-syntax comes with a shorthand form so that we don’t have to write
lambda when specifying the transformation function:
(define-syntax (id-to-recognize label-for-the-syntax-object) (syntax "the-syntax-object was replaced with this"))
As mentioned above, we aren’t actually doing anything with the syntax object that
define-syntax grabs – we’re just sending back a string.
II. How to Transform It
Next up, let’s look at the tools available to carry out transfomations on syntax.
A. General Racket Tooling G
We can work with syntax objects using the same functions as we’re used to anywhere else. We could use
syntax->list to convert our s-expression syntax object to a list of syntax objects within the expression, for example, and then use
cdr to slice and dice it.
Because syntax transformation takes place in a separate phase than normal code, though, we may need to tell Racket explicitly that we want normal code tools around for that phase – all Racket automatically makes available during syntax phase is
Each of these are ways to include or create functionality we need within our transformation function. They are defined outside of a macro itself, though – don’t include them within
1. (require (for-syntax …))
Just like you’d expect, this makes the specified modules available during the syntax phase, so we can their functions within the transformation function in
We can write our own modules and
for-syntax, as well.
2. (begin-for-syntax …) R
Anything included within this s-expression is evaluated at syntax phase, and in sequence. This allows us to define helper functions without having to put them in a separate module that we then
(require (for-syntax ...)):
(begin-for-syntax (define (a-helper args) ...) (define (another-helper) ...))
3. (define-for-syntax …) R
This allows us to jump right into defining a helper function for syntax phase. It’s the same thing as
(begin-for-syntax (define ...)).
B. Pattern-Matching Transformers
Manually breaking down syntax objects and operating on them could be a real pain. Instead, it’s handy to name each part of our syntax object and then to compose our new syntax using a template that refers to those names. Racket has robust pattern-matching tools for the syntax phase. Some of these are used as the transformer function within
define-syntax; others are used in conjunction with it or in place of it entirely.
syntax-rules gives us a straightforward way to match up different forms of a syntax object with the various transformations we’d like to execute. We use
syntax-rules in place of the transformer function within
syntax-rules takes any number of pairs, where the first member is a pattern, and the second is a template. If those sound pretty similar, keep in mind that a template is something that still needs to be filled in with content. The pattern should look like an s-expression you want to match, but with labels in place of each member of the expression. The template should look like the code that you want to result from the transformation.
(define-syntax diff-num-args (syntax-rules () [(diff-num-args arg1) (print (string-append "There was one argument: " arg1))] [(diff-num-args arg1 arg2) (print (string-append "There were two arguments: " arg1 " and " arg2))])) $ (diff-num-args "A") "There was one argument: A" $ (diff-num-args "A" "B") "There were two arguments: A and B"
You might notice the empty set of parentheses right after
syntax-rules – that’s for any terms that we want to treat literally in the patterns, so that they won’t be assigned as labels for the different s-experession pieces.
If we only have on pattern to match, we can use
define-syntax-rule. It acts just the same as
syntax-rules,3 except that we can’t reserve literal terms. Note that
define-syntax-rule doesn’t take place in the context of
define-syntax – instead, it replaces both
syntax-rules. Of course, you can only use it for a single transformation rule, so it’s really the quick-and-dirty option.
Pattern matching is nice, but there’s a limitation to
syntax-rules: we can’t do anything except replace syntax with other syntax. It would be nice to react to some patterns in more comprehensive ways, such as by handling errors.
syntax-case gives us that flexibility: instead of pairs of patterns and templates,
syntax-case involves pairs of patterns and expressions. Expressions are normal Racket code, but we can dive into template mode as needed – to check different bits of the input syntax, for example, or ultimately to return the final result of the macro, which will be a syntax object.
The Racket Guide section on
syntax-case has a clear example of this.
III. Other Resources
- Fear of Macros
- Macros and Languages in Racket
- A Scheme syntax-rules Primer
- DSL Embedding in Racket [YouTube]
The identifier or s-expression itself can be unwrapped from the source information quite easily, using the
I’ve been writing as though an s-expression must be involved. This isn’t true – both run-time functions and macros can identify and replace “naked” identifiers. ↩
In fact, it’s a Racket macro that expands to