Polymorphism in Elixir
This post walks through the basics of protocols in Elixir. It’s the information I wish I knew five years ago.
Protocols are all about polymorphism, which means writing code that behaves differently depending on its input.
Object-Oriented Interfaces
In object-oriented programming, this is typically done through interfaces:
public interface ISpeak {
string Speak();
}
public class Duck : ISpeak {
public string Speak() => "Quack";
}
public class Cat : ISpeak {
public string Speak() => "Meow";
}
public static void Announce(ISpeak animal) {
Console.WriteLine(animal.Speak());
}
Duck
and Cat
implement the same interface, so any function that expects ISpeak
can operate on either a Duck
or Cat
.
Elixir Protocols
Elixir handles polymorphism through protocols.
First we define our Cat
and Duck
structs:
defmodule Cat do
defstruct [:name, :microchip]
end
defmodule Duck do
defstruct [:name, :microchip]
end
Even when the fields are the same, Elixir knows they are different types.
Now let’s define a protocol:
defprotocol Speak do
def speak(term)
end
And implement it for our Duck
and Cat
:
defimpl Speak, for: Duck do
def speak(_), do: "Quack"
end
defimpl Speak, for: Cat do
def speak(_), do: "Meow"
end
The announcer module can use our protocol to change behavior based on input:
defmodule Announcer do
def announce(animal) do
IO.puts(Speak.speak(animal))
end
end
There’s no inheritance here. Protocols dispatch based on the runtime type and call the matching implementation.
If no implementation exists, it crashes. But Elixir lets us define a default:
defprotocol Speak do
@fallback_to_any true
def speak(term)
end
defimpl Speak, for: Any do
def speak(_), do: "(silence)"
end
This gives us a safe fallback when no specific implementation is found. Not all domains have a meaningful default, but when they do, we can use Any
.
Now our animals speak:
whiskers = %Cat{name: "Whiskers", microchip: "123-abc"}
quacky = %Duck{name: "Quacky", microchip: "999-xyz"}
Announcer.announce(whiskers) # prints "Meow"
Announcer.announce(quacky) # prints "Quack"
Speak
works well for animals, but functional programming emphasizes more general patterns that apply across many domains.
Protocols in Functional Programming
Let’s look at the general concept of equality:
defprotocol Eq do
def eq?(a, b)
end
We’ve got a cat named “Whiskers.” How do we know it’s the same cat we saw last year?
We can use Eq
to compare microchips:
defimpl Eq, for: Cat do
def eq?(%Cat{microchip: a}, %Cat{microchip: b}), do: a == b
end
Notice that the first Cat
pattern match isn’t necessary, it’s already handled by the implementation. This version expresses the same logic:
defimpl Eq, for: Cat do
def eq?(%{microchip: a}, %Cat{microchip: b}), do: a == b
end
Now, with our two cats named “Whiskers”:
previous = %Cat{name: "Whiskers", microchip: "abc"}
current = %Cat{name: "Whiskers", microchip: "xyz"}
Eq.eq?(previous, current) # false
Even though the names match, the microchips don’t. So we know they’re not the same cat.
Protocol Limitations
Protocols in Elixir have some limitations.
Dispatches on the First Argument
Protocols dispatch only on the first argument. In a two-argument function like equality, only the first argument’s type determines which implementation is used.
defprotocol Eq do
def eq?(a, b)
end
Calling Eq.eq?(%Cat{}, any)
dispatches to the Cat
implementation because protocol dispatch depends on the first argument.
In our Cat
implementation, this will crash:
Eq.eq?(%Cat{}, %Duck{})
# => (FunctionClauseError)
It dispatched to Cat
, but Duck
didn’t match. Note that it will not fallback to Any
. If the logic cannot be resolved within the Cat
implementation, it will crash.
Most of the time, equality is homogeneous—comparing two of the same type. If the domain needs heterogeneous equality, we can relax the match:
defimpl Eq, for: Cat do
def eq?(%{microchip: a}, %{microchip: b}), do: a == b
end
This allows comparison of Cat
with any Struct with a microchip
key, but first argument still must be a Cat
.
Also, if the second argument does not have the microchip
key, this will crash.
No Optional Functions
In Haskell, Eq
includes both (==)
and (/=)
, but (/=)
is optional. If it’s not defined, Haskell derives not-equal from equal: x /= y = not (x == y)
.
Elixir protocols don’t support optional functions or default implementations. If we want not_eq?/2
, every implementation must define it. However, this is just the protocol, the implementations can choose to derive not_eq?/2
from eq?/2
.
This topic is explored in more depth in my book, Advanced Functional Programming with Elixir, available now in beta from The Pragmatic Bookshelf.