Powered by AppSignal & Oban Pro

Combine with Monoids

chapters/chap_04_monoid.livemd

Combine with Monoids

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 4
Advanced Functional Programming with Elixir.

Define the Protocol

```elixir
defprotocol FunPark.Monoid do
  def empty(monoid)
  def append(a, b)
  def wrap(monoid, value)
  def unwrap(monoid)
end
```

Combine Numbers with Sum

```elixir
defmodule FunPark.Monoid.Sum do
  defstruct value: 0
end
```
```elixir
defimpl FunPark.Monoid, for: FunPark.Monoid.Sum do
  def empty(_), do: %FunPark.Monoid.Sum{value: 0}
  def append(%{value: a}, %{value: b}), do: %FunPark.Monoid.Sum{value: a + b}
  def wrap(_, value), do: %FunPark.Monoid.Sum{value: value}
  def unwrap(%{value: value}), do: value
end
```

Use Monoid.wrap/2 to lift raw values into the monoid context:

sum_1 = FunPark.Monoid.wrap(%FunPark.Monoid.Sum{}, 1)
sum_2 = FunPark.Monoid.wrap(%FunPark.Monoid.Sum{}, 2)

Use append/2 to combine the values within the context of Monoid.Sum:

value = FunPark.Monoid.append(sum_1, sum_2)

And unwrap the final value:

FunPark.Monoid.unwrap(value)

Appendable

```elixir
def m_append(monoid, a, b) do
  monoid
  |> wrap(a)
  |> append(wrap(monoid, b))
  |> unwrap()
end
```

Combine 1 and 2:

FunPark.Monoid.Utils.m_append(%FunPark.Monoid.Sum{}, 1, 2)

Foldable

```elixir
defprotocol FunPark.Foldable do
  def foldl(foldable, fun, acc)
  def foldr(foldable, fun, acc)
end
```
```elixir
defimpl FunPark.Foldable, for: List do
  def foldl(list, fun, acc), do: :lists.foldl(fun, acc, list)
  def foldr(list, fun, acc), do: :lists.foldr(fun, acc, list)
end
```
```elixir
def m_concat(monoid, list) do
  empty_value = empty(monoid)
  FunPark.Foldable.foldl(list, &append/2, empty_value)
  |> unwrap()
end
```

Math

```elixir
def sum(a, b) when is_number(a) and is_number(b) do
  Utils.m_append(%Monoid.Sum{}, a, b)
end

def sum(list) when is_list(list) do
  Utils.m_concat(%Monoid.Sum{}, list)
end
```

Now we can add two Ride wait time sensors:

FunPark.Math.sum(1, 2)

And we can add a collection of sensors:

FunPark.Math.sum([1, 2, 3])

Or even a single sensor:

FunPark.Math.sum([3])

And when there are no sensors at all, the monoid still knows what to do:

FunPark.Math.sum([])

Combine Equality

Equal All

```elixir
defmodule FunPark.Monoid.EqAll do
  defstruct value: true
end
```
```elixir
defimpl FunPark.Monoid, for: FunPark.Monoid.EqAll do
  def empty(_), do: %FunPark.Monoid.EqAll{value: true}
  def append(%{value: a}, %{value: b}), do: %FunPark.Monoid.EqAll{value: a && b}
  def wrap(_, value), do: %FunPark.Monoid.EqAll{value: value}
  def unwrap(%{value: value}), do: value
end
```
```elixir
def append_all(eq_list) do
  Utils.contramap(fn {a, b} ->
    Enum.all?(eq_list, fn eq -> 
      eq_map = Utils.to_eq_map(eq)
      eq_map.eq?.(a, b)
    end)
  end)
end
```
```elixir
def concat_all(eq_list) do
  append_all(eq_list)
end
```

Create two fast passes that share the same ride and time:

datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
apple = FunPark.Ride.make("Apple Cart")
fast_pass_a = FunPark.FastPass.make(apple, datetime)
fast_pass_b = FunPark.FastPass.make(apple, datetime)

Combine ride and time comparators:

eq_ride = FunPark.FastPass.eq_ride()
eq_time = FunPark.FastPass.eq_time()
eq_both = FunPark.Eq.Utils.concat_all([eq_ride, eq_time])

Different IDs make them different:

FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b)
FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b, eq_ride)
FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b, eq_time)
FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b, eq_both)

Update the time on fast_pass_a:

datetime_2 = DateTime.new!(~D[2025-06-01], ~T[14:00:00])
fast_pass_a = FunPark.FastPass.change(fast_pass_a, %{time: datetime_2})

Same ride:

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

Different time:

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

Different time and ride:

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

Add to FastPass

```elixir
def eq_ride_and_time do
  Eq.Utils.concat_all([eq_ride(), eq_time()])
end
```

Equal Any

```elixir
defmodule FunPark.Monoid.EqAny do
  defstruct value: false
end
```
```elixir
defimpl FunPark.Monoid, for: FunPark.Monoid.EqAny do
  def empty(_), do: %FunPark.Monoid.EqAny{value: false}
  def append(%{value: a}, %{value: b}), do: %FunPark.Monoid.EqAny{value: a || b}
  def wrap(_, value), do: %FunPark.Monoid.EqAny{value: value}
  def unwrap(%{value: value}), do: value
end
```
```elixir
def duplicate_pass do
  Eq.Utils.concat_any([
    Eq,
    eq_ride_and_time()
  ])
end
```

Generate two FastPasses:

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

pass_a = FunPark.FastPass.make(tea_cup, datetime)
pass_b = FunPark.FastPass.make(tea_cup, datetime)

Different IDs:

