Powered by AppSignal & Oban Pro

Create Flexible Ordering with Protocols

chapters/chap_03_ord.livemd

Create Flexible Ordering 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 3
Advanced Functional Programming with Elixir.

Define Order with a Protocol

```elixir
defprotocol FunPark.Ord do
  def lt?(a, b)
  def le?(a, b) 
  def gt?(a, b)
  def ge?(a, b)
end
```
```elixir
defimpl FunPark.Ord, for: Any do
  def lt?(a, b), do: a < b
  def le?(a, b), do: a <= b
  def gt?(a, b), do: a > b 
  def ge?(a, b), do: a >= b
end
```

Using Elixir’s operators:

1 < 2
1 > 2

Our Ord implementation produces the same results:

FunPark.Ord.lt?(1, 2)
FunPark.Ord.gt?(1, 2)

Implement Order for FunPark Contexts

Ride Context

Rides should be ordered by name:

```elixir
defimpl FunPark.Ord, for: FunPark.Ride do
  alias FunPark.Ord
  alias FunPark.Ride
  def lt?(%Ride{name: v1}, %Ride{name: v2}), do: Ord.lt?(v1, v2)
  def le?(%Ride{name: v1}, %Ride{name: v2}), do: Ord.le?(v1, v2)
  def gt?(%Ride{name: v1}, %Ride{name: v2}), do: Ord.gt?(v1, v2)
  def ge?(%Ride{name: v1}, %Ride{name: v2}), do: Ord.ge?(v1, v2)
end
```

First, let’s make a couple of rides:

banana_slip = FunPark.Ride.make("Banana Slip")
apple_cart = FunPark.Ride.make("Apple Cart")

Elixir’s built-in < operator sorts Apple Cart after Banana Slip:

apple_cart < banana_slip

Our Ord protocol knows that Ride values should be compared by name:

FunPark.Ord.lt?(apple_cart, banana_slip)

FastPass Context

FastPasses should be ordered by time:

```elixir  
defimpl FunPark.Ord, for: FunPark.FastPass do
  alias FunPark.Ord
  alias FunPark.FastPass
  def lt?(%FastPass{time: v1}, %FastPass{time: v2}), do: Ord.lt?(v1, v2)
  def le?(%FastPass{time: v1}, %FastPass{time: v2}), do: Ord.le?(v1, v2)
  def gt?(%FastPass{time: v1}, %FastPass{time: v2}), do: Ord.gt?(v1, v2)
  def ge?(%FastPass{time: v1}, %FastPass{time: v2}), do: Ord.ge?(v1, v2)
end
```

Let’s create a couple of timestamps to illustrate the problem:

datetime_1 = DateTime.new!(~D[2025-06-01], ~T[13:10:00.000005])
datetime_2 = DateTime.new!(~D[2025-06-01], ~T[13:40:00.000004])

The first comes before the second, so it should be less—but it’s not:

datetime_1 < datetime_2

DateTime.compare/2, on the other hand, does understand time:

DateTime.compare(datetime_1, datetime_2)

We can implement our own Ord instance for DateTime:

```elixir
defimpl FunPark.Ord, for: DateTime do
  def lt?(a, b), do: DateTime.compare(a, b) == :lt
  def le?(a, b), do: DateTime.compare(a, b) in [:lt, :eq]
  def gt?(a, b), do: DateTime.compare(a, b) == :gt
  def ge?(a, b), do: DateTime.compare(a, b) in [:gt, :eq]
end
```

Let’s start with a couple of FastPasses:

apple_cart = FunPark.Ride.make("Apple Cart")
banana_slip = FunPark.Ride.make("Banana Slip")

datetime_1 = DateTime.new!(~D[2025-06-01], ~T[13:10:00.000005])
datetime_2 = DateTime.new!(~D[2025-06-01], ~T[13:40:00.000004])

fast_pass_1 = FunPark.FastPass.make(banana_slip, datetime_1)
fast_pass_2 = FunPark.FastPass.make(apple_cart, datetime_2)

Our default Ord correctly identifies that the first pass comes before the second:

FunPark.Ord.lt?(fast_pass_1, fast_pass_2)

If we update fast_pass_1 to a later time:

time_3 = DateTime.new!(~D[2025-06-01], ~T[15:00:00.000012])
fast_pass_1 = FunPark.FastPass.change(fast_pass_1, %{time: time_3})

The first pass is now considered greater:

FunPark.Ord.gt?(fast_pass_1, fast_pass_2)

Transform Inputs Before Comparison

```elixir
def contramap(f) do
  %{
    lt?: fn a, b -> Ord.lt?(f.(a), f.(b)) end,
    le?: fn a, b -> Ord.le?(f.(a), f.(b)) end,
    gt?: fn a, b -> Ord.gt?(f.(a), f.(b)) end,
    ge?: fn a, b -> Ord.ge?(f.(a), f.(b)) end
  }
end
```

