Throughout this introductory chapter, we've mentioned a couple of times that Elixir has protocols, with Enumerable being one of the examples. In this section, we'll dive into protocols and even define our own!
Protocols, like the behaviours we've seen in the last section, define a set of functions that have to be implemented. In that sense, both constructs serve as a way to achieve polymorphism in Elixir–being able to display multiple forms of behavior, but all linked to a single interface. While behaviours define a set of functions that a module needs to implement, and are thus tied to a module, protocols define a set of functions that a data type must implement. This means that, with protocols, we have data type polymorphism, and we're able to write functions that behave differently depending on the type of their arguments.
Let's now see how we can create a new protocol. We'll pick up, and extend, the example present in the official Getting Started guide (at http://elixir-lang.github.io/getting-started/protocols.html). We will define a Size protocol, which will be implemented by each data type. To define a new protocol, we use the defprotocol construct:
$ cat examples/size.ex
defprotocol Size do
@doc "Calculates the size of a data structure"
def size(data)
end
We're stating that our Size protocol expects the data types that will implement it must define a size/1 function, where the argument is the data structure we want to know the size of.
You can use the @doc directive to add documentation to this function, as you normally do with named functions inside modules. We can now define the implementation of this protocol for the data types we're interested in, using the defimpl construct:
$ cat examples/size_implementations_basic_types.ex
defimpl Size, for: BitString do
def size(string), do: byte_size(string)
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
With this defined, we can see our protocol in action:
iex> Size.size("a string")
8
iex> Size.size(%{a: "b", c: "d"})
2
iex> Size.size({1, 2, 3})
3
If we try to use our protocol on a type that doesn't have an implementation defined, an error is raised:
iex> Size.size([1, 2, 3, 4])
** (Protocol.UndefinedError) protocol Size not implemented for [1, 2, 3, 4]
Having to implement a protocol for all types may quickly become monotonous and exhausting. You can define a fallback behavior for types that don't implement your protocol by implementing the protocol for Any. Let's do this for our Size protocol:
$ cat examples/size_implementation_any.ex
defimpl Size, for: Any do
def size(_), do: 0
end
You have to define the desired behavior when a type doesn't implement your protocol. In this case, we're saying that it has a size of 0 (which might not make sense, since the data type may have a size different than 0, but let's ignore that detail).
We now have two options for this implementation to be used: Either mark the modules where we want this fallback behavior with @derive [Size] (the List module, for instance), or use @fallback_to_any true in the definition of our Size protocol. The former is more laborious as you have to annotate each module that you want to assume the behavior for Any, while the latter is simpler since you make it work on all data types just by changing the definition of your protocol. In the Elixir community, explicitness is usually preferred, and, as such, you're more likely to see the @derive approach in Elixir projects.
While implementing protocols for Elixir's data types already opens a world of possibilities, we can only fully utilize Elixir's extensibility when we mix them with structs. We haven't yet talked about structs, so we'll introduce them in the next section.