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 asSession.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
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
Then create an instance and pass it to Session
:
st = SessionStateImpl.new(args)
{:ok, pid} = Session.start_link(st, ...)
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
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, ...)
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, ...)
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
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.
Top comments (2)
Thank you! I'm glad to see my blog post to be inspiring!
I think maybe
handle_continue
callback can be useful here 🤔pass/intialize a pure data structure in the
init
callback, then callstart_timer
in thehandle_continue
callback?Thanks for the feedback!
That should work, but does not completely resolves the thing that bothers me the most: that we still have two places where initialization happens: when creating a data structure and then some "additional" initialization in
handle_continue
. Maybe I am too spoiled with traditional OOP languages, and this is OK :)