Metaprogramming Elixir Notes

I’ve been reading Metaprogramming Elixir (rereading, actually) and trying to understand the macro system in Elixir at a deeper level. This is a rough collection of my notes. I imagine they won’t make much sense to someone that isn’t already passingly familiar with metaprogramming in Elixir, but perhaps they can be a useful reference to someone.

Quote, unquote, and ASTs

The quote macro will return the AST (abstract syntax tree) representation of the code that you pass to its do block.

iex(1)> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

Macros are code that writes code, via ASTs. They receive ASTs as arguments and return ASTs. The reason they operate at the AST level is so that they can write code at compile time. That is to say, during compilation the AST returned from a macro will be written to the call site(s) of the macro.

The Elixir AST is a tree of three-element tuples, where each tuple is of the following format:

  • First element is an atom representing the function call, or another tuple representing a nested call in the AST
  • Second element is metadata about the call
  • Third element is a list of arguments for the function call

Some Elixir data types are the same as their representation in the AST, however. These include atoms, strings, basic tuples, and some others. Quoting them will return the same value, since their representation in Elixir code and in the AST is the same.

iex(5)> quote do: :myatom
:myatom
iex(6)> quote do: {1, 2}
{1, 2}

quote and unquote can be used together to generate an AST. quote will return the AST representation of whatever high-level Elixir code is passed to it, while unquote will inject the value of an outside variable into the AST. The Metaprogramming Elixir analogizes it to string interpolation for ASTs.

It is important not to unquote a given variable more than once. This is because an argument to a macro is passed as the the AST expression of the argument, not the value itself as in a normal function call. This means that if you unquote an expression twice, the expression will be evaluated twice, leading to unintended side effects. See the quote docs.

You can use the bind_quoted option to quote to help ensure you don’t do this. It works like so:

defmodule BindExample do
  defmacro bind_example(one, two) do
    quote bind_quoted: [one: one, two: two] do
      IO.puts "one: #{one}"
      IO.puts "one: #{two}"
    end
  end
end

# iex(3)> BindExample.bind_example("foo", "bar")
# one: foo
# one: bar
# ok

A macro’s context is the place in the code where the macro injects code. The context includes the variables, bindings, and aliases of the place where the code is injected. Macro hygiene is the concept that deals with how macros interact with their context. There are generally two contexts to keep in mind when writing macros - the context where the macro is defined, and the context where it is called (compiled to). Generally speaking, macro hygiene will prevent you from inadvertently mutating the calling context. Also, keep in mind that unquoted code that is executed inside the macro will be executed at compile time, not runtime. For example:

defmodule MyMacros do
  defmacro context() do
    IO.puts "I am in the MyMacros context at compile time: #{__MODULE__}"

    quote do
      IO.puts "I can get the MyMacros context at runtime by unquoting: #{unquote(__MODULE__)}"
      IO.puts "I am in the caller's context at runtime: #{__MODULE__}"
    end
  end
end

defmodule Caller do
  require MyMacros

  def test_context() do
    MyMacros.context()
  end
end

# iex(1)> c "macro_context.exs"
# I am in the MyMacros context at compile time: Elixir.MyMacros
# [Caller, MyMacros]
# iex(2)> Caller.test_context()
# I can get the MyMacros context at runtime by unquoting: Elixir.MyMacros
# I am in the caller's context at runtime: Elixir.Caller
# :ok

You can use the var! macro to override hygiene, both to access variables from the caller’s context and to set variables in the caller’s context. This can create side effects from calling a macro and should be used sparingly, but can be critical in some situations.

Code injection, use, and __using__

The use macro will invoke the __using__/1 macro on the module that you pass to it, providing an idiomatic way to do module extension. It is common to see an import unquote(__MODULE__) statement inside a __using__ macro as a tidy way of injecting macros and function definitions from the module (that are defined outside the macro) being used into the calling module. For example:

defmodule MyMacro do
  defmacro __using__(opts \\ []) do
    quote do
      import unquote(__MODULE__)
    end
  end

  def imported, do: IO.puts "I'm imported"
end

defmodule UseMyMacro do
  use MyMacro

  def test_imported(), do: imported()
end

You are not limited to importing the module being used, and can import any arbitrary module you like in __using__.

You can also do code injection by defining functions directly inside the macro. For example, the following will work, even without a import unquote(__MODULE__) call, because we define the function directly inside the macro.

defmodule MyMacro do
  defmacro __using__(opts \\ []) do
    quote do
      def imported, do: IO.puts "I'm imported"
    end
  end
end

defmodule UseMyMacro do
  use MyMacro

  def test_imported(), do: imported()
end

Module attributes and compile hooks

When creating DSLs (domain specific languages) it can be useful to invoke the same macro many times, keep track of each execution, then at some later point do something with that data. A good example of this is a test framework, where a test macro registers each test case, and then calling MyTestSuite.run executes all the test cases.

You can accomplish this by registering a module attribute with the accumulate: true option like so:

Module.register_attribute(__MODULE__, :my_attribute_name, accumulate: true)

Be careful when making use of these attributes, however. Depending on where your function using the attribute is defined, it’s possible that the attribute hasn’t been accumulated yet when you try to use it. To circumvent this problem, Elixir provides the before_compile hook. The way to use it is a bit odd - you must declare a @before_compile __MODULE__ attribute, and then implement a __before_compile__/1 macro on the module that you registered the before compile callback for.

Here’s an example of this in action.

defmodule RegisterProperties do
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
      Module.register_attribute(__MODULE__, :properties, accumulate: true)

      # If you were to define the `print_properties` method here, @properties would still be blank.
      # Instead, using the before compile hook delays the definition of `print_properties` until
      # @properties has been populated.
      @before_compile unquote(__MODULE__)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      def print_properties, do: IO.inspect @properties
    end
  end

  defmacro register_property(property_name) do
    quote do
      @properties unquote(property_name)
    end
  end
end

# In another file
defmodule PropertiesExample do
  import RegisterProperties

  register_property :one
  register_property :two
end

PropertiesExample.print_properties # => [:one, :two]