Protocols

Learn about protocols and their usage in Elixir.

Introduction

We’ve used the inspect function many times in this course. It returns a printable representation of any value as a binary, which is what we call strings.

But let’s stop and think for a minute. Just how can Elixir, which doesn’t have objects, know what to call to do the conversion to a binary? We can pass inspect anything, and Elixir somehow makes sense of it. It could be done using guard clauses:

def inspect(value) when is_atom(value), do: ...
def inspect(value) when is_binary(value), do: ...
:       :

But there’s a better way.

Elixir has the concept of protocols. A protocol is a little like the behaviours we saw in the previous chapter in that it defines the functions that must be provided to achieve something. But a behaviour is internal to a module; the module implements the behaviour. Protocols are different because we can place a protocol’s implementation completely outside the module. This means we can extend modules’ functionality without having to add code to them. In fact, we can extend the functionality even if we don’t have the modules’ source code.

Defining a protocol

Protocol definitions are very similar to basic module definitions. They can contain module- and function-level documentation (@moduledoc and @doc), and they’ll contain one or more function definitions. However, these functions won’t have bodies. Their job is simply to declare the interface that the protocol requires.

For example, here’s the definition of the Inspect protocol:

defprotocol Inspect do 
  @fallback_to_any true
def inspect(thing, opts) 
end

Just like a module, the protocol defines one or more functions. But we implement the code separately.

Implementing a protocol

The defimpl macro lets us give Elixir the implementation of a protocol for one or more types. The code that follows is the implementation of the Inspect protocol for PIDs and references.

defimpl Inspect, for: PID do 
  def inspect(pid, _opts) do
    "#PID" <>IO.iodata_to_binary(pid_to_list(pid)) 
  end
end

defimpl Inspect, for: Reference do 
  def inspect(ref, _opts) do
    '#Ref' ++ rest = :erlang.ref_to_list(ref)
    "#Reference" <> IO.iodata_to_binary(rest) 
  end
end

Finally, the Kernel module implements inspect, which calls Inspect.inspect with its parameter. This means that when we call inspect(self), it becomes a call to Inspect.inspect(self). And because self is a PID, this in turn resolves to something like "#PID<0.25.0>".

Behind the scenes, defimpl puts the implementation for each protocol-and-type combination into a separate module. The protocol for Inspect for the PID type is in the module Inspect.PID. Because we can recompile modules, we can change the implementation of functions accessed via protocols.

iex> inspect self
"#PID<0.25.0>"
iex> defimpl Inspect, for: PID do
...> def inspect(pid, _) do
...> "#Process: " <> IO.iodata_to_binary(:erlang.pid_to_list(pid)) <> "!!" 
...> end
...> end
iex:3: redefining module Inspect.PID
{:module, Inspect.PID, <<70,79....

iex> inspect self
"#Process: <0.25.0>!!"

Try running the above commands below:

Get hands-on with 1200+ tech skills courses.