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