Behaviours and use
Behaviours in Elixir (and Erlang) are a way to separate and abstract the generic part of a component (which becomes the behaviour module) from the specific part (which becomes the callback module).
Let’s consider an example. Let’s assume we have products that have name and price, and we want to print them in different ways:
-
as a human-readable sentence (
The bike costs $10) -
as a CSV row (
bike,10)
A common way to do that would be using pattern matching in function heads:
defmodule MatchingPrinter do
def print(product, :pretty) do
"The #{product.name} costs $#{product.price}"
end
def print(product, :csv) do
"#{product.name},#{product.price}"
end
end
MatchingPrinter.print(%{name: "bike", price: 10}, :pretty)
This approach has a limitation though: whenever we want a new way of printing, we need to change the MatchingPrinter module. Oftentimes it’s perfectly fine and we don’t need anything more complex. However, in some cases, we need more flexibility. For example, if MatchingPrinter is not a part of our project, but belongs to some library, we can’t change its code at all. In that case, we can split the implementation into different modules:
defmodule PrettyPrinter do
def print(product) do
"The #{product.name} costs $#{product.price}"
end
end
defmodule CSVPrinter do
def print(product) do
"#{product.name},#{product.price}"
end
end
PrettyPrinter.print(%{name: "bike", price: 10})
and use them interchangably:
mode = :pretty
module =
case mode do
:pretty -> PrettyPrinter
:csv -> CSVPrinter
end
module.print(%{name: "bike", price: 10})
This is possible because these modules share the same API: the print function accepting product and returning text. In Elixir, we can formalize this API using a behaviour, and mark these modules as implementations of the behaviour.
A behaviour is a module that defines callbacks, and the implementations are modules with functions implementing those callbacks. In our case, there’s one callback: print/1.
defmodule PrinterBehaviour do
# The product type for convenience
@type product :: %{name: String.t(), price: number()}
# @callback has the same syntax as @spec - there's just no implementation
@callback print(product) :: String.t()
end
defmodule PrettyPrinterImpl do
# With @behaviour we mark that the module implements given behaviour
@behaviour PrinterBehaviour
# @impl true marks that a function is an implementation of a callback
# It helps distinguishing it from other functions that this module may have
#
# 💡 Try renaming the below function to `print2`. What happens?
@impl true
def print(product) do
"The #{product.name} costs $#{product.price}"
end
end
# 💡 Let's implement a CSV printer!
defmodule CSVPrinterImpl do
end
PrettyPrinterImpl.print(%{name: "bike", price: 10})
Apart from defining callbacks, behaviours often provide generic functions, that accept the implementation module as one of arguments. For example, we can add a generic print_product function to the PrinterBehaviour, that accepts a product and a printer module:
defmodule GenericPrinter do
@type product :: %{name: String.t(), price: number()}
@callback print(product) :: String.t()
# The new function that accepts a product
# and the printer module. In more complex
# scenarios it could have additional logic,
# like transforming the product argument
# or printed value.
def print_product(product, printer) do
printer.print(product)
end
end
GenericPrinter.print_product(%{name: "bike", price: 10}, PrettyPrinterImpl)
Default callback implementations
In some cases, we want to provide a default implementation of a callback, so that the implementation doesn’t have to define it. It’s particularly useful when there are many callbacks, but let’s assume that print should default to inspect if the implementation isn’t provided. We can do that with a macro:
defmodule PrinterWithDefault do
@type product :: %{name: String.t(), price: number()}
@callback print(product) :: String.t()
# We define a macro with defmacro
defmacro default_impl() do
# Macro returns code.
# To return code instead of evaluating it,
# we need to wrap the code with quote do ... end
quote do
# This is the default implementation
# that will be 'injected' into the implementing
# module.
@impl true
def print(product) do
inspect(product)
end
# In case someone calls this macro and then
# provides implementation anyway, we mark
# `print/1` as overridable. This means that
# the default is not injected when the implementation
# is provided.
defoverridable print: 1
end
end
def print_product(product, printer) do
printer.print(product)
end
end
# A new implementation that falls back to the default
defmodule DefaultPrinterImpl do
@behaviour PrinterWithDefault
# require so that we can call the macro
require PrinterWithDefault
# call the macro that injects the default impl
PrinterWithDefault.default_impl()
end
PrinterWithDefault.print_product(%{name: "bike", price: 10}, DefaultPrinterImpl)
We’ve just created and used our first macro. Macros are powerful and complex, but we won’t dive into that here. To learn more about macros, see the meta-programming chapter of the Elixir Guide.
The way of injecting callbacks or other code from a behaviour module is common in Elixir. It’s also a classic use case for use.
Use
use MyModule is equivalent to calling require MyModule and then MyModule.__using__/1. Let’s see how it can simplify our implementations:
defmodule PrinterWithUse do
@type product :: %{name: String.t(), price: number()}
@callback print(product) :: String.t()
# We renamed default_impl to __using__
# It can be passed additional arguments,
# but we don't use them here
defmacro __using__(_args) do
quote do
# We added @behaviour here,
# so that it's automatically injected too
@behaviour PrinterWithUse
@impl true
def print(product) do
inspect(product)
end
defoverridable print: 1
end
end
def print_product(product, printer) do
printer.print(product)
end
end
defmodule DefaultPrinterImplWithUse do
# Now we can create a printer implementation
# with just a single line
use PrinterWithUse
end
defmodule PrettyPrinterImplWithUse do
# The pretty printer can also use the same
# API, by calling use instead of @behaviour
use PrinterWithUse
@impl true
def print(product) do
"The #{product.name} costs $#{product.price}"
end
end
# 💡 Let's implement a CSV printer using the new API
PrinterWithUse.print_product(%{name: "bike", price: 10}, PrettyPrinterImplWithUse)
While the mechanisms described above may seem complex, in practice, it mostly boils down to calling use SomeModule and implementing callbacks as stated in the docs.
Note that use can inject any code into the caller module. Therefore, to keep the code maintainable and compilation times low, follow the following rules:
-
The documentation of a given module should state clearly what exactly happens when you
useit -
If something can be achieved with
aliasorimport, they should be preferred touse - The amount of code injected with macros should be possibly small