Elixir Macros Demystified, part 3: defmacro and require

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!

defmacro

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:

  • They’re evaluated at compile time, which we’ll discuss below.
  • They take and return quoted expressions. (If you don’t know what that means, go back and reread part 2.)

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.

Macros are evaluated at compile time

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).

Want more posts like this in your inbox?

No spam. Unsubscribe any time.

require vs. import

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 importing 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 required:

 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.

Want more posts like this in your inbox?

No spam. Unsubscribe any time.