FunPark.Eq.Utils.eq?(pass_a, pass_b)

Duplicate check considers them the same:

dup_pass_check = FunPark.FastPass.duplicate_pass()
FunPark.Eq.Utils.eq?(pass_a, pass_b, dup_pass_check)

Update the first pass to point to a different ride:

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

Same ID makes them equal:

FunPark.Eq.Utils.eq?(pass_a, pass_a_changed, dup_pass_check)

Different IDs and rides:

FunPark.Eq.Utils.eq?(pass_b, pass_a_changed, dup_pass_check)

Combine Order

```elixir
defmodule FunPark.Monoid.Ord do
  defstruct lt?: fn _, _ -> false end,
            le?: fn _, _ -> false end,
            gt?: fn _, _ -> false end,
            ge?: fn _, _ -> false end
end
```
```elixir
def empty(_) do
  %FunPark.Monoid.Ord{}
end

def append(ord1, ord2) do
  %FunPark.Monoid.Ord{
    lt?: fn a, b ->
      cond do
        ord1.lt?.(a, b) -> true
        ord1.gt?.(a, b) -> false
        true -> ord2.lt?.(a, b)
      end
    end,
    le?: fn a, b ->
      cond do
        ord1.lt?.(a, b) -> true
        ord1.gt?.(a, b) -> false
        true -> ord2.le?.(a, b)
      end
    end,
    gt?: fn a, b ->
      cond do
        ord1.gt?.(a, b) -> true
        ord1.lt?.(a, b) -> false
        true -> ord2.gt?.(a, b)
      end
    end,
    ge?: fn a, b ->
      cond do
        ord1.gt?.(a, b) -> true
        ord1.lt?.(a, b) -> false
        true -> ord2.ge?.(a, b)
      end
    end
  }
end
```
```elixir
def append(ord1, ord2) do
  Utils.m_append(%Monoid.Ord{}, ord1, ord2)
end

def concat(ord_list) do
  Utils.m_concat(%Monoid.Ord{}, ord_list)
end
```

Create the patrons Alice, Beth, and Charles:

alice = FunPark.Patron.make(
  "Alice", 15, 50, reward_points: 50, ticket_tier: :premium
)
beth = FunPark.Patron.make(
  "Beth", 16, 55, reward_points: 20, ticket_tier: :vip
)
charles = FunPark.Patron.make(
  "Charles", 14, 60, reward_points: 50, ticket_tier: :premium
)

Default sort is alphabetical by name:

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

Priority sort by ticket tier and reward points:

ord_ticket = FunPark.Patron.ord_by_ticket_tier()
ord_reward_points = FunPark.Patron.ord_by_reward_points()
ord_priority = FunPark.Ord.Utils.concat([ord_ticket, ord_reward_points])
FunPark.List.sort([charles, beth, alice], ord_priority)

Add name as tie-breaker:

ord_priority = FunPark.Ord.Utils.concat(
  [ord_ticket, ord_reward_points, FunPark.Ord]
)

FunPark.List.sort([charles, beth, alice], ord_priority)
```elixir
def ord_by_priority do
  Ord.Utils.concat([
    ord_by_ticket_tier(),
    ord_by_reward_points(),
    Ord
  ])
end
```

Generalize Maximum

```elixir
defmodule FunPark.Monoid.Max do
  defstruct value: nil, ord: FunPark.Ord
end
```
```elixir
def empty(%{value: identity}), do: %FunPark.Monoid.Max{value: identity}

def append(%{value: a, ord: ord}, %{value: b}) do
  %FunPark.Monoid.Max{value: Ord.Utils.max(a, b, ord), ord: ord}
end

def wrap(monoid, value), do: %{monoid | value: value}
def unwrap(%{value: value}), do: value
```
```elixir
def max(a, b) when is_number(a) and is_number(b) do
  identity = %Monoid.Max{value: Float.min_finite()}
  Utils.m_append(identity, a, b)
end

def max(list) when is_list(list) do
  identity = %Monoid.Max{value: Float.min_finite()}
  Utils.m_concat(identity, list)
end
```

Find the larger of two numbers:

FunPark.Math.max(1, 2)

And max/1 solves our Ride expert’s daily report from a ride’s wait time log:

log = [20, 30, 10, 20, 15, 10, 20]
FunPark.Math.max(log)

Like all Monoids, Max also works with a single value:

FunPark.Math.max([3])

And for an empty list, it returns the identity—in this case, Elixir’s smallest possible number:

FunPark.Math.max([])

Prioritize a Patron

```elixir
def priority_empty do
  %__MODULE__{
    id: nil,
    name: nil,
    ticket_tier: nil,
    reward_points: Float.min_finite(),
    age: 0,
    height: 0,
    fast_passes: [],
    likes: [],
    dislikes: []
  }
end
```
```elixir
def max_priority_monoid do
  %FunPark.Monoid.Max{
    value: priority_empty(),
    ord: ord_by_priority()
  }
end
```
```elixir
def highest_priority(patrons) do
  FunPark.Monoid.Utils.m_concat(max_priority_monoid(), patrons)
end
```

Beth has more reward points:

alice = FunPark.Patron.make("Alice", 15, 150)
beth = FunPark.Patron.make("Beth", 15, 150, reward_points: 100)

FunPark.Patron.highest_priority([beth, alice])

Alice upgrades to VIP:

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

FunPark.Patron.highest_priority([beth, alice])

The Max Monoid also works when there is only a single patron in the list:

FunPark.Patron.highest_priority([beth])

If there are no patrons, the sentinel value is returned:

FunPark.Patron.highest_priority([])