Macros
If macros were strings…
# NOT REAL ELIXIR CODE
defmacro unless(condition, body) do
"if not #{condition} do #{body} end"
end
unless result == :ok do
raise error
end
It is possible to implement a string based macro system. For example the C preprocessor works a bit like this.
However, string-based macros suck:
- Code has structure; strings do not. Manipulating code strings is clumsy and error prone.
- There is no way to make string-based macros hygenic.
Influences
José was smoking a combination of the following drugs when he created the Elixir macro system:
- Common Lisp (procedural macros that manipulate ASTs, quasiquoting)
- Scheme (hygiene)
- Various obscure languages that have layered a more friendly syntax on top of Lisp-style macros.
In Lisp, all code is represented using lists. For example, here is the syntax for the list of numbers from 1 to 3, and the syntax for a function definition:
(1 2 3) ; Equivalent to [1, 2, 3] in Elixir
;
; a function that adds one to its argument
;
(defun increment (x)
(+ 1 x))
If we wrote the function definition in Elixir syntax, it would look like this:
[:defun, :increment, [:x], [:+, 1, :x]]
This is actually very similar to the internal representation of your code that the compiler is using.
This representation is the Abstract Syntax Tree (AST).
Macros in Elixir return an AST (and can take ASTs as arguments).
Quasiquoting
This is an idea that Elixir takes from Lisp. The quote do ... end
form allows you to obtain an AST from normally-written Elixir code:
quote do
my_variable = "foo" <> "bar"
my_function(my_variable, 18)
end
The Elixir AST isn’t too scary. Each node is either a constant value (e.g. a string like "foo"
) or a 3-tuple:
{node_kind, metadata, [first_arg|other_args]}
You can typically ignore the metadata. Each element of [first_arg|other_args]
is itself an AST node.
Quoting becomes more useful in combination with the unquote
form. This allows you to splice in an expression that evaluates to an AST node:
quote do
1 + unquote(:math.cos(0.5))
end
There is also unquote_splicing
, which removes a level of nesting:
quote do
[1, 2, 3, unquote_splicing([4, 5]), 6, 7]
end
A simple example - a logging macro
defmodule MyLogger do
defmacro log(msg) do
if true or Application.get_env(:logger, :enabled) do
quote do
IO.puts("Logged message: #{unquote(msg)}")
end
end
end
end
defmodule Example do
require MyLogger
def test do
# Using Macro.expand to debug the macro
IO.inspect(
Macro.expand_once(
quote do
MyLogger.log("This is a log message")
end,
__ENV__
)
)
# Using Macro.expand(...) |> Macro.to_string to debug the macro
IO.inspect(
Macro.expand_once(
quote do
MyLogger.log("This is a log message")
end,
__ENV__
)
|> Macro.to_string()
)
MyLogger.log("This is a log message")
end
end
Example.test()
A more complex example macro - deep matching on a map
defmodule Mod do
# Not a proper implementation of deep matching because it doesn't
# handle overlapping keys.
defmacro deep_match({:=, _, [{:%{}, metadata, entries}, expr]}) do
quote do
unquote({:%{}, metadata, Enum.map(entries, &entry/1)}) =
unquote(expr)
end
end
defp flatten("" <> str) do
[str]
end
defp flatten({:>, _, ["" <> k1, "" <> k2]}) do
[k1, k2]
end
defp flatten({:>, _, [expr, k]}) do
flatten(expr) ++ [k]
end
defp entry({keys, val}) do
keys
|> flatten
|> Enum.reverse()
|> Enum.reduce(val, fn k, expr ->
quote do
%{unquote(k) => unquote(expr)}
end
end)
|> elem(2)
|> List.first()
end
def run() do
deep_match(%{("foo" > "bar" > "amp") => amp} = %{"foo" => %{"bar" => %{"amp" => "goo"}}})
IO.inspect(amp, label: "AMP")
quote do
deep_match(%{("foo" > "bar" > "amp") => amp} = %{"foo" => %{"bar" => %{"amp" => "goo"}}})
end
|> Macro.expand(__ENV__)
|> Macro.to_string()
|> IO.puts()
end
end
Mod.run()
A more complex macro - Tim’s example
defmodule Mod do
defmacro get_expected_status_fields_for(status, map_to_merge \\ {:%{}, [], []}) do
status_map = %{
simplified_status_text: Status.human_status(status),
simplified_status_atom: status
}
{map_to_merge_raw, _} = Code.eval_quoted(map_to_merge)
merged_map = Map.merge(status_map, map_to_merge_raw)
Macro.escape(merged_map)
end
def run() do
quote do
get_expected_status_fields_for(:hired, %{foo: 1, bar: 2})
end
|> Macro.expand(__ENV__)
|> Macro.to_string()
|> IO.puts()
case %{} do
get_expected_status_fields_for(:role_closed, %{foo: 1, bar: 2}) ->
IO.puts("OPT 1")
_ ->
IO.puts("OPT 2")
end
end
end
defmodule Status do
def human_status(status) do
case status do
:hired -> "Hired"
:role_closed -> "Role closed"
:not_moving_forward -> "Not moving forward"
:withdrawn -> "Application withdrawn"
:sent -> "Application sent"
:progressing -> "Progressing"
:in_review -> "Application in review"
end
end
end
Mod.run()
Macro hygeine
What happens here?
defmodule Mod do
defmacro bork_x() do
quote do
x = 77
end
end
def run() do
x = 19
bork_x()
IO.inspect(x, label: "X")
end
end
Mod.run()
Elixir keeps track of the syntactic location of a variable definition/assignment, so it doesn’t confuse the x
introduced inside the macro with the x
in the run
function.
Similarly, a macro can’t just assume that some variable is defined in the syntactic context where it’s called. The following code won’t compile:
defmodule Mod do
defmacro print_x() do
quote do
IO.inspect(x, label: "X")
end
end
def run() do
x = 19
print_x()
end
end
Mod.run()
???
Elixir’s macro hygiene appears not to be perfect when it comes to function calls. For example, the following doesn’t do what you’d hope it would!
defmodule MyMacro do
def fst({a, _}), do: a
defmacro get_first(tup) do
quote do
fst(unquote(tup))
end
end
end
defmodule MyMod do
require MyMacro
def fst({_, b}) do
b
end
def run() do
MyMacro.get_first({"first element", "second element"}) |> IO.inspect()
fst({"first element", "second element"}) |> IO.inspect()
end
end
MyMod.run()
Deliberately breaking hygiene
Elixir makes it easy to break hygiene if you really want to. (Hint: you don’t).
defmodule Mod do
defmacro print_x() do
quote do
IO.inspect(var!(x), label: "X")
end
end
def run() do
x = 19
print_x()
end
end
Mod.run()
An anaphoric if macro
defmodule Mod do
require Logger
defmacrop aif(condition, body) do
{bind, test} = mung(condition)
quote do
var!(it) = unquote(bind)
if unquote(test) do
unquote(body)
end
end
end
defp mung(node = {:!=, _, _}), do: mung_op(node)
defp mung(node = {:==, _, _}), do: mung_op(node)
defp mung(node),
do:
{node,
quote do
var!(it)
end}
defp mung_op({op, metadata, [lhs, rhs]}) do
{lhs,
{op, metadata,
[
quote do
var!(it)
end,
rhs
]}}
end
def run do
aif mock_api_call_result() != "Good" do
IO.puts(it)
end
end
defp mock_api_call_result() do
"Some error message"
end
end
Mod.run()
Debugging macros
The key functions are Macro.expand
, Macro.expand_once
and Macro.to_string
.
https://elixirschool.com/en/lessons/advanced/metaprogramming/#debugging-3
Limitations of Elixir macros
Elixir macros have to receive syntactically valid Elixir expressions as their arguments. This limits the extent to which macros can be used to add truly new syntax.
(This limitation is probably a good thing, on balance.)
Scratch
quote do
"foo" > "bar" > "amp" > "goo"
end