This post is mostly a tutorial on Elixir macros, yet by the end we’ll have a
helper module that supports bound variables passed to defmodule
built.
The problem
Nested modules in Elixir
derive the “context” if one treats context as
fully name qualification. In all other aspects, these two codepieces
are equivalent:
defmodule A.B do
end
# versus
defmodule A do
defmodule B do
end
# the only difference is that here
# we can refer B module as B
end
Usually when one nests anything, they expect to derive more context. Like
module variables, or whatever. Even more, the context switch should probably
allow passing a binding through. At least, quote
macro does:
block = quote bind_quoted: [var: 42] do
var == 42
end
Code.eval_quoted block
#⇒ {true, [{\{:var, Elixir}, 42}]}
The above trick is specifically handy when writing macros:
defmodule A do
defmacro a(var) do
quote bind_quoted: [var: var] do
IO.puts var # use it as is, without `unquote`
end
end
end
require A
A.a(42)
#⇒ 42
OK, so we are to implement binding passing through for defmodule
. The
code that might use our implementation, would look like:
defmodule WithBinding do
# our module, that implements custom `defmodule` macro
require Atadura
Atadura.defmodule DynamicModule, status: :ok, message: "¡Yay!" do
def response, do: [status: status, message: message]
IO.inspect message, label: "Message (local)"
#⇒ Message (local): "¡Yay!"
IO.inspect @message, label: "Message (attribute)"
#⇒ Message (attribute): "¡Yay!"
IO.inspect ~b|status message|, label: "Message (sigil)"
#⇒ Message (sigil): [:ok, "¡Yay!"]
end
end
WithBinding.DynamicModule.response()
#⇒ [status: :ok, message: "¡Yay!"]
As one might see, both status
and message
variables were bound to
newly created module in three different ways:
- — as local functions;
- — as module variables;
- — as smart sigil.
Also the module was granted with bindings/0
function, returning the bindings
keyword list as is.
Under the hood, the nested module WithBinding.DynamicModule.Bindings
was also
created, having two interesting macros, bindings!/0
and attributes!/0
.
The former populates the binding as local variables, the latter declares
modules attributes (it is internally used to declare module attributes for
the binding in the example above.)
bindings!/0
require WithBinding.DynamicModule.Bindings
WithBinding.DynamicModule.Bindings.bindings!
#⇒ [:ok, "¡Yay!"]
status
#⇒ :ok
attributes!/0
defmodule A do
require WithBinding.DynamicModule.Bindings
WithBinding.DynamicModule.Bindings.attributes!
def status, do: @status
end
A.status
#⇒ :ok
Implementation
The implementation is simple, though it might look a bit cumbersome at the
first glance. Let’s start with Atadura.defmodule/{2,3}
.
If no bindings were given, Atadura.defmodule/2
would gracefully fallback
to Kernel.defmodule/2
, without any impact on the compiled code.
Here is the complete code of this module, including some comments:
# function declaration; needed to allow importing w/out
# conflicts with `Kernel.defmodule/2` and fallback to it
# when no parameters are given.
defmacro defmodule(name, bindings \\ [], do_block)
# fallback to `Kernel.defmodule/2`
defmacro defmodule(name, [], do: block) do
quote do: Kernel.defmodule(unquote(name), do: unquote(block))
end
# handler for non-bracketed call (not available on import)
defmacro defmodule(name, [], bindings_and_do) do
block = Keyword.get(bindings_and_do, :do, nil)
bindings = Keyword.delete(bindings_and_do, :do)
quote do: Atadura.defmodule(unquote(name), unquote(bindings), do: unquote(block))
end
# main handler
defmacro defmodule(name, bindings, do: block) do
# we are to build the module name in the first place
# it’s done before `quote do` because `defmodule` below
# does not accept `Module.concat(unquote(name), Bindings)`
binding_module = with {:__aliases__, line, names} <- name do
{:__aliases__, line, names ++ [:Bindings]}
end
quote do
# `Bindings` nested module, passes `bindings` to
# `Atadura.Binder`, that will declare all the
# stuff in `__using__` callback
defmodule unquote(binding_module) do
use Atadura.Binder, unquote(bindings)
end
# OK, that is the exact reason we were quoting
# and unquoting everything: call to `use binding_module`
# is required to update (patch, extend, younameit) the
# module that is being created
defmodule unquote(name) do
use unquote(binding_module)
unquote(block)
end
end
end
That’s almost it. The only thing remains is to implement __using__
there
in Atadura.Binder
. Since it’s a bit of hardcore, let’s continue with
usage advises on that module. The post’s tail will cover the tech details
of the implementation, I promise.
Tips and tricks
import Atadura, only: [:defmodule, 3]
The above allows to still use default Kernel.defmodule/2
unless
the keyword list is given as the second parameter:
import Atadura, only: [:defmodule, 3]
# Plain old good `Kernel.defmodule/2` without bindings
defmodule A1, do: def a, do: IO.puts "★★★"
# `Atadura.defmodule/3` with bindings
defmodule A2, [b: 42], do: def a, do: IO.puts "★★★"
Without explicit import
, Atadura.defmodule/{2,3}
would gracefully
fallback to Kernel.defmodule/2
if no bindings were given. Unfortunately,
there is no chance to distinguish between two following calls:
defmodule A1, do: def a, do: IO.puts "★⚐★"
defmodule A2, bound: 3.14, do: def a, do: IO.puts "★π★"
because both examples are defmodule/2
clauses, and we would be conflicting
with always imported Kernel
. So, when import Atadura
is used, the
brackets around the second argument in call to defmodule
are mandatory.
Atadura.Binder.__using__/1
Dear José, thanks for still bearing with me, in case you wonder how ugly I had this written, here are the implementation details.
defmodule Atadura.Binder do
@moduledoc ~S"""
Since we want to hide the implementation details inside
`Bindings` nested module, we’ll do nested `__using__`.
See `Atadura.defmodule/3` for details.
"""
defmacro __using__(bindings) do
[
quote do
# bindings require two unquotes, on each subsequent quote
bindings = unquote(bindings)
# this will be used in nested `Bindings` module, that will
# be in turn `use`d by `Atadura.defmodule/3`
defmacro __using__(_opts \\ []) do
module = __MODULE__ # to get it properly inside quote
quote do
require unquote(module), as: Bindings
import unquote(module)
# Bindings.bindings! # declare local variables: not very handy
Bindings.attributes! # declare module attributes
# just an example of how module documentation
# might be constructed on the fly
Module.add_doc(__MODULE__, 0, :def, {:version, 0}, [],
~s"""
Returns a binding for this module, supplied when it was created.
This module #{__MODULE__} was created with the following binding:
#{inspect Bindings.bindings}
Enjoy!
""")
# the plain function, that returns all the bindings
def bindings, do: Bindings.bindings
end
end
# back to `Bindings` module level, here it’s macro called 3 LOCs above
defmacro bindings, do: unquote(bindings)
# local variables producer, might be called from everywhere
# to populate the current context with binding
defmacro bindings! do
Enum.map(unquote(bindings), fn {attr, val} ->
{:=, [], [{attr, [], nil}, val]}
end)
end
# module attribute producer, might be called from inside
# of any module to populate the current context with binding
defmacro attributes!() do
Enum.map(unquote(bindings), fn {attr, val} ->
quote do
Module.register_attribute(__MODULE__, unquote(attr), accumulate: false)
Module.put_attribute(__MODULE__, unquote(attr), unquote(val))
end
end)
end
# sigils producer; when called with a single attribute name,
# e.g. `~b|status|`, returns it’s value as is.
# when called with many, returns a list of values.
defmacro sigil_b(keys, _modifiers) do
bindings = unquote(bindings)
{:<<>>, _, [key]} = keys
case String.split(key, ~r/\s+/) do
[key] when is_binary(key) -> # single value requested
quote do: unquote(bindings)[String.to_atom(unquote(key))]
keys when is_list(keys) -> # multi values
Enum.map(keys, fn key ->
with bindings <- unquote(bindings),
do: quote do: unquote(bindings)[String.to_atom(unquote(key))]
end)
end
end
end |
# functions returning bindings by name, appended to the AST.
Enum.map(bindings, fn {attr, val} ->
quote do
def unquote(attr)(), do: unquote(val)
end
end)
]
end
end
Yes, that is it. There is no more code in the package, besides the above, but for those picky persons, we have atadura @ github, also the package is available @ hex.pm.