Ticket tiers should be sorted in order: :basic, :premium, :vip:

```elixir
def get_ticket_tier(%__MODULE__{ticket_tier: tier}), do: tier

def ticket_tier_to_int(:basic), do: 1
def ticket_tier_to_int(:premium), do: 2  
def ticket_tier_to_int(:vip), do: 3

def ord_by_ticket_tier do
  Ord.Utils.contramap(fn patron ->
    patron |> get_ticket_tier() |> ticket_tier_to_int()
  end)
end
```

Let’s start with two patrons with differing ticket tiers:

alice = FunPark.Patron.make("Alice", 15, 50, ticket_tier: :premium)
beth = FunPark.Patron.make("Beth", 16, 53)

Alice ranks higher than Beth due to her :premium ticket:

ticket_ord = FunPark.Patron.ord_by_ticket_tier()
ticket_ord.gt?.(alice, beth)

Later, Beth decides to upgrade to :vip:

beth = FunPark.Patron.change(beth, %{ticket_tier: :vip})

With a :vip ticket, Beth now ranks higher than Alice:

ticket_ord.gt?.(beth, alice)

Harness Order for Collections

Single-Purpose Sorting

```elixir
def sort_rides(rides) do
  Enum.sort(rides, fn ride1, ride2 -> ride1.name < ride2.name end)
end
```

Now we can create a couple of rides and sort them:

apple_cart = FunPark.Ride.make("Apple Cart")
banana_slip = FunPark.Ride.make("Banana Slip")

FunPark.List.sort_rides([banana_slip, apple_cart])

Generalize the Sort

```elixir
def compare(a, b, ord \\ Ord) do
  ord = to_ord_map(ord)
  cond do
    ord.lt?.(a, b) -> :lt
    ord.gt?.(a, b) -> :gt
    true -> :eq
  end
end
```

We can compare simple values:

FunPark.Ord.Utils.compare(1, 1)
FunPark.Ord.Utils.compare(1, 2)
FunPark.Ord.Utils.compare(1, 0)

And context-aware values, such as rides:

apple_cart = FunPark.Ride.make("Apple Cart")
banana_slip = FunPark.Ride.make("Banana Slip")

FunPark.Ord.Utils.compare(apple_cart, apple_cart)
FunPark.Ord.Utils.compare(apple_cart, banana_slip)
FunPark.Ord.Utils.compare(banana_slip, apple_cart)
```elixir
def sort(list, ord \\ Ord) do
  comparator = comparator(ord)
  Enum.sort(list, comparator)  
end
```

We can sort simple values:

FunPark.List.sort([:banana, :pear, :apple])

Or context-aware values that have implemented the Ord protocol:

banana_slip = FunPark.Ride.make("Banana Slip")
apple_cart = FunPark.Ride.make("Apple Cart")

FunPark.List.sort([banana_slip, apple_cart])

Strict Sort

Let’s create a few rides:

tea_cup = FunPark.Ride.make("Tea Cup", wait_time: 40)
haunted_mansion = FunPark.Ride.make("Haunted Mansion", wait_time: 20)
river_ride = FunPark.Ride.make("River Ride", wait_time: 40)
rides = [tea_cup, haunted_mansion, river_ride]

Using ord_by_wait_time/0, we can sort rides by their current wait times:

ord_wait_time = FunPark.Ride.ord_by_wait_time()
FunPark.List.sort(rides, ord_wait_time)

To generate a snapshot—just one ride per distinct wait time—we use strict_sort/2:

FunPark.List.strict_sort(rides, ord_wait_time)

Reverse the Order

```elixir
def reverse(ord \\ Ord) do
  ord = to_ord_map(ord)
  %{
    lt?: ord.gt?,
    le?: ord.ge?,
    gt?: ord.lt?,
    ge?: ord.le?
  }
end
```

We can invert the result of compare/3:

reverse_ord = FunPark.Ord.Utils.reverse()
FunPark.Ord.Utils.compare(:apple, :banana)
FunPark.Ord.Utils.compare(:apple, :banana, reverse_ord)

By default, Patron sorts by name, so Alice comes before Beth:

alice = FunPark.Patron.make("Alice", 14, 140, ticket_tier: :vip)
beth = FunPark.Patron.make("Beth", 15, 130, ticket_tier: :premium)

FunPark.List.sort([alice, beth])

With reverse/0, Beth now comes before Alice:

reverse_ord = FunPark.Ord.Utils.reverse()
FunPark.List.sort([alice, beth], reverse_ord)

If we sort by ticket tier, Alice’s VIP ticket comes after Beth’s Premium:

ticket_ord = FunPark.Patron.ord_by_ticket_tier()
FunPark.List.sort([alice, beth], ticket_ord)

And with reverse/1, we can invert the logic so higher ticket tiers come first:

reverse_ticket_ord = FunPark.Ord.Utils.reverse(ticket_ord)
FunPark.List.sort([alice, beth], reverse_ticket_ord)