Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Macro Madness: when busting boilerplate backfires

macro_madness.livemd

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 defmacros

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 unquotes 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