Reference and functions for working with protocols.
A protocol specifies an API that should be defined by its
implementations. A protocol is defined with Kernel.defprotocol/2
and its implementations with Kernel.defimpl/3.
Example
In Elixir, we have two nouns for checking how many items there
are in a data structure: length and size. length means the
information must be computed. For example, length(list) needs to
traverse the whole list to calculate its length. On the other hand,
tuple_size(tuple) and byte_size(binary) do not depend on the
tuple and binary size as the size information is precomputed in
the data structure.
Although Elixir includes specific functions such as tuple_size,
byte_size and map_size, sometimes we want to be able to
retrieve the size of a data structure regardless of its type.
In Elixir we can write polymorphic code, i.e. code that works
with different shapes/types, by using protocols. A size protocol
could be implemented as follows:
defprotocol Size do
@doc "Calculates the size (and not the length!) of a data structure"
def size(data)
end
Now that the protocol can be implemented for every data structure
the protocol may have a compliant implementation for:
defimpl Size, for: BitString do
def size(binary), do: byte_size(binary)
end
defimpl Size, for: Map do
def size(map), do: map_size(map)
end
defimpl Size, for: Tuple do
def size(tuple), do: tuple_size(tuple)
end
Finally, we can use the Size protocol to call the correct implementation:
Size.size({1, 2})
# => 2
Size.size(%{key: :value})
# => 1
Note that we didn't implement it for lists as we don't have the
size information on lists, rather its value needs to be
computed with length.
The data structure you are implementing the protocol for
must be the first argument to all functions defined in the
protocol.
It is possible to implement protocols for all Elixir types:
Protocols and Structs
The real benefit of protocols comes when mixed with structs.
For instance, Elixir ships with many data types implemented as
structs, like MapSet. We can implement the Size protocol
for those types as well:
defimpl Size, for: MapSet do
def size(map_set), do: MapSet.size(map_set)
end
When implementing a protocol for a struct, the :for option can
be omitted if the defimpl/3 call is inside the module that defines
the struct:
defmodule User do
defstruct [:email, :name]
defimpl Size do
# two fields
def size(%User{}), do: 2
end
end
If a protocol implementation is not found for a given type,
invoking the protocol will raise unless it is configured to
fall back to Any. Conveniences for building implementations
on top of existing ones are also available, look at defstruct/1
for more information about deriving protocols.
Fallback to Any
In some cases, it may be convenient to provide a default
implementation for all types. This can be achieved by setting
the @fallback_to_any attribute to true in the protocol
definition:
defprotocol Size do
@fallback_to_any true
def size(data)
end
The Size protocol can now be implemented for Any:
defimpl Size, for: Any do
def size(_), do: 0
end
Although the implementation above is arguably not a reasonable
one. For example, it makes no sense to say a PID or an integer
have a size of 0. That's one of the reasons why @fallback_to_any
is an opt-in behavior. For the majority of protocols, raising
an error when a protocol is not implemented is the proper behavior.
Multiple implementations
Protocols can also be implemented for multiple types at once:
defprotocol Reversible do
def reverse(term)
end
defimpl Reversible, for: [Map, List] do
def reverse(term), do: Enum.reverse(term)
end
Inside defimpl/3, you can use @protocol to access the protocol
being implemented and @for to access the module it is being
defined for.
Types
Defining a protocol automatically defines a zero-arity type named t, which
can be used as follows:
@spec print_size(Size.t()) :: :ok
def print_size(data) do
result =
case Size.size(data) do
0 -> "data has no items"
1 -> "data has one item"
n -> "data has #{n} items"
end
IO.puts(result)
end
The @spec above expresses that all types allowed to implement the
given protocol are valid argument types for the given function.
Configuration
The following module attributes are available to configure a protocol:
@fallback_to_any - when true, enables protocol dispatch to
fallback to any
@undefined_impl_description - a string with additional description
to be used on Protocol.UndefinedError when looking up the implementation
fails. This option is only applied if @fallback_to_any is not set to true
Consolidation
In order to speed up protocol dispatching, whenever all protocol implementations
are known up-front, typically after all Elixir code in a project is compiled,
Elixir provides a feature called protocol consolidation. Consolidation directly
links protocols to their implementations in a way that invoking a function from a
consolidated protocol is equivalent to invoking two remote functions - one to
identify the correct implementation, and another to call the implementation.
Protocol consolidation is applied by default to all Mix projects during compilation.
This may be an issue during test. For instance, if you want to implement a protocol
during test, the implementation will have no effect, as the protocol has already been
consolidated. One possible solution is to include compilation directories that are
specific to your test environment in your mix.exs:
def project do
...
elixirc_paths: elixirc_paths(Mix.env())
...
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
And then you can define the implementations specific to the test environment
inside test/support/some_file.ex.
Another approach is to disable protocol consolidation during tests in your
mix.exs:
def project do
...
consolidate_protocols: Mix.env() != :test
...
end
If you are using Mix.install/2, you can do by passing the consolidate_protocols
option:
Mix.install(
deps,
consolidate_protocols: false
)
Although doing so is not recommended as it may affect the performance of
your code.
Finally, note all protocols are compiled with debug_info set to true,
regardless of the option set by the elixirc compiler. The debug info is
used for consolidation and it is removed after consolidation unless
globally set.