Protocols vs. Behaviours in Elixir: Additional Thoughts

savonarola

Ilya Averyanov

Posted on August 9, 2021

Protocols vs. Behaviours in Elixir: Additional Thoughts

Initial Thoughts

Recently I have read a fantastic article by Yiming Chen about Protocols vs. Behaviours in Elixir.

I want to thank the author for the great article — it clarifies a lot and strictly formulates some ideas that I couldn't acquire by myself.

Here I want to add some thoughts about the use cases of Protocols vs. Behaviours, precisely why we often still use Behaviours instead of Protocols.

My personal informal point is the following:

Protocols are suitable for pure interfaces (as stated in the article) of pure data structures.

When we try to use Protocols with something that deals with side effects, namely processes and message sending, we often start to feel awkward.

The reason is that things with side effects are built with OTP pieces (GenServer's), and:

  • we feel comfortable following its original design;
  • delayed initialization is essential, and it doesn't look fine with Protocols.

Illustrations

Let me illustrate that.

I develop a small library SMPPEX, and there I have a module and Behaviour named Session. It's only important that it wraps and extends GenServer, i.e.

  • It specifies callbacks, such as init, handle_call, ..., and some own ones: handle_pdu, etc. They operate as usual: init initializes some state, and then it is passed to the callbacks.
  • It implements interface functions such as Session.start_link, Session.call, Session.cast, as well as Session.send_pdu, etc.

Naive Design

When I first tried to implement this module, I tried to apply the idea of Protocols.

I made a Protocol, like SessionState, which specified callbacks:

defprotocol SessionState do
  def handle_call(st, from, message)
  def handle_pdu(st, pdu)
  ...
end
Enter fullscreen mode Exit fullscreen mode

To use Session, I would do the following.

First, implement SessionState.

defmodule SessionStateImpl do
  defstruct [...]

  def new(args) do
    ...
  end
end

defimpl SessionState, for: SessionStateImpl do
  def handle_pdu(st, pdu) do
    ...
    {:noreply, new_st}
  end
end
Enter fullscreen mode Exit fullscreen mode

Then create an instance and pass it to Session:

st = SessionStateImpl.new(args)
{:ok, pid} = Session.start_link(st, ...)
Enter fullscreen mode Exit fullscreen mode

At first glance, it may seem that everything is excellent, but trying to make a PoC app, I ran into an issue. It is that in OTP the context of initialization is essential.

When implementing GenServers, users often like to do something like:

def init(opts) do
  ...
  timer = :erlang.start_timer(@interval, self(), :tick)
  ...
end
Enter fullscreen mode Exit fullscreen mode

We need to know the session's PID to set up timers or do other useful stuff, like global registration, etc.

But we create SessionStateImpl (call SessionStateImpl.new) before starting Session and don't have a place to do that.

Probable Improvements

Solution 1

We may defer SessionStateImpl creation and initialization so that Session internals could do that.

# st = SessionStateImpl.new(args)
{:ok, pid} = Session.start_link(SessionStateImpl, :new, args, ...)
Enter fullscreen mode Exit fullscreen mode

But I don't think that we used Protocol and got rid of passing module and state around to start to pass them again :)

Solution 2

We may defer initialization and make it be a part of SessionState Protocol. The problem is that we can't use Protocol without the underlying struct, so we should do something like:

defprotocol SessionState do
  ...
  def init(uninitialized_st, args)
  ...
end

...

defimpl SessionState, for: SessionStateImpl do
  def init(uninitialized_st, args) do
    ...
    # some initialization
    ...
    {:ok, initialized_st}
  end
end

...

st = %SessionStateImpl{}
{:ok, pid} = Session.start_link(st, args, ...)
Enter fullscreen mode Exit fullscreen mode

Now we have to pass uninitialized states around — the idea I do not like much.

Using Behaviours

Playing around with this, I concluded that I was doing something strange and would confuse possible users.

I moved to traditional Behaviours and got the feeling that everything was right :)

Successful Example of Using Protocols

Once I was developing a system analyzing git commits. The commits were fetched from Bitbucket API and represented huge JSON chunks of data passed around.

I added Commit Protocol and implemented it for APICommit:

defprotocol Commit do
  def author(commit)
  def author_email(commit)
  def added_files(commit)
  ...
end
Enter fullscreen mode Exit fullscreen mode

Later I had to combine such commits with ones saved to DB differently. But, thanks to having a Protocol, this logic didn't have to treat them differently after I implemented Commit for DBCommit data structure.

This case of Protocol usage is an example of dealing with pure data structures, and it proved to be successful.

Conclusion

The problem of selecting between Behaviours and Protocols in Elixir is exciting and challenging at the same time.

I hope to see more examples of successful and unsuccessful attempts of modeling using Behaviours and Protocols.

💖 💪 🙅 🚩
savonarola
Ilya Averyanov

Posted on August 9, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related