Macrofunology 2: wrapping bodies, dissecting arglists
Any def
contains a head (the function's name and argument list) and a body (what comes after the do
). This post shows how to build a macro that takes the same structure but uses a part of the argument list to wrap code around the body. That is, the macro defchain
will turn this:
defchain assert_fields(map, pairs) do <body> end
... into this:
def assert_fields(map, pairs) do code_around(<body>) end
Note: handling function guards (when is_list(arg)
and the like) is covered in the next post.
The series: module structure, this post, guard expressions, atoms and function names
I'm fond of test assertions that return their first argument because they can be used in pipelines:
VM.ServiceGap.accept_form(params, @institution)
|> ok_content
|> assert_valid
|> assert_changes(id: 1,
in_service_datestring: @iso_date_1,
out_of_service_datestring: @iso_date_2,
reason: "reason")
Fond enough, in fact, that I've created two Hex packages with a selection of them (general-purpose and Ecto-specific).
For my own convenience, I wrote a macro, defchain
, to use when creating such assertions. It does the work of remembering to return the first argument. Consider the following definition of an assertion that checks whether a map contains certain expected values for a set of keys:
@doc ~S"""
iex> map = %{a: 3, b: 4, c: 1}
iex> map |> assert_fields(a: 3, b: 4) # |> assert...
%{a: 3, b: 4, c: 1}
"""
defchain assert_fields(map, pairs) do
for {key, expected} <- pairs do
assert Map.get(map, key) == expected
end
end
The expansion of that defchain
expression will be this:
def(assert_fields(map, pairs)) do
_called_for_side_effect = <<<<<<
for({key, expected} <- pairs) do
assert(Map.get(map, key) == expected)
end
map <<<<<<
end
All defchain
does is wrap a little code around the original body. (The only change that really matters is the insertion of a new return value on the next-to-last line. The binding of _called_for_side_effect
isn't required; it's just documentation.)
Let's now look at how defchain
is defined. Here is its argument list, which mimics the syntax of a def
:
defmacro defchain(head, do: body) do
...
end
Digression alert!
Note that do
is a keyword argument, just like any other keyword argument to any function. The previous code could also be written like this:
defmacro defchain(head, opts) do
body = Keyword.fetch!(opts, :do)
...
One thing that's special about do
is that the Elixir parser understands how to turn a do ... end
block into a key/value pair. If I wanted to use wrapping
instead of do
, I could. But the parser wouldn't understand wrapping ... end
, so I'd have to use an explicit keyword notation in the definition of assert_fields
:
defchainX assert_fields(map, pairs), wrapping:
(for {key, expected} <- pairs do
assert Map.get(map, key) == expected
end)
Ick. I have at times found such a thing useful, though.
We won't need to look inside assert_fields
' body, so its structure is uninteresting, but we do have to find the name of the first argument (map
) in order to have the generated function use it for a return value. That means looking at the structure of defchain
's head
argument:
defmacro defchain(head, do: body) do
IO.inspect head
end
Here's the result for assert_fields
:
{:assert_fields, [line: 45],
[{:map, [line: 45], nil}, {:pairs, [line: 45], nil}]}
That's the standard representation for Elixir parsed text (also called "quoted text", "abstract syntax trees", or "ASTs"). Except for some exceptions, it's a three-tuple with these parts:
- An atom that represents a string of non-whitespace characters in the source text. For example, what I typed as
assert_fields
(with no leading colon) was represented by:assert_fields
. If I'd typed code likea < b
, that would be parsed into a tuple whose first element would be the atom:<
. - Miscellaneous data about the source text ("metadata") such as the line number.
- A description – typically a list of nested three-tuples – of subordinate parsed text. For example, in our
assert_fields
case, the tuple for the function name contains the tuples for the argumentsmap
andpairs
. The:<
tuple fora < b
would contain three-element tuples fora
andb
.
A non-list third element, likenil
, means there was no subordinate text.
Later in this series, we'll see some gotchas involving the exceptions to this representation. In this post, we don't need to care.
Given the above representation of assert_fields(map, pairs)
, our macro code can find the name of the first argument like this:
defmacro defchain(head, do: body) do
{_name, _metadata, [return_this | _args]} = head
... and here's how we use that information to add it into the expansion of defchain assert_fields...
:
quote do
def unquote(head) do
_called_for_side_effect = unquote(body)
unquote(return_this) <<<<<<<<<<<<<<<
end
end
Easy-peasy, right? But there's a complication, described in the next post.
Next: guard expressions