Skip to content

Instantly share code, notes, and snippets.

@kubischj
Last active May 8, 2024 08:36
Show Gist options
  • Save kubischj/8c6d573a07ef2d8c1387647c090e91ff to your computer and use it in GitHub Desktop.
Save kubischj/8c6d573a07ef2d8c1387647c090e91ff to your computer and use it in GitHub Desktop.

On elixir protocols & behaviours

Coming from TypeScript, the difference between behaviours and protocols in elixir might not be immediately obvious. Both of them look pretty much like an interface from two different angles. We'll go into detail to try and clear up how they are different in this post, and also how they are similar.

tldr

Behaviours:

Behaviours are a way to define a set of functions that a module must implement. Behaviours are defined with a list of functions and their return types, without providing implementations for them. Other modules can then declare that they implement this behaviour by providing implementations for all the functions specified.

Protocols:

Protocols provide a way to implement the same functionality for different types of data. They allow you to define a set of functions for a certain data type, together with different implementations for given structs of your choosing. You can extend on them later to support new types without modifying already existing implementations.

Behaviours

Behaviours are used by modules that expect another module as an argument. They define what methods the input module should implement in order to be able to work using the @callback and @macrocallback attributes. They are mostly for documentation and linting purposes, and are not checked by the compiler. You can annotate your module with any number of callbacks that specify the methods your module will be using, and indicate the expected return type for them. In the following example, the RealEstate module expects a get_price_per_square_meter method that returns an integer defined on the module passed as the parameter for its get_price function:

defmodule RealEstate do
  @callback get_price_per_square_meter() :: integer()

  @spec get_price(TwoDShape, module()) :: float()
  def get_price(shape, module) do
    TwoDShape.area(shape) * module.get_price_per_square_meter()
  end
end

Then, the module implementing the behaviour is also annotated, and defines the required method:

defmodule PricePerSquareMeter do
  @behaviour RealEstate
  @impl RealEstate
  def get_price_per_square_meter() do
    Enum.random([15, 20])
  end
end

With that done, you can call the get_price method, passing it the PricePerSquareMeter module as such:

defmodule BehaviourExample do
  @spec real_estate_price() :: :ok
  def real_estate_price() do
    land_price =
      RealEstate.get_price(%Circle{radius: 60}, PricePerSquareMeter)
    IO.puts("Land price #{land_price}")

    house_price =
      RealEstate.get_price(%Rectangle{width: 60, height: 40}, PricePerSquareMeter)
    IO.puts("House price #{house_price}")

    pyramid_price =
      RealEstate.get_price(%Triangle{base: 23, height: 55}, PricePerSquareMeter)
    IO.puts("Pyramid price #{pyramid_price}")
  end
end

And out you'll get all the prices.

You can think of behaviours as stating the collection of methods for your model that it requires to function properly. These methods are then also decorated to further enhance the documentation you can generate.

A good example of a behaviour is the Swoosh Adapter for email delivery, letting you use Swoosh to send emails using whatever custom delivery method your setup needs. Swoosh comes with a lot of the common email services already available, while you can also implement the adapter behaviour it defines to conform to your custom setup. The only thing you need to do is provide a module that defines the deliver, deliver_many, validate_config and validate_dependency methods.

# my_adapter.ex

defmodule MyApp.MyAdapter do

In a real life scenario, you'd invoke the use Swoosh.Adapter macro, that inserts the necessary code for you, but for the sake of the example, we'll implement the behaviour explicitly.

  @behaviour Swoosh.Adapter

  @impl Swoosh.Adapter
  def validate_config(config) do
    required_config = [:api_key]
    Swoosh.Adapter.validate_config(required_config, config)
  end

  @impl Swoosh.Adapter
  def validate_dependency do
    Swoosh.Adapter.validate_dependency([MyApp.MailApiClient])
  end

  @impl Swoosh.Adapter
  def deliver(email, config) do
    MyApp.MailApiClient.post!("https://my-service.org/deliver", Jason.encode!(email), config)
  end

  @impl Swoosh.Adapter
  def deliver_many(emails, config) do
    MyApp.MailApiClient.post!("https://my-service.org/deliver_many", Jason.encode!(emails), config)
  end
end

And then, in either your config.exs or runtime.exs, you tell Swoosh to use your implementation of its adapter behaviour.

# config.exs || runtime.exs

  config :my_app, MyApp.Mailer,
    adapter: MyApp.MyAdapter,
    api_key: api_key

In javascript, Vue implements pretty much the same concept for component data and lifecycle methods:

export default {
  data () {
    return {
      dataStore: ref(dataStore)
    }
  },
  mounted () {
    this.loadData()
  },
  beforeDestroy () {
    this.cleanup()
  }
}

