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.
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 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 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.
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.
@Shadowbeetle csináltam gist, nem tudom látod-e xd