Powered by AppSignal & Oban Pro

Implement Domain-Specific Equality with Protocols

chapters/chap_02_eq.livemd

Implement Domain-Specific Equality with Protocols

Mix.install([
  {:fun_park,
    git: "https://github.com/JKWA/funpark_notebooks.git",
    branch: "main"
  }
])

Advanced Functional Programming with Elixir

https://www.joekoski.com/assets/images/jkelixir_small.jpg” alt=”Book cover” width=”120” /> Interactive Examples from Chapter 2
Advanced Functional Programming with Elixir.

Polymorphic Equality

```elixir
defprotocol FunPark.Eq do
  def eq?(a, b)
  def not_eq?(a, b)
end
```
```elixir
defimpl FunPark.Eq, for: Any do
  def eq?(a, b), do: a == b
  def not_eq?(a, b), do: a != b
end
```
1 == 1
1 == 2
FunPark.Eq.eq?(1, 1)
FunPark.Eq.eq?(1, 2)

Implement Equality for FunPark Contexts

```elixir
def change(%__MODULE__{} = patron, attrs) when is_map(attrs) do
  attrs = Map.delete(attrs, :id)
  struct(patron, attrs)
end
```

Create a patron named Alice:

alice_a = FunPark.Patron.make("Alice", 15, 150)

Alice decides to update her ticket to :premium:

alice_b = FunPark.Patron.change(alice_a, %{ticket_tier: :premium})

From Elixir’s perspective, alice_a and alice_b are not equal:

alice_a == alice_b

However, within the Patron context, this is wrong. Upgrading Alice’s ticket doesn’t make her a different patron; it just reflects a change in her attributes.

Implement the Eq Protocol

```elixir
defimpl FunPark.Eq, for: FunPark.Patron do
  alias FunPark.Eq
  alias FunPark.Patron
  def eq?(%Patron{id: v1}, %Patron{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%Patron{id: v1}, %Patron{id: v2}), do: Eq.not_eq?(v1, v2)
end
```

First, let’s regenerate our Patron structs:

alice_a = FunPark.Patron.make("Alice", 15, 150)
alice_b = FunPark.Patron.change(alice_a, %{ticket_tier: :premium})

And FunPark knows that although these are different records, they refer to the same Patron:

FunPark.Eq.eq?(alice_a, alice_b)

Ride Eq

```elixir
def change(%__MODULE__{} = ride, attrs) when is_map(attrs) do
  attrs = Map.delete(attrs, :id)
  struct(ride, attrs)
end
```
```elixir
defimpl FunPark.Eq, for: FunPark.Ride do
  alias FunPark.Eq
  alias FunPark.Ride
  def eq?(%Ride{id: v1}, %Ride{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%Ride{id: v1}, %Ride{id: v2}), do: Eq.not_eq?(v1, v2)
end
```

Let’s generate a ride:

ride_a = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])

And update the wait time to 20 minutes:

ride_b = FunPark.Ride.change(ride_a, %{wait_time: 20})

Again, from Elixir’s perspective, these are not equal:

ride_a == ride_b

But from the Ride context, updating the wait time does not make them different rides:

FunPark.Eq.eq?(ride_a, ride_b)

FastPass Eq

```elixir
def change(%__MODULE__{} = fast_pass, attrs) when is_map(attrs) do
  attrs = Map.delete(attrs, :id)
  struct(fast_pass, attrs)
end
```
```elixir
defimpl FunPark.Eq, for: FunPark.FastPass do
  alias FunPark.Eq
  alias FunPark.FastPass
  def eq?(%FastPass{id: v1}, %FastPass{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%FastPass{id: v1}, %FastPass{id: v2}), do: Eq.not_eq?(v1, v2)
end
```

Generate a fast pass:

tea_cup = FunPark.Ride.make("Tea Cup")
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
pass_a = FunPark.FastPass.make(tea_cup, datetime)

Now update the ride on that pass:

haunted_mansion = FunPark.Ride.make("Haunted Mansion")
pass_b = FunPark.FastPass.change(pass_a, %{ride: haunted_mansion})

Elixir sees the difference:

pass_a == pass_b

But our Eq implementation focuses only on ID and treats them as equal:

FunPark.Eq.eq?(pass_a, pass_b)

Transform Inputs Before Matching

After implementing the logic for fast passes, our Patron expert points out a problem—a patron can’t be in two places at once. From their perspective, two passes scheduled for the same time are effectively duplicates.

```elixir
defmodule FunPark.Eq.Utils do
  alias FunPark.Eq

  def contramap(f) do
    %{
      eq?: fn a, b -> Eq.eq?(f.(a), f.(b)) end,
      not_eq?: fn a, b -> Eq.not_eq?(f.(a), f.(b)) end
    }
  end
end
```
```elixir
def get_time(%__MODULE__{time: time}), do: time

def eq_time do
  Eq.Utils.contramap(&get_time/1)
end
```

