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

Macros

metaprogramming.livemd

Macros

Mix.install([
  {:kino, github: "livebook-dev/kino", override: true},
  {:kino_lab, "~> 0.1.0-dev", github: "jonatanklosko/kino_lab"},
  {:vega_lite, "~> 0.1.4"},
  {:kino_vega_lite, "~> 0.1.1"},
  {:benchee, "~> 0.1"},
  {:ecto, "~> 3.7"},
  {:math, "~> 0.7.0"},
  {:faker, "~> 0.17.0"},
  {:httpoison, "~> 1.8"},
  {:poison, "~> 5.0"},
  {:utils, path: "#{__DIR__}/../utils"}
])

Navigation

Return Home Report An Issue

Metaprogramming

Metaprogramming is the process of writing code that generates code.

In Elixir, we use macros for code generation. Macros are a powerful tool for generating code. But, as always, with great power comes great responsibility.

> Even though Elixir attempts its best to provide a safe environment for macros, the major responsibility of writing clean code with macros falls on developers. Macros are harder to write than ordinary Elixir functions and it’s considered to be bad style to use them when they’re not necessary. So write macros responsibly. > > * elixir-lang.org

Quote

Under the hood, Elixir represents expressions as three-element tuples. We call this representation the AST (abstract syntax tree). Elixir lets us inspect the AST representation of expressions using the quote macro.

quote do
  2 + 2
end

The three-element tuple above is often called a quoted expression.

The first element in the tuple is the function name. The second element is a keyword list containing metadata, and the third element is a list of arguments.

{function, metadata, arguments}

So 2 + 2 as a quoted expression is

  • function: :+
  • metadata: [context: Elixir, import: Kernel]
  • arguments: [2, 2]

The function name is :+, which refers to the Kernel.+/2 function. + is simply a convenient syntax for calling this function.

Kernel.+(2, 2) == 2 + 2

The metadata includes information about the environment. By default, the :context is Elixir because we are in the top-level scope.

The context changes if we use quote in a module. Now the context will be the name of the module.

defmodule MyModule do
  def example do
    quote do
      2 + 2
    end
  end
end

MyModule.example()

We can also call quote on a single line.

quote do: 1 - 1

The AST represents primitive data types as themselves rather than three-element tuples.

quote do: 2

All other expressions will be three-element tuples—even non-primitive data types such as maps.

quote do: %{key: "value"}

Here’s an anonymous function as a quoted expression.

sum = fn int1, int2, int3 -> int1 + int2 + int3 end

quote do: sum(1, 2, 3)

Here’s the same anonymous function as a named function when broken down into a quoted expression.

defmodule Math do
  def sum(int1, int2, int3) do
    int1 + int2 + int3
  end
end

quote do: Math.sum(1, 2, 3)

Arguments in the three-element tuple can themselves be three-element tuples.

quote do: sum(1, 2, sum(1, 2, 3))

Your Turn

Use the quote macro to discover the AST representation of the following expression. You may also choose to experiment with quote with other Elixir expressions to see their quoted expression representation.

2 + 2 + 2

Unquote

unquote injects code into the quote macro.

We can use unquote to inject some computed value into a quote block.

For example, the following unquote(1 + 1) evaluates to 2 inside of the quote block.

quote do
  2 + unquote(1 + 1)
end

The above quote expression is equivalent to 2 + 2, because we injected the result of 1 + 1 into the quote expression using unquote.

quote do
  2 + 2
end

Variables outside the quote block will not be available within the quote block. So we can use unquote to inject their evaluated value.

my_variable = 5

quote do
  2 + unquote(my_variable)
end

This creates the same quoted expression as 2 + 5.

quote do
  2 + 5
end

defmacro

We can use macros to extend the Elixir language or create DSLs (Domain-specific Languages). For example, every time you use test and assert in ExUnit, you use ExUnit macros.

ExUnit.start(auto_run: false)

defmodule Test do
  use ExUnit.Case

  test "example" do
    assert 1 == 2
  end
end

ExUnit.run()

Notice above that ExUnit can determine the operator used in the assertion 1 == 2.

To demonstrate how ExUnit leverages the power of macros to provide better test feedback, we’re going to create our own assert macro.

The assert macro will accept a truthy expression and print a message with feedback.

assert 1 == 2

"""
Assertion with `==` failed.

left: 1
right: 2
"""

Notice that we cannot accomplish this with a function. Functions accept the evaluated result expressions as arguments. We lose the context about the operator and values.

assert = fn expression ->
  if expression do
    IO.puts("Success")
  else
    IO.puts("Failure")
  end
end

assert.(1 == 2)

We use defmacro to define a macro. We’ll create an assert macro in an Assertion module.

defmodule Assertion do
  defmacro assert do
  end
end

Arguments to the assert macro are the AST representation of the code. The AST representation of an expression will always be a three-element tuple. Specifically, in this case, the quoted expression for 1 == 2.

quote do
  2 == 2
end

We can pattern match on this expression in the macro definition.

defmodule Assertions do
  defmacro assert({operator, _meta, [left, right]}) do
    IO.inspect(operator, label: "operator")
    IO.inspect(left, label: "left")
    IO.inspect(right, label: "right")
  end
