Extensibility in Elixir Using Behaviours

by Matt Furness, Senior Software Engineer

The last post dug into protocols and how they can be used to define implementations for new or different data types. This post will cover another option Elixir provides for extensibility, behaviours.

When to use Behaviours

You might look to use Behaviours when there can be different implementations to achieve the same overall goal using the same function inputs and outputs that can be checked at compile time. Another helpful way to think Behaviours might be pluggable implementations that use the same type of data to fulfil a contract. Behaviours are handy both for library authors so that consumers can provide custom implementations for contracts defined in those libraries, but also within an app that may choose or configure different behaviours depending on various conditions. Often times you can get quite a lot of payoff by defining some key contracts / callbacks that lots of functions can utilize to achieve an overall goal in an extensible way.

Some Behaviours that you may come across, and may inspire you, include:

Behaviours are contracts

To quote the guide:

Behaviours provide a way to define a set of functions that have to be implemented by a module, and ensure that a module implements all the functions in that set.

Typespecs reference, https://hexdocs.pm/elixir/1.16/typespecs.html

At their core behaviours are a specification or contract. Any Module can define a contract that can be adopted as a @behaviour by specifying one or more @callback or @macrocallback Module attributes. These callbacks can then be adopted as a behaviour by any other Module. It is important to note that you don't mark a Module as a behaviour, merely defining one or more callback attributes is enough. Callbacks are defined by passing a specification to the callback Module attribute. These are identical to how you would define a specification using the @spec Module attribute. As explained in the docs a callback specification is made up of:

  • the callback name
  • the arguments that the callback must accept
  • the expected type of the callback return value

Lets take a look at a contrived example of a simple contract or callback that can print Strings. How and where the Strings are printed depends on the adopter of the Behaviour:

defmodule Printer do
  @moduledoc """
  Defines the contract to print to output "devices"
  """

  @doc """
  Prints the given string to an output device.

  Returns `:ok` if successful or `{:error, msg}` if it fails
  """
  @callback print(text :: String.t()) :: :ok | {:error, String.t()}
end

The example above highlights that you can document the callback the same as would for any named function or macro. I personally think it is worth providing docs that explain the callback, whether you are authoring a library that expects consumers to adopt the contract, or defining and using the contracts in a single app.

Note: The Printer Module above will have no functions defined on it. If you try to invoke print/1 on Printer you will get an error:

iex> Printer.print("hello")
** (UndefinedFunctionError) function Printer.print/1 is undefined or private

You can get all of the callbacks defined on a Module with the behaviour_info/1 function:

iex> Printer.behaviour_info(:callbacks)
[print: 1]

The result of behaviour_info is a keyword list of all callbacks and their arity specified on the Module. If a callback is a macro callback the key will be prefixed with MACRO-. The function behaviour_info will only be exported (defined) for a Module that defines callbacks.

Adopting behaviour

The first step when adopting a behaviour is to specify the Module name that defines the callback(s) as the value of a @behaviour Module attribute.

defmodule StdOutPrinter do
  @behaviour Printer
end

Compiling the above gives us a warning:

warning: function print/1 required by behaviour Printer is not implemented (in Module StdOutPrinter)

To get rid of the warning we have to implement the print callback defined in the Printer Module.

defmodule StdOutPrinter do
  @behaviour Printer

  @impl Printer
  def print(text) when is_binary(text) do
    IO.puts(text)
  end
end

The @impl attribute above that was introduced in Elixir 1.5. It is optional, but again I think it is worthwhile to always specify when define functions or macros that are implementing a callback. It both gives the complier a hint about the intent of the definition, and improves the readability of the Module. From the docs:

You may pass either false, true, or a specific behaviour to @impl.

I tend to always pass the behaviour Module name, but this is just personal preference. If a Module implements callbacks from more than one behaviour I would argue you should definitely specify the Module name. Another interesting side-effect of providing an explicit @impl is that @doc will be (implicitly) given a value of false. This makes sense, because it should really be Module that defines that callback that documents it. The docs define the @docs Module attribute as:

Accepts a string (often a heredoc) or false where @doc false will make the function/macro invisible to documentation extraction tools like ExDoc

If for some reason it makes sense to document the adopter of the callback simply specify a value for @doc on the implemented callback.

