Macro Madness: when busting boilerplate backfires
Intro
https://twitter.com/robertellen
https://github.com/rellen/talks/blob/main/2022-02-16-macro-madness/macro_madness.livemd
Macros?
Macros are the meta-programing construct in Elixir, declared through the defmacro
… err, macro
# kernel.ex
defmacro defmacro(call, expr \\ nil) do
define(:defmacro, call, expr, __CALLER__)
end
We can try out defmacro
defmodule DefMacroExample1 do
defmacro add_one(x) do
x + 1
end
end
For this example, it works…
require DefMacroExample1
DefMacroExample1.add_one(2)
But what if we pass an expression?
# doesn't work
require DefMacroExample1
DefMacroExample1.add_one(1 + 1)
Well, actually, macros are not really functions after all.
Macros receive their arguments as quoted expressions, and return “quoted” expressions.
So what is quoting
?
quote do
1 + 1
end
This is Elixir AST (Abstract Syntax Tree) representation for 1 + 1
It’s defined in Elixir data structures - homoiconicity
quote do
Enum.map(xs, fn x -> x * 2 end)
end
So we should use quote
in our defmacro
s
defmodule DefMacroExample2 do
defmacro add_one(x) do
quote do
x + 1
end
end
end
# doesn't work
require DefMacroExample2
DefMacroExample2.add_one(1 + 1)
quote do
x + 1
end
x
is not a quoted expression, it’s a variable name that quote
will faithfully use.
Enter unquote
unquote
accepts quoted expresions and expands them
quote do
unquote({:+, [context: Elixir, import: Kernel], [1, 2]})
end
We can see what code the macro would produce we can Macro.to_string
quote do
unquote({:+, [context: Elixir, import: Kernel], [1, 2]})
end
|> Macro.to_string()
And we can now grab the values out of the environment
x = 1
y = 2
quote do: unquote(x) + unquote(y)
x = 1
y = 2
quote do
unquote(x) + unquote(y)
end
|> Macro.to_string()
defmodule DefMacroExample3 do
defmacro add_one(x) do
quote do
unquote(x) + 1
end
end
end
require DefMacroExample3
DefMacroExample3.add_one(1 + 1)
Boilerplate busting - Using Macros to define functions
There is a special macro name, __using__
, that can be defined in one module and used in another.
def MyModule
use MyMacroModule
....
We can’t def
directly in defmacro
# doesn't work
defmodule UsingExample1 do
defmacro __using__() do
def add_one(x), do: x + 1
end
end
quote
to the rescue!
quote do
def add_one(x), do: x + 1
end
quote do
def add_one(x), do: x + 1
end
|> Macro.to_string()
Putting it together…
defmodule UsingExample2 do
defmacro __using__(_opts) do
quote do
def add_one(x), do: x + 1
end
end
end
defmodule UsingUsingExample2 do
use UsingExample2
end
UsingUsingExample2.add_one(5)
How can we use this?
(example from https://elixir-lang.org/getting-started/meta/macros.html)
defmodule UnlessFun do
def fun_unless(clause, do: expression) do
if(!clause, do: expression)
end
end
UnlessFun.fun_unless(true, do: IO.puts("this should never be printed"))
Explanation:
In Elixir function arguments are evaluated eagerly (i.e. before the function receiving the arguments is evaluated)
So, let’s try using a macro!
defmodule UnlessMacro1 do
defmacro macro_unless(clause, do: expression) do
if(!clause, do: expression)
end
end
require UnlessMacro1
UnlessMacro1.macro_unless(true == fal, do: IO.puts("this should never be printed"))
OK, this still doesn’t work, we are still evaluating the arguments before running the macro.
But then remember that we have quote
defmodule UnlessMacro2 do
defmacro macro_unless(clause, do: expression) do
quote do
if(!clause, do: expression)
end
end
end
Warnings! Let’s see if it works…
# doesn't work
require UnlessMacro2
UnlessMacro2.macro_unless(true, do: IO.puts("this should never be printed"))
Oh no, forgot the unquote
, so clause
and expression
are being considered literally as variable names for the if
call, rather than as the AST of clause
and expression
arguments to our defmacro
.
quote do
if(!clause, do: expression)
end
We can’t grab an outside variable with just quote
x = 2
Macro.to_string(quote do: 1 + x)
What we really want is to grab the AST values held in clause
and expression
…
Let’s see the difference unquote
makes when the variable is a quoted expression
x = quote do: IO.puts("Hello, world!")
quote do
x
end
x = quote do: IO.puts("Hello, world!")
quote do
unquote(x)
end
So let’s try unquote
with arguments that are full expressions!
defmodule UnlessMacro3 do
defmacro macro_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end
end
require UnlessMacro3
UnlessMacro3.macro_unless(true, do: IO.puts("this should never be printed"))
Happy days!
Explanation:
In Elixir:
- function arguments are evaluated eagerly (i.e. before the function receiving the arguments us evaluated)
- macro arguments are not evaluated, but received as quoted expressions
Busting boilerplate backfiring
defmodule MyBoilerplateMacro1 do
defmacro __using__(_) do
actions = [:action1, :action2]
Enum.each(actions, fn action ->
quote do
def do_action(action) do
IO.inspect(action)
end
end
end)
end
end
# def do_action(:action1), do: ...
# def do_action(:action2), do: ...
We’ve seen this before! Let’s use unquote
to fix it.
defmodule MyBoilerplateMacro2 do
defmacro __using__(_) do
actions = [:action1, :action2]
Enum.each(actions, fn action ->
quote do
def do_action(unquote(action)) do
IO.inspect(unquote(action))
end
end
end)
quote do: def(some_other_fun(x, y), do: x + y)
end
end
defmodule MyModule2 do
use MyBoilerplateMacro2
end
Weird, looks like no do_action
functions are exported, but some_other_fun
is
# doesn't work
MyModule2.do_action(:action1)
Yep, they don’t exist
MyModule2.__info__(:functions)
Then we remember that defmacro
needs to return quoted AST, and like functions, it will return the value last expression. Hence why some_other_fun
made the cut, but not do_action
.
We can try putting all the code in one quote.
# doesn't work
defmodule MyBoilerplateMacro3 do
defmacro __using__(_) do
actions = [:action1, :action2]
quote do
Enum.map(unquote(actions), fn action ->
def do_action(unquote(action)) do
IO.inspect(unquote(action))
end
end)
def some_other_fun(x, y), do: x + y
end
end
end
defmodule MyModule3 do
use MyBoilerplateMacro3
end
Maybe we don’t need the unquote
s inside the loop
defmodule MyBoilerplateMacro4 do
defmacro __using__(_) do
actions = [:action1, :action2]
quote do
Enum.map(unquote(actions), fn action ->
def do_action(action) do
IO.inspect(action)
end
end)
def some_other_fun(x, y), do: x + y
end
end
end
defmodule MyModule4 do
use MyBoilerplateMacro4
end
These warnings are saying that we got two do_action
functions with the same argument (literally action
).
Many other permutations could be tried, and this could be a big time sink!
After much hair-pulling…aha, a breakthrough! If quote
returns a value, can we assign it to a variable and stitch them together?
defmodule MyBoilerplateMacro5 do
defmacro __using__(_) do
actions = [:action1, :action2]
action_funs =
Enum.map(actions, fn action ->
quote do
def do_action(unquote(action)) do
IO.inspect(unquote(action))
end
end
end)
some_other =
quote do
def some_other_fun(x, y), do: x + y
end
[action_funs, some_other]
end
end
defmodule MyModule5 do
use MyBoilerplateMacro5
end
MyModule5.do_action(:action1)
MyModule5.do_action(:action2)
MyModule5.__info__(:functions)
defmodule ListFuns do
[
def foo() do
:foo
end,
def bar() do
:bar
end
]
def baz() do
:baz
end
end
ListFuns.__info__(:functions)
ListFuns.foo() |> IO.inspect(label: "foo")
ListFuns.bar() |> IO.inspect(label: "bar")
ListFuns.baz() |> IO.inspect(label: "baz")
:ok
Macro.to_string(quote do: (unquote_splicing([1, 2])))
defmodule MyBoilerplateMacro6 do
defmacro __using__(_) do
actions = [:action1, :action2]
action_funs =
Enum.map(actions, fn action ->
quote do
def do_action(unquote(action)) do
IO.puts(unquote(action))
end
end
end)
some_other =
quote do
def some_other_fun(x, y), do: x + y
end
quote do
[unquote_splicing(action_funs), unquote(some_other)]
end
end
end
defmodule MyModule6 do
use MyBoilerplateMacro6
end
MyModule6.do_action(:action1)
MyModule6.do_action(:action2)
:ok
MyModule6.__info__(:functions)
Nesting use
defmodule MyBoilerplateMacro7 do
defmacro __using__(_) do
prelude =
quote do
use MyBoilerplateMacro6
end
actions = [:action1, :action2]
action_funs =
Enum.map(actions, fn action ->
quote do
def do_action_nested(unquote(action)) do
IO.puts("nesting action")
do_action(unquote(action))
end
end
end)
yet_another =
quote do
def yet_another_fun(x, y), do: x + y
end
[prelude, action_funs, yet_another]
end
end
defmodule MyModule7 do
use MyBoilerplateMacro7
end
MyModule7.do_action_nested(:action1)
MyModule7.do_action_nested(:action2)
:ok
defmodule MyBoilerplateMacro8 do
defmacro __using__(_) do
actions = [:action1, :action2]
action_funs =
Enum.map(actions, fn action ->
quote do
def do_action(unquote(action)) do
IO.puts(unquote(action))
end
end
end)
some_other =
quote do
def some_other_fun(x, y), do: x + y
end
defaults =
quote do
def some_other_fun(x) do
IO.puts("Catch-all for some other fun")
some_other_fun(x, 1)
end
def do_action(action) do
IO.puts("Catch-all for #{inspect(action)}")
end
end
[action_funs, some_other, defaults]
end
end
defmodule MyModule8 do
use MyBoilerplateMacro8
end
MyModule8.do_action(:action3)
MyModule8.some_other_fun(5)
Beware
Macros are great! But…
flowchart TD
A[Start] --> B{Should you use macros in your code?};
B --> C[No];
https://github.com/rellen/talks/blob/main/2022-02-16-macro-madness/macro_madness.livemd
defmodule NakedUnquote do
xs = [1, 2, 3, 4]
Enum.each(xs, fn x ->
IO.inspect(x)
def foo(x, y), do: y * x
end)
end