Accessing behaviour information of a module at compile time

Hello, I work with a team of Elixir devs and some time ago we came across a code pattern within the Hex.pm website source that has an elegant solution for configuring which implementation is used within the behaviour: https://github.com/hexpm/hexpm/blob/master/lib/hexpm/billing/billing.ex
This solution fits the “explicit contracts” philosophy and works great, since you can for instance simply call Hexpm.Billing.get(organization) in a controller and not have to know about using Application.get_env(:hexpm, :billing_impl) everywhere.

As you can see from the code though, you need to write a definition for each callback function, that will simply delegate to impl().

I tried to automate some of that work using metaprogramming, but I hit a roadblock: I have no way of accessing the callback attributes during compilation!

Here is my sample module:

defmodule Foobar do
  @moduledoc """
  Behaviour for Foobar.
  """

  @doc """
  Reticulates the spline.
  """
  @callback reticulate() :: :ok | {:error, any()}

  defp impl(),
    do: Application.get_env(:myapp, :foobar_impl, Foobar.InMemory)

  # I want this to be autogenerated:
  # def reticulate(), do: impl().reticulate()
end

defmodule Foobar.InMemory do
  @behaviour Foobar

  @impl true
  def reticulate do
    :ok
  end
end

I am able to use the behaviour_info function once the module is compiled:

Foobar.behaviour_info(:callbacks) |> IO.inspect(label: "callbacks list after compilation")
# => callbacks list after compilation: [reticulate: 0]

But this function is not yet accessible from the module while I’m trying to write a macro.

My other solution would have been to access the @callback attributes directly, but it seems that typespecs attributes have a special meaning to the compiler, and I cannot access them:

defmodule Implementor do
  defmacro list_callbacks() do
    quote do
      Module.get_attribute(__MODULE__, :callback)
      |> IO.inspect(label: "callback attribute")

      Module.get_attribute(__MODULE__, :notcallback)
      |> IO.inspect(label: "notcallback attribute")
    end
  end
end

defmodule Foobar do
   require Implementor

  @callback reticulate() :: :ok | {:error, any()}
  @notcallback "reticulate"

  Implementor.list_callbacks()
end

# => callback attribute: nil
# => notcallback attribute: "reticulate"

Is there any solution I may have missed?
As a workaround, I thought about generating a second module (Foobar.Impl) using an after_compile callback, but I found the solution of putting behaviour and implementation in the same module a bit more elegant.

It’s because you put the two modules in the same file. I split it into two and

➜  app mix compile --force
Compiling 3 files (.ex)
callback attribute: [
  {:callback,
   {:::, [line: 4],
    [
      {:reticulate, [line: 4], []},
      {:|, [line: 4], [:ok, {:error, {:any, [line: 4], []}}]}
    ]}, {Foobar, {4, 1}}}
]
notcallback attribute: "reticulate"
1 Like

Thanks, that did the trick!

Macros are still mostly magic for me but I managed to write a usable module. Here is the end result:

defmodule ImplGenerator do
  defmacro __using__(_) do
    quote location: :keep do
      @before_compile {ImplGenerator, :__generate_impl__}
    end
  end

  defmacro __generate_impl__(_env) do
    quote do
      callbacks = Module.get_attribute(__MODULE__, :callback)

      Enum.each(callbacks, fn {:callback, {:::, _, [{fname, _, args_cb_ast}, _]}, _} ->
        arity = length(args_cb_ast)
        args = Macro.generate_arguments(arity, __MODULE__)

        ast =
          quote do
            def unquote(fname)(unquote_splicing(args)) do
              impl().unquote(fname)(unquote_splicing(args))
            end
          end

        Code.eval_quoted(ast, [], __ENV__)
      end)
    end
  end
end
2 Likes