Learn Phoenix LiveView is the comprehensive tutorial that teaches you everything you need to build a complex, realistic, fully-featured web app with Phoenix LiveView. Click here to learn more!
This is Part 3 in a series on Elixir macros - what they are, why they matter, and how to use them.
In Part 1 we tried to write a simple testing library that mimics the behavior of ExUnit. We created this basic test harness, with a single test that deliberately fails:
defmodule Tests do
import Assertions
def run_test() do
IO.puts("run test:")
assert 2 + 2 == 5
end
end
Tests.run_test()
We want to define Assertions.assert/1
such that, when the test fails, we get some helpful output that explains what went wrong, similar to what ExUnit would print:
$ mix test tests.exs
1) test fail on purpose (Tests)
tests.exs:4
Assertion with == failed
code: assert 2 + 2 == 5
left: 4
right: 5
stacktrace:
tests.exs:6: (test)
As we saw, this isn’t possible if assert/1
is a function. Function arguments are evaluated before they’re passed in, so the code assert 2 + 2 == 5
would be equivalent to assert(false)
. assert/1
only knows that the test failed, but it can’t know why. That’s as far as we can get if assert/1
is a function.
In Part 2 I introduced quoted expressions, otherwise known as abstract syntax trees (ASTs), and showed how to create them using quote
and unquote
. With these in our toolbelt, we’re finally ready to write our first macro!
Macros in Elixir are defined with defmacro
. Syntactically they look like functions: they have a name, arguments, and a body defined between do
and end
. But there are two important differences:
Let’s rewrite Assertions.assert/1
as a macro using defmacro
:
defmodule Assertions do
defmacro assert(assertion) do
quote do
if unquote(assertion) do
IO.puts("Assertion passed")
else
IO.puts("""
Assertion failed
code: assert #{unquote(Macro.to_string(assertion))}
""")
end
end
end
end
Without making any changes to our Tests
module, this new macro already has the effect we want:
$ elixir tests.exs
fail on purpose
Assertion failed
code: assert 2 + 2 == 5
Macro arguments are quoted before being passed in to the macro body. So when Elixir evaluates assert 2 + 2 == 5
, it converts 2 + 2 == 5
into a quoted expression. We can see this if we inspect the value of assertion
within the macro:
defmodule Assertions do
defmacro assert(assertion) do
+ IO.inspect(assertion)
quote do
…
end
end
end
Run the test again and you’ll see it prints this tuple:
{:==, [line: 22, column: 18], [{:+, [line: 22, column: 14], [2, 2]}, 5]}
This quoted expression is effectively the same as the result of running quote do: 2 + 2 == 5
in the Elixir console, except it includes some extra information about the line and column numbers of the original expression in the codebase. This might be useful when debugging, for example.
So when Elixir evaluates assert 2 + 2 == 5
, it quotes the expression 2 + 2 == 5
, passes it to the macro, and then replaces the original call to assert
call with the quoted expression that the macro returns.
It’s as if our original test function has been transformed from this:
def run_test() do
IO.puts("run test:")
assert 2 + 2 == 5
end
… into this:
def run_test() do
IO.puts("run test:")
if 2 + 2 == 5 do
IO.puts("Assertion passed")
else
IO.puts("""
Assertion failed
code: assert 2 + 2 == 5
""")
end
end
There are many subtleties to how this works. We’ll cover some of them in this series. But at a basic level, you can think of macros as transforming one piece of code into another.
Importantly, this transformation happens when your code is compiled, not when it’s run.
Since macros are evaluated at compile time, there’s no performance penalty associated with them. Your expanded macro code will run just as fast at runtime as if you’d written it out explicitly with no macro.
But the compile-time nature of macros leads to some pitfalls to be aware of. In particular, any code outside of the returned quote
block will be run once, when the macro is compiled, and not later when the macro is called.
For example, suppose assert/1
has a side effect such as printing to stdout:
defmodule Assertions do
defmacro assert(assertion) do
+ IO.puts("asserting the assertion")
quote do
if unquote(assertion) do
IO.puts("Assertion passed")
else
IO.puts("""
Assertion failed
code: assert #{unquote(Macro.to_string(assertion))}
""")
end
end
end
end
Note that we’re calling IO.puts/1
outside of the quote
block.
Save that module in a file called assertions.ex
, and create a Tests
module in test.ex
with two assertions:
defmodule Tests do
import Assertions
def run_test() do
IO.puts("run test:")
assert 2 + 2 == 4
assert 2 + 2 == 5
end
end
Now open an iex
console and compile both files:
iex> c "assertions.ex"
[Assertions]
iex> c "tests.ex"
# asserting the assertion
# asserting the assertion
[Tests]
The line “asserting the assertion” got printed twice while compiling the Tests
module - once for each time the assert/1
macro is expanded while compiling Tests.run_test/0
.
Then it doesn’t get printed again when the tests are run:
iex> Tests.run_test()
# Assertion passed
# Assertion failed
# code: assert 2 + 2 == 5
This example is contrived, but it illustrates the point. If the code needs to be run at runtime, not compile time, make sure you only evaluate it inside the returned quote
block.
Incidentally this is why - as I touched upon in part 2 - it’s not recommended to use Code.eval_quoted/2
inside a macro. Take it from the official docs:
Warning: Calling
Code.eval_quoted/2
inside a macro is considered bad practice as it will attempt to evaluate runtime values at compile time. Macro arguments are typically transformed by unquoting them into the returned quoted expressions (instead of evaluated).
No spam. Unsubscribe any time.
In Elixir you can call a function using its fully-qualified name including the module:
iex> String.trim(" hello ")
"hello"
So you might expect something similar to be possible with macros:
defmodule Tests do
def run_test() do
Assertions.assert 2 + 2 == 4
Assertions.assert 2 + 2 == 5
end
end
We’re no longer import
ing Assertions
. Instead we’re calling assert/1
using it’s fully-qualified name.
But when you recompile, you’ll get a warning:
iex> c "tests.ex"
warning: you must require Assertions before invoking the macro Assertions.assert/1
│
3 │ Assertions.assert 2 + 2 == 4
│ ~
│
└─ tests.ex:3:16: Tests.run_test/0
└─ tests.ex:4:16: Tests.run_test/0
Then running the tests will raise an error
iex> Tests.run_test
** (UndefinedFunctionError) function Assertions.assert/1 is undefined or private. However, there is a macro with the same name and arity. Be sure to require Assertions if you intend to invoke this macro
Assertions.assert(true)
tests.ex:3: Tests.run_test/0
iex:3: (file)
Macros don’t work like functions. We can’t call them with their fully-qualified name unless the module has been require
d:
defmodule Tests do
+ require Assertions
+
def run_test() do
Assertions.assert 2 + 2 == 4
Assertions.assert 2 + 2 == 5
end
end
Now it works:
iex> c "tests.ex"
…
iex> Tests.run_test()
# Assertion passed
# Assertion failed
# code: assert 2 + 2 == 5
Again, it comes down to the fact that macros are evaluated at compile time. By calling Assertions.assert/1
within Tests
, we’re implicitly introducing a dependency: Tests
can’t be compiled unless Assertions
has been compiled first.
But the Elixir compiler can’t figure out every possible macro-based dependency on its own. To avoid issues during compilation, it requires us to require
all macros before we call them - or import
them, which implicitly calls require
.
require
takes an :as
option that works similarly to alias
:
defmodule Tests do
require Assertions, as: A
def run_test() do
IO.puts("run test:")
A.assert 2 + 2 == 4
A.assert 2 + 2 == 5
end
end
Our assert/1
macro is still very simple, and it’s obviously not nearly as powerful as the “real” ExUnit assert
macro that we’re trying to emulate. But in the next post (coming soon), we’ll learn more about what’s possible with macros, and make our test harness a bit more sophisticated.
No spam. Unsubscribe any time.