Combine with Monoids
Mix.install([
{:fun_park,
git: "https://github.com/JKWA/funpark_notebooks.git",
branch: "main"
}
])
Advanced Functional Programming with Elixir
|
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([])