Note: If an attempt is made to adopt two behaviours in the same Module that specify a callback with the same name and arity you will get a warning warning: conflicting behaviours found. This should be avoided because it may not be possible to satisfactorily implement both of the conflicting behaviours being adopted.

Type checking behaviours

If we were to change the above implementation to erroneously check for is_map in the guard:

defmodule StdOutPrinter do
  @behaviour Printer

  @impl Printer
  def print(text) when is_map(text) do
    IO.puts(text)
  end
end

It would still compile with no errors or warnings, because the Elixir compiler will only look at callback name and arity. It would be great to be notified when an implementation of a callback was incorrectly typed in an obvious way. Dialyxir does a great job of this, in fact if you add any typescpecs to your project dialyxir is an invaluable tool for picking up type errors. Running mix dialyzer on the above code will produce a warning

The inferred type for the 1st argument of print/1 (map()) is not a supertype of binary(), which is expected type for this argument in the callback of the 'Elixir.Printer' behaviour.

That is much more helpful for catching erroneous implementations.

Note: I have also heard good things about Dialyzex but I haven't tried it myself yet.

Optional callbacks

Modules support an @optional_callbacks Module attribute. It takes the name and arity of a @callback or a @macrocallback attribute. Let's update our contrived behaviour to have an optional callback:

defmodule Printer do
  @moduledoc """
  Defines the contract to print to output "devices"
  """

  @doc """
  Prints the given string to an output device.

  Returns `:ok` if successful or `{:error, msg}` if it fails
  """
  @callback print(text :: String.t()) :: :ok | {:error, String.t()}

  @doc """
  Optionally restrict the maximum length of the string that can be printed

  Returns the maximum length of the string supported by the `Printer`
  """
  @callback max_length() :: pos_integer()

  @optional_callbacks max_length: 0
end

behaviour_info can also report the optional callbacks in a Module.

iex> Printer.behaviour_info(:callbacks)
[print: 1, max_length: 0]
iex> Printer.behaviour_info(:optional_callbacks)
[max_length: 0]

StdOutPrinter would still compile without the warning of a missing callback because we have marked the max_length callback as optional. We could similarly update StdOutPrinter to implement the max_length callback:

defmodule StdOutPrinter do
  @behaviour Printer

  @impl Printer
  def print(text) when is_binary(text) do
    IO.puts(text)
  end

  @impl Printer
  def max_length() do
    1024
  end
end

To determine if an adopting Module defines an optional callback one simple option is to use the Kernel.function_exported?/3 or Kernel.macro_exported?/3 functions. If we were to use function_exported? on the last example of StdOutPrinter above it would be true.

iex> function_exported?(StdOutPrinter, :max_length, 0)
true

Alternatively identifying whether optional callbacks can be part of config

Another neat example of the advantages of using Dialyzer is that it will pickup if we return an integer that is not positive because of the return type of our specification being pos_integer() even if we use a Module attribute. So if we changed the above to:

defmodule StdOutPrinter do
  @behaviour Printer
  @max_length -1

  @impl Printer
  def print(text) when is_binary(text) do
    IO.puts(text)
  end

  @impl Printer
  def max_length() do
    @max_length
  end
end

Running mix dialyzer would generate a warning The inferred return type of max_length/0 (-1) has nothing in common with pos_integer(), which is the expected return type for the callback of the 'Elixir.Printer' behaviour

Choosing an adopter

There is no simple way to discover all the adopters of a given behaviour. Instead the app or library needs to be told in some way which adopter to use, and there are a few common ways of doing this.

Calling the function directly

If you know the Module in advance you can always just call it directly:

StdOutPrinter.print("some text")

Although the above print/1 is implementing a callback there is nothing special about it at runtime, it is just a function on the StdOutPrinter Module.

Note: If you are interested in registering adopting modules via a macro PlugBuilder is an example of this approach.

Passing the Module

Elixir supports dynamic dispatch so it is relatively common to find functions that take the adopter of a behaviour as an argument. We can make a print_up_to_max/2 function that is suitable for any Module adopting the Printer behaviour.

  @doc """
  Print as much of the text as possible given the supplied Printer
  """
  @spec print_up_to_max(module(), String.t()) :: :ok | {:error, String.t()}
  def print_up_to_max(printer, text)
      when is_atom(printer) and is_binary(text) do
    max_length =
      if function_exported?(printer, :max_length, 0),
        do: printer.max_length(),
        else: :infinite

    print_up_to_max(printer, text, max_length)
  end

  defp print_up_to_max(printer, text, :infinite), do: printer.print(text)

  defp print_up_to_max(printer, text, max_length) do
    text
    |> String.split_at(max_length)
    |> elem(0)
    |> printer.print()
  end

