Crystal Lang: Macros and how they’re useful

Crystal Language Logo
Logo of Crystal Language

The Crystal Programming Language includes a feature called Macros. Crystal’s Documentation states: “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.

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.

Wikipedia https://en.wikipedia.org/wiki/Abstract_syntax_tree

Before a compiler can do its work it reads through all of the source files. While reading it removes all unnecessary characters, spacing, and such. It adds useful annotations such as a line number in case the compiler encounters an error. This is how stack traces work internally. Normally this process would involve changing the files somehow, 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 that equals three. If you want to learn more I recommend reading over the slides from Prof. Stephen A. Edwards Lecture on Abstract Syntax Trees. You could also try listening to Daniel Sanchezs Lecture on Compilers. Both resources will help you get a better grasp than what I can explain.

A first-look at Macros

Macros are an under-documented feature of Crystal when compared to the other features of the language. They are a powerful feature but are also confusing to use. It is a topic as advanced as meta programming. Before learning how to use macros you should have a good understanding of the other functionality of Crystal first. 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:

macro define_method(name, content)
  def {{name.id}}
    {{content}}
  end
end
# This generates:
#
#     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

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

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,

# 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 at run-time. A limitation of macros is that the macro cannot rely on run-time information. The macro generated code can use run-time information.

Conclusion

I hope this post gave you a better understanding of macros. Go give macros a try and see what you can build.

%d bloggers like this: