Understanding Elixir macros

Written . Tagged Elixir, Macros.

Elixir macros are conceptually quite simple, though they can be daunting when you’re starting out.

It took me plenty of flailing about to get a clear mental model of how they work, and plenty of experimentation.

I thought I’d write up some of my insights, in case they help others get there sooner.

This is not a “write your first macro” tutorial. The reader is expected to have made at least an attempt or two at writing macros, but may still feel that they’re hard to grasp.

Compile-time vs. run-time

Consider this example:

example.ex
defmodule MyMacro do
  defmacro example({value, _, _}) do
    IO.puts "You'll see me at compile-time: #{inspect value}"

    quote do
      IO.puts "You'll see me at run-time: #{inspect unquote(value)}"
    end
  end
end

defmodule Lab do
  import MyMacro

  def run do
    IO.puts "Someone called the run function."
    example(hello)
  end
end

Let’s compile that file:

$ elixirc example.ex
You'll see me at compile-time: :hello

And now let’s call the Lab.run function:

$ elixir -e Lab.run
Someone called the run function.
You'll see me at run-time: :hello

What is happening here is crucial to understanding macros.

Even though we put example(hello) inside the run function, the macro executes when we compile the file. So the macro runs at compile-time.

It doesn’t matter whether or not we’ll ever call the Lab.run function. We could even do something like if false, do: example(hello). The macro will still run when we compile.

So why don’t we see the “You’ll see me at run-time” message at compile-time?

quote will turn Elixir code into an abstract syntax tree (AST), without executing that code:

iex(1)> quote do: IO.puts("Hello")
{{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["Hello"]}

So the return value of the macro is an AST much like {…, [], ["Hello"]}. The code inside the block is never executed at compile time.

The compiler will then effectively replace example(hello) with the code represented by this return value, as though we had written

def run do
  IO.puts "Someone called the run function."
  IO.puts "You'll see me at run-time: #{inspect :hello}"
end

This also implies that you want to do as much work as possible outside the quote block, because that work will only happen during compilation, and not on each run.

AST in, AST out

Another crucial insight is that macros simply take an abstract syntax tree (AST) as input, and return another as output. Anything else is just implementation details.

(To be accurate, macros don’t have to take any input or return any output. But they typically do. And when they do, it’s all ASTs.)

These ASTs are sometimes called “quoted expressions” in Elixir, because you can use quote to create them from code expressions.

But quote is just one of those implementation details. It’s just a convenience. You can write a macro without it. Elixir doesn’t care how you build the AST:

defmodule MyMacro do
  defmacro example do
    {:+, [], [1, 2]}
  end
end

defmodule Run do
  import MyMacro

  def run do
    IO.puts example
  end
end

Run.run  # => Output: 3

Because quote is just a detail, it also doesn’t have to be the last thing you do in the function, and you can do it more than once. For example, with Enum.map:

defmodule MyMacro do
  defmacro make_methods(numbers) do
    Enum.map numbers, fn (num) ->
      quote do
        def unquote(:"say_#{num}")() do
          IO.puts unquote(num)
        end
      end
    end
  end
end

defmodule Run do
  import MyMacro

  make_methods([1, 2])

  def run do
    say_1
    say_2
  end
end

Run.run

The Enum.map returns a list of ASTs. A list of ASTs is just another, more complex AST.

We can verify this with a smaller experiment:

iex(1)> Code.eval_quoted [ quote(do: IO.puts(1)), quote(do: IO.puts(2)) ]
1
2

If you want a more useful example, I implemented a regex_case macro that liberally mixes quote with “raw” ASTs.

Visualizing the AST

It can be hard for a human brain to parse a complex AST accurately.

I think much more clearly if I can visualize things, so I made a small Phoenix app called QED to show Elixir ASTs.

It looks something like this:

QED screenshot

Try it out.

Questions?

Please let me know if anything above is unclear, or if I got anything wrong.

Also, if there is anything else about Elixir macros that you find hard to grasp, do write a comment. I enjoy figuring these things out.