Specifying the Adopting Module in Configuration

Another common approach is to specify the adopting Module's name in an Application's config.exs. This approach is often used when you can provide a Behaviour to a dependency that defines the callbacks. Imagine there is a HTTP library that defined callbacks for serializing/deserialzing JSON. It could support configuration to specify an adopting Module that did the serialization work with a JSON serializer of choice. Let's again imagine that there is a library that wanted to support printing text and defined the Printer callbacks above. There could be configuration that supported us specifying StdOutPrinter as the adopting Module.

# In config.exs
config :dummy_text_printer, printer: StdOutPrinter

This configuration could even support specifying whether there is a max_length/0 function defined:

# In config.exs
config :dummy_text_printer, printer: StdOutPrinter, max_length: true

Then the dummy_text_printer dependency could look up the specified Printer where needed:

printer = Application.get_env(:dummy_text_printer, :printer)
printer.print("Some text to print")

This approach is particularly useful when the implementation changes when testing. José's blog post on mocks and explicit contracts provides some guidance regarding this approach.

Choosing an implementation based on a predicate

Predicates can be built in to the specifications so that the implementation may be chosen at runtime. A contract that centers around deserialization can provide a function to determine whether an implementation is applicable based on a file's extension extension_supported?/1. The app could cycle through all the known implementations and use the first that it finds that supports an extension. The docs have an example that eludes to this approach.

Mixing in functions

A Module that defines callbacks can also define other functions as we have seen above. This can be a good place to put functions that build upon the implemented callbacks, as we saw with the print_up_to_max/2 function. It is possible to take this further and support being able to define those functions on the adopting Module with the __using__/1 macro

defmodule Printer
  defmacro __using__(_) do
    quote do
      @behaviour Printer

      @spec print_up_to_max(String.t()) :: :ok | {:error, String.t()}
      def print_up_to_max(text) when is_binary(text) do
        Printer.print_up_to_max(__MODULE__, text)
      end

      defoverridable print_up_to_max: 1
    end
  end

  # The rest of the Module
end

StdOutModule could then adopt the behaviour by "using" the Printer Module:

defmodule StdOutPrinter do
  use Printer

  @impl Printer
  def print(text) when is_binary(text) do
    IO.puts(text)
  end
end

The use Printer expression will call the __using__/1 macro in the Printer Module. The macro defines the print_up_to_max/1 function that calls the Printer.print_up_to_max/2 function with the StdOutPrinter module. The print_up_to_max/1 function can now be called on the StdOutPrinter Module directly.

Printer.printer_up_to_max("Some text to print")

It is even possible to mark the "mixed in" function as overridable using defoverridable. In the above example print_up_to_max/1 is marked as overridable using the expression defoverridable print_up_to_max: 1. This allows any Module that adopts the Printer Module via the __using__/1 macro to define it's own implementation of print_up_to_max/1 if it needs to do something specific.

Wrapping up

Behaviours are a great way to get compile time checking for contracts that can differ in implementation. Not only that, they be a great source of documentation for both your future self, your team, or consumers of a library. Obviously most implementations don't need to be "pluggable" so you probably won't end up with a large number of callbacks. Identifying those that do, however, can pay dividends for maintainability and extensibility. There is currently no way to discover adopters of a Behaviour so deciding on how an adopting Module is specified or determined at run-time is important.

More articles

How do AI Clinical Notes work? - A high level review for clinicians

There has been an explosion in the availability of AI tools that help automatically create clinical notes for doctors and other health practitioners. We give a high level overview of how many of these systems are built to help doctors and other clinicians understand what they are using.

Read more

Comparing and Sorting in Elm

In this short post we will take a peek at how values can be compared and sorted in Elm, with the goal of sorting a List of records using multiple criteria.

Read more

We’d love to accelerate your next project.

Our offices

  • Brisbane
    L2, 303 Coronation Drive
    4064, Brisbane, Australia