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.
