Notes on Metaprogramming Elixir
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]