Crystal Lang: What are Macros and how are they useful?

The Crystal Programming Language includes a feature called Macros. As described by the Crystal Documentation “Macros are methods that receive AST nodes at compile-time and produce code that is pasted into a program.”, to simplify this means you can write code that writes more code. This post is a deep-dive into how to write macros and why they’re useful.

What’s an Abstract Syntax Tree (AST)?

To understand how Macros work, you should be familiar with the concept of an Abstract Syntax Tree. Wikipedia describes an Abstract Syntax Tree as follows “In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language.”. To describe at a higher level, before a compiler can compile your code is needs to read through all of the source files and remove all unnecessary characters, spacing, and such along with adding useful annotations such as a line number for helpful annotations (normally this would involve changing the files, whereas we’re changing an object representing the syntax instead. An AST Node is just part of the tree for example one node might be a simple return statement while another might be variable name equals three. If you want to learn more I recommend reading over the slides from Prof. Stephen A. Edwards Lecture on Abstract Syntax Trees or listening to Daniel Sanchezs Lecture on Compilers both resources will help you get a better grasp than what I can explain in one short section of a blog post.

A first-look at Macros

Macros are (in my opinion) an under-documented feature of Crystal when compared to the other features of the language. Macros are powerful but are also confusing to use, it’s a topic as advanced as meta programming, so make sure you have a good understanding of the other functionality of the crystal language before attempting to use macros. Macros have some special syntax such as { {name}} to insert code or the value of a variable somewhere into the code defined by the macro and a limited subset of language features (some developers say it’s too limited and needs improvement). Macros are challenging but rewarding to use, if you are still reading this blog post give them a try.

How to create a Macro

When trying out a language feature or if I just want to experiment for a while I type crystal play into terminal and load up the in-browser Crystal REPL. Since Crystal is a compiled language, it waits for the user to stop typing, compiles the code quickly, and returns the results of said code in the browser. It’s not a pure REPL but it serves it’s purpose for me. The Crystal Documentation provides an example macro, consider the following code block:

1
2
3
4
5
macro define_method(name, content)
def { {name.id}}
{ {content}}
end
end

This generates:

1
2
3
4
5
6
     def foo
1
end
define_method foo, 1

foo #=> 1

You use the keyword macro to start a block choose a name and define any parameters, then inside the block use the parameters to define methods. This happens at compile time and not runtime, it takes the data from your source files, expands it using the macro and to oversimplify things it copies and pastes the resulting code into your application. You cannot call define_method at runtime. Remember this when using macros in your programs written using Crystal.

A brief Macro syntax summery

I’ve summarized a few Macro syntax features below to give you ideas on what you can do and how you can do it. Several of these examples are from the official docs on macros (which you should read)

Interpolation

You can use { {…} } to interpolate, an AST node.

Conditionals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
macro define_method(name, content)
def { {name}}
{% if content == 1 %}
"one"
{% elsif content == 2 %}
"two"
{% else %}
{ {content}}
{% end %}
end
end

define_method foo, 1
define_method bar, 2
define_method baz, 3

foo #=> one
bar #=> two
baz #=> 3

Iterators

1
2
3
4
5
6
7
8
9
10
11
12
13
macro define\_dummy\_methods(names)
{% for name, index in names %}
def { {name.id}}
{ {index}}
end
{% end %}
end

define\_dummy\_methods \[foo, bar, baz\]

foo #=> 0
bar #=> 1
baz #=> 2

Use-case: Amber Framework uses Macros to register before and after filters

The Amber Framework supports before_filters and after_filters this allows you to run (or yield) a block of code before and after a request. It accomplishes this using a Domain Specific Language which relies on Macros internally. Take a look at the following code block,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
\# amber/src/amber/dsl/callbacks.cr 
module Amber::DSL
module Callbacks
macro before_action
def before_filters : Nil
filters.register :before do
{ {yield}}
end
end
end

macro after_action
def after_filters : Nil
filters.register :after do
{ {yield}}
end
end
end
end
end

The module is loaded and runs two macros, a before_action macro which defines a method called before_filters with no return value and goes through the filters and yields all of the code blocks, the same occurs with the after_action macro and after_filters method. The copying and generation of code is done at compile-time maximizing performance run-time, a drawback of macros is that they cannot rely on run-time information (however the generated code can still use run-time information keeping them useful, however the macro doesn’t exist after it’s compiled only the resulting code from the macro during the compilation process).

Conclusion

I hope this post gave you a better understanding of Crystal Language Macros and gave you some ideas on when and how to use them.