View Source Protocol (Elixir v1.15.5)

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.

A real case

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, binary_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 behaviour. For the majority of protocols, raising an error when a protocol is not implemented is the proper behaviour.

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.

Reflection

Any protocol module contains three extra functions:

  • __protocol__/1 - returns the protocol information. The function takes one of the following atoms:

    • :consolidated? - returns whether the protocol is consolidated

    • :functions - returns a keyword list of protocol functions and their arities

    • :impls - if consolidated, returns {:consolidated, modules} with the list of modules implementing the protocol, otherwise :not_consolidated

    • :module - the protocol module atom name

  • impl_for/1 - returns the module that implements the protocol for the given argument, nil otherwise

  • impl_for!/1 - same as above but raises Protocol.UndefinedError if an implementation is not found

For example, for the Enumerable protocol we have:

iex> Enumerable.__protocol__(:functions)
[count: 1, member?: 2, reduce: 3, slice: 1]

iex> Enumerable.impl_for([])
Enumerable.List

iex> Enumerable.impl_for(42)
nil

In addition, every protocol implementation module contains the __impl__/1 function. The function takes one of the following atoms:

  • :for - returns the module responsible for the data structure of the protocol implementation

  • :protocol - returns the protocol module for which this implementation is provided

For example, the module implementing the Enumerable protocol for lists is Enumerable.List. Therefore, we can invoke __impl__/1 on this module:

iex(1)> Enumerable.List.__impl__(:for)
List

iex(2)> Enumerable.List.__impl__(:protocol)
Enumerable

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.

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.

Summary

Functions

Checks if the given module is loaded and is an implementation of the given protocol.

Checks if the given module is loaded and is protocol.

Receives a protocol and a list of implementations and consolidates the given protocol.

Returns true if the protocol was consolidated.

Derives the protocol for module with the given options.

Extracts all types implemented for the given protocol from the given paths.

Extracts all protocols from the given paths.

Functions

Link to this function

assert_impl!(protocol, base)

View Source
@spec assert_impl!(module(), module()) :: :ok

Checks if the given module is loaded and is an implementation of the given protocol.

Returns :ok if so, otherwise raises ArgumentError.

Link to this function

assert_protocol!(module)

View Source
@spec assert_protocol!(module()) :: :ok

Checks if the given module is loaded and is protocol.

Returns :ok if so, otherwise raises ArgumentError.

Link to this function

consolidate(protocol, types)

View Source
@spec consolidate(module(), [module()]) ::
  {:ok, binary()} | {:error, :not_a_protocol} | {:error, :no_beam_info}

Receives a protocol and a list of implementations and consolidates the given protocol.

Consolidation happens by changing the protocol impl_for in the abstract format to have fast lookup rules. Usually the list of implementations to use during consolidation are retrieved with the help of extract_impls/2.

It returns the updated version of the protocol bytecode. If the first element of the tuple is :ok, it means the protocol was consolidated.

A given bytecode or protocol implementation can be checked to be consolidated or not by analyzing the protocol attribute:

Protocol.consolidated?(Enumerable)

This function does not load the protocol at any point nor loads the new bytecode for the compiled module. However, each implementation must be available and it will be loaded.

@spec consolidated?(module()) :: boolean()

Returns true if the protocol was consolidated.

Link to this macro

derive(protocol, module, options \\ [])

View Source (macro)

Derives the protocol for module with the given options.

If your implementation passes options or if you are generating custom code based on the struct, you will also need to implement a macro defined as __deriving__(module, struct, options) to get the options that were passed.

Examples

defprotocol Derivable do
  def ok(arg)
end

defimpl Derivable, for: Any do
  defmacro __deriving__(module, struct, options) do
    quote do
      defimpl Derivable, for: unquote(module) do
        def ok(arg) do
          {:ok, arg, unquote(Macro.escape(struct)), unquote(options)}
        end
      end
    end
  end

  def ok(arg) do
    {:ok, arg}
  end
end

defmodule ImplStruct do
  @derive [Derivable]
  defstruct a: 0, b: 0
end

Derivable.ok(%ImplStruct{})
#=> {:ok, %ImplStruct{a: 0, b: 0}, %ImplStruct{a: 0, b: 0}, []}

Explicit derivations can now be called via __deriving__/3:

# Explicitly derived via `__deriving__/3`
Derivable.ok(%ImplStruct{a: 1, b: 1})
#=> {:ok, %ImplStruct{a: 1, b: 1}, %ImplStruct{a: 0, b: 0}, []}

# Explicitly derived by API via `__deriving__/3`
require Protocol
Protocol.derive(Derivable, ImplStruct, :oops)
Derivable.ok(%ImplStruct{a: 1, b: 1})
#=> {:ok, %ImplStruct{a: 1, b: 1}, %ImplStruct{a: 0, b: 0}, :oops}
Link to this function

extract_impls(protocol, paths)

View Source
@spec extract_impls(module(), [charlist() | String.t()]) :: [atom()]

Extracts all types implemented for the given protocol from the given paths.

The paths can be either a charlist or a string. Internally they are worked on as charlists, so passing them as lists avoid extra conversion.

Does not load any of the implementations.

Examples

# Get Elixir's ebin directory path and retrieve all protocols
iex> path = :code.lib_dir(:elixir, :ebin)
iex> mods = Protocol.extract_impls(Enumerable, [path])
iex> List in mods
true
Link to this function

extract_protocols(paths)

View Source
@spec extract_protocols([charlist() | String.t()]) :: [atom()]

Extracts all protocols from the given paths.

The paths can be either a charlist or a string. Internally they are worked on as charlists, so passing them as lists avoid extra conversion.

Does not load any of the protocols.

Examples

# Get Elixir's ebin directory path and retrieve all protocols
iex> path = :code.lib_dir(:elixir, :ebin)
iex> mods = Protocol.extract_protocols([path])
iex> Enumerable in mods
true