end

To use macros, we must require the module that defines them.

Notice the the operator is :==, the left value is 1, and the right value is 2. Unlike with a function, the macro retains context about the expression given to it.

require Assertions

Assertions.assert(2 == 2)

We can use the operator, left, and right variables to print the assertion message.

defmodule Assertions do
  defmacro assert({operator, _meta, [left, right]}) do
    IO.puts("""
    Assertion with #{operator} failed.
    left: #{left}
    right: #{right}
    """)
  end
end
require Assertions

Assertions.assert(1 == 2)

We want to verify if the left and right values are equal.

We can delegate to a separate function now that the assert macro separates the expression into its quoted expression.

This function will take the values from the quoted expression and print the assertion success or failure message.

defmodule Assertion.Test do
  def assert(:==, left, right) when left == right do
    IO.puts("Success!")
  end

  def assert(:==, left, right) do
    IO.puts("""
    Assertion with == failed.
    left: #{left}
    right: #{right}
    """)
  end
end

Assertion.Test.assert(:==, 1, 2)

We’ll use the Assertion.Test.assert function inside our macro. Remember, we need to use unquote to use bound variables inside the quote block. The same is true for parameters. So we need to use unquote to inject their evaluated value into the quote block.

defmodule Assertion do
  defmacro assert({operator, _meta, [left, right]}) do
    quote do
      Assertion.Test.assert(unquote(operator), unquote(left), unquote(right))
    end
  end
end

When we call the assert macro below it compiles into Assertion.Test.assert(:==, 1, 2).

require Assertion

Assertion.assert(1 == 2)

Alternatively, we can use bind_quoted to bind multiple values to the quoted expression without unquote.

defmodule Assertion do
  defmacro assert({operator, _meta, [left, right]}) do
    quote bind_quoted: [operator: operator, left: left, right: right] do
      Assertion.Test.assert(operator, left, right)
    end
  end
end

The macro continues to work as expected.

require Assertion
Assertion.assert(1 == 2)

Your Turn

Expand the Assertion.Test module to include the <, <=, >, >=, and === operators.

defmodule Assertion.Test do
  def assert(:==, left, right) when left == right do
    IO.puts("Success!")
  end

  def assert(:==, left, right) do
    IO.puts("""
    Assertion with == failed.
    left: #{left}
    right: #{right}
    """)
  end
end

defmodule Assertion do
  defmacro assert({operator, _meta, [left, right]}) do
    quote bind_quoted: [operator: operator, left: left, right: right] do
      Assertion.Test.assert(operator, left, right)
    end
  end
end

You should be able to use the Assertion.assert macro to display a failure message for the following assertions without causing a FunctionClauseError.

require Assertion

Assertion.assert(1 == 2)
Assertion.assert(1 == 2)
Assertion.assert(1 > 2)
Assertion.assert(1 >= 2)
Assertion.assert(2 < 1)
Assertion.assert(2 <= 1)
Assertion.assert(1 === 1.0)

use and __using__

While you may not write macros often, you are likely to use them daily. For example, we have already relied on macros with the use keyword.

When we use GenServer, a macro generates the necessary boilerplate code to make a GenServer.

defmodule Server do
  use GenServer

  def init(state) do
    {:ok, state}
  end
end

We can use the __info__/2 function on the Server module to gain insight into code generated under the hood. Here we see it defines several functions.

Server.__info__(:functions)

The use keyword provides a clean and controlled interface for working with macros. Under the hood, the use keyword calls a __using__ macro in the specified module.

defmodule Template do
  defmacro __using__(_opts) do
    quote do
      def template_function do
        "hello"
      end
    end
  end
end

Conceptually, it may help think of modules that define macros as templates or common patterns that we can reuse throughout a program. For example, GenServer is a common pattern we want to extend and reuse.

defmodule ExtendedTemplate do
  use Template

  def extended_function() do
    template_function() <> " world"
  end
end

Once we define our pattern, we can reuse it throughout our program and extend its functionality.

"hello" = ExtendedTemplate.template_function()
"hello world" = ExtendedTemplate.extended_function()

For a real-world example, it’s common to create custom ExUnit cases for common test scenarios. The example below is only a small example of what’s possible.

defmodule IOCase do
  # Use the module
  defmacro __using__(_opts) do
    quote do
      use ExUnit.Case, async: true
      import ExUnit.CaptureIO
    end
  end
end

ExUnit.start(auto_run: false)

defmodule Test do
  use IOCase

  test "capure io" do
    capture_io(fn -> IO.puts("hello") end) =~ "hello"
  end
end

ExUnit.run()

Your Turn

Create a Greetings module with a __using__ macro. define a hello/0 function inside of the __using__ macro. You may choose to experiment with defining other functions or module attributes.

def hello do
  "hello"
end

Create a Usage module that uses the use keyword to call the __using__ macro in the Greetings module.

defmodule Greetings do
end

defmodule Usage do
end

Call Usage.hello() to ensure your solution works correctly.

Usage.hello()

Commit Your Progress

Run the following in your command line from the project folder to track and save your progress in a Git commit.

$ git add .
$ git commit -m "finish metaprogramming section"