Create the rides:

mansion = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])
tea_cup = FunPark.Ride.make("Tea Cup")

Generate a fast pass for the Dark Mansion:

datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass_a = FunPark.FastPass.make(mansion, datetime)

Generate another Fast Pass for the Tea Cup using the same time:

fast_pass_b = FunPark.FastPass.make(tea_cup, datetime)

Our default equality check knows these are different (they have different ids):

FunPark.Eq.eq?(fast_pass_a, fast_pass_b)

But our new custom equality knows they have the same time:

FunPark.FastPass.eq_time().eq?.(fast_pass_a, fast_pass_b)

Simplify Equality Checks

```elixir
def eq?(a, b, eq \\ Eq) do
  eq = to_eq_map(eq)
  eq.eq?.(a, b)
end
```

Let’s regenerate our passes:

mansion = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])
tea_cup = FunPark.Ride.make("Tea Cup")
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass_a = FunPark.FastPass.make(mansion, datetime)
fast_pass_b = FunPark.FastPass.make(tea_cup, datetime)

From the context of FastPass, the passes have different ID’s, so they are different:

FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b)

But we can inject our eq_time/0 logic to determine they are scheduled for the same time:

has_eq_time = FunPark.FastPass.eq_time()
FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b, has_eq_time)

Harness Equality for Collections

Unique

FunPark tracks the number of patrons entering per day, but some patrons leave and re-enter, so we need a unique count.

```elixir
def uniq(list, eq \\ Eq) do
  eq = Utils.to_eq_map(eq)

  Enum.reduce(list, [], fn item, acc ->
    if Enum.any?(acc, &eq.eq?.(item, &1)) do
      acc
    else
      acc ++ [item]
    end
  end)
end
```

First, let’s make two copies of Alice:

alice_a = FunPark.Patron.make("Alice", 15, 50)
alice_b = FunPark.Patron.change(alice_a, %{ticket_tier: :premium})
FunPark.List.uniq([alice_a, alice_b])

Even though alice_a and alice_b have different ticket tiers, they share the same ID. Since uniq/1 is context-aware, it used the Eq implementation for Patron and removed the second entry.

Union

FunPark tracks ride downtime from multiple sources, including scheduled maintenance and unexpected breakdowns. At the end of the day, we need to combine these logs to generate a unique list of rides that were offline.

```elixir
def union(list_a, list_b, eq \\ Eq) do
  uniq(list_a ++ list_b, eq)
end
```
tea_cup = FunPark.Ride.make("Tea Cup")
haunted_mansion = FunPark.Ride.make("Haunted Mansion")
apple_cart = FunPark.Ride.make("Apple Cart")

maintenance_log = [haunted_mansion, apple_cart]
breakdown_log = [tea_cup, haunted_mansion]

FunPark.List.union(maintenance_log, breakdown_log)

This combines both lists into one, removing duplicates based on the Ride‘s Eq implementation.

Intersection

FunPark adjusts incentives to balance crowd flow, steering guests away from overbooked rides. By intersecting rides with the longest wait times and rides with the most Fast Pass bookings, we identify high-demand attractions.

```elixir
def intersection(list_a, list_b, eq \\ Eq) do
  eq = Utils.to_eq_map(eq)

  Enum.filter(list_a, fn item_a ->
    Enum.any?(list_b, &eq.eq?.(item_a, &1))
  end)
  |> uniq(eq)
end
```

The Haunted Mansion is in high demand:

long_wait = [haunted_mansion, apple_cart]
most_fast_pass = [tea_cup, haunted_mansion]

FunPark.List.intersection(long_wait, most_fast_pass)

Difference

Not all rides in FunPark are accessible to every patron. We can use difference to subtract restricted rides from the full list, identifying attractions available to all guests.

```elixir
def difference(list_a, list_b, eq \\ Eq) do
  eq = Utils.to_eq_map(eq)

  Enum.filter(list_a, fn item_a ->
    not Enum.any?(list_b, &eq.eq?.(item_a, &1))
  end)
end
```

Apple Cart is available to everyone:

all_rides = [haunted_mansion, apple_cart]
restricted_rides = [haunted_mansion]

FunPark.List.difference(all_rides, restricted_rides)

Subset

FunPark needs to track if guests made full use of their Fast Passes. We can check if every eligible ride was used by verifying that the patron’s Fast Pass list is a subset of the rides they rode.

```elixir
def subset?(list_a, list_b, eq \\ Eq) do
  eq = Utils.to_eq_map(eq)

  Enum.all?(list_a, fn item_a ->
    Enum.any?(list_b, &eq.eq?.(item_a, &1))
  end)
end
```

We find that our patron has indeed completed all of their Fast Pass rides:

fast_pass_rides = [tea_cup]
rides_completed = [haunted_mansion, tea_cup]

FunPark.List.subset?(fast_pass_rides, rides_completed)