Which is also what Phoenix LiveView uses to implement its functionality. You could implement a countdown similar to the above setup like this:

defmodule AppWeb.PageLive do
  use Phoenix.LiveView

  @impl Phoenix.LiveView
  def mount(_session, socket) do
    {:ok, loadData()}
  end

  @impl Phoenix.LiveView
  def handle_event("event", params, socket) do
    {:noreply, handleEvent(params)}
  end

  @impl Phoenix.LiveView
  def render(assigns) do
    ~H"""
    Hello world!
    """
  end
end

You can see it's mostly the same idea, except LiveView expects you to provide the various handlers when implementing the behaviour.

It's worth noting that Elixir deprecated their Behaviour module in favour of the @callback and @macrocallback attributes, which is also noted in their documentation. You might run into some of the remnants of this module in code written with previous versions, but these effectively achieve the same goal.

Protocols

Protocols are all about data manipulation. A good example is String.Chars, that defines the way to convert a certain data type to a string. It only requires one function to be implemented, to_string, which handles the conversion.

Another common example for a protocol is the Enumerable protocol, that has four required functions - count, reduce, slice and member, and allows iterating over values of the data types the protocol is implemented for. One of the most used modules implementing the protocol is probably Enum, defining many commonly used iteration functions such as map, find, sum, split and zip.

We can take a look at the Enumerable implementation for Maps:

defimpl Enumerable, for: Map do
  def count(map) do
    {:ok, map_size(map)}
  end

For the count method, Maps already have a map_size that tells how many key-value tuples are in the map, so that can be re-used.

  def member?(map, {key, value}) do
    {:ok, match?(%{^key => ^value}, map)}
  end

  def member?(_map, _other) do
    {:ok, false}
  end

The member method uses pattern matching to decide if the key and value pair is present in the Map or not.

  def slice(map) do
    size = map_size(map)
    {:ok, size, &:maps.to_list/1}
  end

As for slice, in the end it will convert the Map to a List and slice it using the method for Lists.

  def reduce(map, acc, fun) do
    Enumerable.List.reduce(:maps.to_list(map), acc, fun)
  end
end

Lastly, reduce uses the implementation for lists, by first converting the map to a list, and forwarding the accumulator and the supplied function to Enumerable.List.reduce.

Similarly, you can implement a protocol by declaring its required functions for specific data types. We can define a protocol for calculating the area for a 2D shape, that can then be implemented for various different shapes:

defprotocol TwoDShape do
  @spec area(t) :: float() | integer()
  def area(shape)
end

It's pretty simple so far, to satisfy the protocol, we need one area method that expects a shape, and returns an integer or a float. We will also need the definitions for the shapes themselves of course. You can find our example for all this here:

defmodule Circle do
  @enforce_keys [:radius]
  defstruct [:radius]

  @type t() :: %__MODULE__{
    radius: integer()
  }
end

defimpl TwoDShape, for: Circle do
  def area(%Circle{radius: radius}) do
    2 * radius * 3.14
  end
end

defmodule Rectangle do
  @enforce_keys [:width, :height]
  defstruct [:width, :height]

  @type t() :: %__MODULE__{
    height: integer(),
    width: integer()
  }
end

defimpl TwoDShape, for: Rectangle do
  def area(%Rectangle{width: width, height: height}) do
    width * height
  end
end

defmodule Triangle do
  @enforce_keys [:base, :height]
  defstruct [:base, :height]

  @type t() :: %__MODULE__{
    base: integer(),
    height: integer()
  }
end

defimpl TwoDShape, for: Triangle do
  def area(%Triangle{base: base, height: height}) do
    1 / 2 * base * height
  end
end

Then we can calculate the area for these shapes as follows:

circle_area = TwoDShape.area(%Circle{radius: 50})
IO.puts("Circle area: #{circle_area}")

rectangle_area = TwoDShape.area(%Rectangle{width: 21, height: 33})
IO.puts("Rectangle area: #{rectangle_area}")

triangle_area = TwoDShape.area(%Triangle{base: 21, height: 35})
IO.puts("Triangle area: #{triangle_area}")

You can see that because we implemented the area method for all three shapes, we'll get the correct calculation from essentially the same method, without having had to type check by hand and create different branches.

Behaviours & Protocols

In summary, behaviours and protocols are pretty similar, with the main differences are behaviours being more of a documentation aid, and being more about modules while protocols are about data. Both of them are mostly useful for library creators who wish to share their code with a wider audience, while users would most likely find themselves on the other side, implementing protocols for their own data, or creating modules to satisfy behaviours.

@kubischj
Copy link
Author

@Shadowbeetle csináltam gist, nem tudom látod-e xd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment