You already know that Elixir is the right choice for your digital product development, now it’s time to partner with a team that knows how to make Elixir work for your unique goals. Book a free consult today to learn more.
I don’t use macros often, other than for using
macros as a convenience for users when making libraries.
If you are new to writing libraries or are digging deeper into metaprogramming, let me explain one case
where @before_compile
can be unexpectadly useful, that I just discovered recently.
If you use a lot of metaprogramming in Elixir, you are probably already familiar with @before_compile
.
In some cases, it would be nice to be able to override a function and pattern-match on a special case, but let it fall back to the default for the rest of the cases. This is not something you would want to do in most circumstances and could actually be surprising to users. One case that may be a good candidate for this, is if you have a code path that the user has a very specific case they want to override, but you want the common case to always apply otherwise.
Now that we’ve prefaced with why we probably do not want to do this, here is how we can do it.
Assume we have this code:
defmodule Thing.Handler do
require Logger
@callback handle_thing(thing :: term()) :: term()
defmacro __using__(_opts) do
quote do
@behaviour Thing.Handler
@impl Thing.Handler
def handle_thing(thing) do
Thing.Handler.default_handle_thing(thing)
end
defoverridable handle_thing: 1
end
end
def default_handle_thing(thing) do
Logger.debug(inspect(thing))
end
end
Now anyone can use this behavior and the default implementation like so:
defmodule Implementation do
use Thing.Handler
end
Next, we want to override the function for our special case:
defmodule Implementation do
use Thing.Handler
@impl Thing.Handler
def handle_thing(1) do
# Handling a specific case ourselves.
end
end
We are now handling the specific case we care about. However, if it doesn’t match that
case, we will get a FunctionClauseError
for no matching function.
The typical way to deal with this, if you want to maintain the default implementation would be to
use super
.
defmodule Implementation do
use Thing.Handler
@impl Thing.Handler
def handle_thing(1) do
# Handling a specific case ourselves.
end
# Catch-all fallback to default implementation.
def handle_thing(thing), do: super(thing)
end
Finally, we will set this up so that if the user of the library forgets to handle the general
case to call super
, that it always falls back to the default implementation.
This is where @before_compile
comes in. It is described as
A hook that will be invoked before the module is compiled. This is often used to change how the current module is being compiled.
To me, that sounds like “blah blah… words,” so let’s see it in action. We’ll update the original
Thing.Handler
module and add @before_compile
, which looks like:
defmodule Thing.Handler do
require Logger
@callback handle_thing(thing :: term()) :: term()
defmacro __using__(_opts) do
quote do
@behaviour Thing.Handler
@impl Thing.Handler
def handle_thing(thing) do
Thing.Handler.default_handle_thing(thing)
end
@before_compile {Thing.Handler, :add_handle_thing_fallback}
defoverridable handle_thing: 1
end
end
defmacro add_handle_thing_fallback(_env) do
quote do
def handle_thing(thing) do
Thing.Handler.default_handle_thing(thing)
end
end
end
def default_handle_thing(thing) do
Logger.debug(inspect(thing))
end
end
Now we can override the function for specific cases and the rest will fall through
to the default_handle_thing/1
function!