What's the point of a polymorphic eq?
Why not keep equality inside the module where it belongs?
Let’s start with a User
:
defmodule User do
defstruct [:id, :name]
end
And create two users with the same ID but different names:
user1 = %User{id: 42, name: "Robert"}
user2 = %User{id: 42, name: "Rob"}
The fields don’t all match, so Elixir’s built-in equality says they’re not equal:
user1 == user2 # false
But in our domain, these users are equal. The same ID means the same person. That’s domain equality, and that’s the problem we need to solve.
Encapsulation
One strategy is encapsulation, where we group data with its behavior.
Object-Oriented Programming
C# uses encapsulation. By default, ==
checks whether two objects occupy the same memory, and Equals
inherits that behavior unless overridden. To implement domain-specific rules, we override the Equals
method.
public class User
{
public int Id { get; }
public string Name { get; }
public User(int id, string name)
{
Id = id;
Name = name;
}
public override bool Equals(object obj)
{
if (obj is User other)
{
return this.Id == other.Id;
}
return false;
}
}
Let’s create a couple of users:
var user1 = new User(42, "Robert");
var user2 = new User(42, "Rob");
These users do not share the same memory location, so ==
returns false:
user1 == user2; // false
But user1
knows it is equal to user2
:
user1.Equals(user2); // true
Here, the equality behavior is encapsulated with the data. The result is each object knows how to compare itself.
Elixir
Elixir can also implement encapsulation:
defmodule User do
defstruct [:id, :name]
def eq?(%User{id: id1}, %User{id: id2}), do: id1 == id2
def eq?(_, _), do: false
end
Now:
User.eq?(user1, user2) # true
The mental model of encapsulation is to group data with its behavior. C# groups by object and Elixir groups by modules.
Contracts
To manage contracts, C# has interfaces, Elixir has behaviours.
With Elixir, we first need a module to define the contract:
defmodule Eq do
@callback eq?(term, term) :: boolean
end
Next, we use the @behaviour
macro to enforce the contract:
defmodule User do
@behaviour Eq
defstruct [:id, :name]
def eq?(%User{id: id1}, %User{id: id2}), do: id1 == id2
def eq?(_, _), do: false
end
With @behaviour Eq
, Elixir will emit a warning if the User
module does not implement the eq/2
callback.
Encapsulation manages complexity by grouping data and behavior within a clear boundary. But what if we don’t want this coupling?
Open and Closed
In functional programming, it is typical for a function to be determined entirely by its arguments. All the information needed to compute the result is passed in explicitly.
Let’s start with an equals?/3
function, which takes two values and a module:
equals? = fn (a, b, module) -> module.eq?(a, b) end
Here, the module is handling the behavior. If we use the User
module we get equality by ID:
equals?.(user1, user2, User) # true
For different behavior, we need a different module:
defmodule UserEqName do
@behaviour Eq
def eq?(%User{name: name1}, %User{name: name2}), do: name1 == name2
def eq?(_, _), do: false
end
With this:
equals?.(user1, user2, UserEqName) # false
Although Robert and Rob share the same ID, they do not have equal names.
This mental model is the open/closed principle. Logic is closed to modification but it is open to extension through injecting behaviors. If you’re coming from OOP, this is the Strategy Pattern.
If you’ve worked with functional programming, this might seem perfectly normal. But from an OOP perspective, it can feel like unnecessary indirection with no clear benefit. Why would you want to decouple data and behavior?
Is this polymorphic?
Passing a different behavior to the equal?/2
function will produce results according to that behavior.
- Is it decoupled? Yes.
- Is it flexible? Yes.
- Is it polymorphic? No.
Ad hoc polymorphism is when the function dispatches differently depending on the type of input. Our solutions so far rely on the caller dictating the behavior—the function makes no choices on its own.
Polymorphism
If you come from a functional programming background, such as Haskell, you might ask, “Why are we not just extending the polymorphic ==
to include our domain-specific rules?”
That’s a different mental model. Here, behavior isn’t coupled to the data, but to equality itself.
In Elixir, operators are fixed, so there is no way to extend ==
directly.
However, we can achieve the same effect with a protocol:
defprotocol Eq do
@fallback_to_any true
def eq?(a, b)
end
We use Any
to define the fallback case to ==
:
defimpl Eq, for: Any do
def eq?(a, b), do: a == b
end
Now, we can extend eq?/2
to include our User
:
defimpl Eq, for: User do
def eq?(%User{id: v1}, %User{id: v2}), do: Eq.eq?(v1, v2)
def eq?(_, _), do: false
end
Here, Eq
is domain-aware. For User
, it picks out the ID and passes it along. In this case, it calls the Any
implementation, which uses ==
to compare the IDs.
Eq.eq?(user1, user2) # true
Notice that primitives work as expected:
Eq.eq?(1, 1) # true
Eq.eq?(1, 2) # false
While Elixir doesn’t support operator overloading, we’ve achieved the same effect. In fact, we can stop using ==
altogether and just use the extended Eq.eq?/2
.
Let’s compare birthdays. First, we need to create a Birthday
struct:
defmodule Birthday do
defstruct [:born_on]
end
Then we need a few birthdays:
a = %Birthday{born_on: ~U[2000-08-25 12:00:00Z]}
b = %Birthday{born_on: ~U[1985-08-25 08:30:00Z]}
c = %Birthday{born_on: ~U[2000-01-01 00:00:00Z]}
By default, Eq falls back to ==
:
Eq.eq?(a, b) # false — different struct values
Eq.eq?(a, c) # false — different struct values
But we can teach Elixir what a Birthday
means:
defimpl Funx.Eq, for: Birthday do
def eq?(%Birthday{born_on: %DateTime{month: m1, day: d1}},
%Birthday{born_on: %DateTime{month: m2, day: d2}}),
do: m1 == m2 and d1 == d2
def eq?(_, _), do: false
end
Equal birthdays mean the same day and month.
Eq.eq?(a, b) # true
Eq.eq?(a, c) # false
Now, our Eq.eq/2
not only knows how to compare users, but birthdays as well. All without the caller having to inject the correct behavior.
Let’s add birthdays to our User
:
defmodule User do
defstruct [:birthday, :id, :name]
end
And define a few users:
user1 = %User{
id: 1,
name: "Robert",
birthday: %Birthday{born_on: ~U[2000-08-25 12:00:00Z]}
}
user2 = %User{
id: 2,
name: "John",
birthday: %Birthday{born_on: ~U[1990-08-25 08:00:00Z]}
}
user3 = %User{
id: 3,
name: "Alice",
birthday: %Birthday{born_on: ~U[2000-01-01 00:00:00Z]}
}
Now we can compare users by their birthdays:
Eq.eq?(user1.birthday, user2.birthday) # true
Eq.eq?(user1.birthday, user3.birthday) # false
Solve the concept of Birthday
once, and that meaning propagates wherever equality is used.
That’s too much magic!
When developers say “magic,” we don’t mean it as a compliment. Abstractions trade ease of use for immediate understanding. When that trade doesn’t feel worth it, we call it “magic.”
With polymorphism, we call a generic function like eq?/2
—and somehow, the right behavior runs. Is that magic? Is the tradeoff worth it?
Back when we used encapsulation, equality was closed. We made a decision, and that decision was enforced every time we compared.
In functional programming, Eq
is just the starting point. We can lift it, compose it, derive it, combine it, project it, and apply it. From the FP perspective, what we gain in composability more than justifies the trade.
This topic is explored in more depth in my book, Advanced Functional Programming with Elixir, available now in beta from The Pragmatic Bookshelf.