Compose in Context with Monads
Mix.install([
{:fun_park,
git: "https://github.com/JKWA/funpark_notebooks.git",
branch: "main"
}
])
Advanced Functional Programming with Elixir
|
Interactive Examples from Chapter 6 Advanced Functional Programming with Elixir. |
Build the Monad
Transform with a Functor
```elixir
def promotion(%__MODULE__{} = patron, points) do
change(patron, %{reward_points: patron.reward_points + points})
end
```
Let’s start by generating a list of patrons:
alice = FunPark.Patron.make("Alice", 14, 125, reward_points: 25)
beth = FunPark.Patron.make("Beth", 15, 140, reward_points: 10)
charles = FunPark.Patron.make("Charles", 13, 130, reward_points: 50)
patrons = [alice, beth, charles]
With Elixir’s Enum.map/2
functor, we can apply promotion/2
to each patron, adding 10 to their reward points:
patrons |> Enum.map(&FunPark.Patron.promotion(&1, 10))
Sequence Computations
First, we need to define a Kleisli function:
kleisli_fn = fn x -> if rem(x, 2) == 0, do: [x * x], else: [] end
Next, we need a list of values:
list = [1, 2, 3, 4, 5, 6]
When we apply our Kleisli function:
list |> Enum.flat_map(kleisli_fn)
Independent Computations
ap = fn values, funcs -> for f <- funcs, v <- values, do: f.(v) end
Next, we need a couple of simple functions, add_one/1
and add_two/1
:
add_one = fn x -> x + 1 end
add_two = fn x -> x + 2 end
func_list = [add_one, add_two]
And we need a list of values:
list = [10, 20, 30]
Finally, we use ap
to apply our list of functions to our list of values:
list |> ap.(func_list)
The Protocol
```elixir
defprotocol FunPark.Monad do
def map(monad, function)
def bind(monad, function)
def ap(monad, function)
end
```
Model Neutrality with Identity
```elixir
defmodule FunPark.Identity do
defstruct value: nil
def pure(value), do: %__MODULE__{value: value}
def extract(%__MODULE__{value: value}), do: value
end
```
Let’s start by generating a patron:
alice = FunPark.Patron.make("Alice", 14, 130)
We can lift Alice into the Identity
context:
alice_monad = FunPark.Identity.pure(alice)
And we can extract her:
FunPark.Identity.extract(alice_monad)
In fact, we can pass anything through the Identity
monad with no effect:
:apple |> FunPark.Identity.pure() |> FunPark.Identity.extract()
Equality
```elixir
defimpl FunPark.Eq, for: FunPark.Identity do
alias FunPark.Eq
alias FunPark.Identity
def eq?(%Identity{value: v1}, %Identity{value: v2}), do: Eq.eq?(v1, v2)
def not_eq?(%Identity{value: v1}, %Identity{value: v2}), do: Eq.not_eq?(v1, v2)
end
```
Let’s start by generating a couple of patrons:
alice = FunPark.Patron.make("Alice", 14, 130)
beth = FunPark.Patron.make("Beth", 16, 125)
The Eq
protocol knows when they are equivalent:
FunPark.Eq.Utils.eq?(alice, alice)
FunPark.Eq.Utils.eq?(alice, beth)
Even when they’re wrapped in the Identity
monad:
alice_monad = FunPark.Identity.pure(alice)
beth_monad = FunPark.Identity.pure(beth)
FunPark.Eq.Utils.eq?(alice_monad, alice_monad)
FunPark.Eq.Utils.eq?(alice_monad, beth_monad)
Ordering
```elixir
defimpl FunPark.Ord, for: FunPark.Identity do
alias FunPark.Ord
alias FunPark.Identity
def lt?(%Identity{value: v1}, %Identity{value: v2}), do: Ord.lt?(v1, v2)
def le?(%Identity{value: v1}, %Identity{value: v2}), do: Ord.le?(v1, v2)
def gt?(%Identity{value: v1}, %Identity{value: v2}), do: Ord.gt?(v1, v2)
def ge?(%Identity{value: v1}, %Identity{value: v2}), do: Ord.ge?(v1, v2)
end
```
Again, let’s generate our Patrons
:
alice = FunPark.Patron.make("Alice", 14, 135, ticket_tier: :vip)
beth = FunPark.Patron.make("Beth", 16, 125)
The Patron
context’s default ordering is by name, so Alice is less than Beth:
FunPark.Ord.Utils.compare(alice, beth)
It makes no difference if they are wrapped in Identity
monads:
alice_monad = FunPark.Identity.pure(alice)
beth_monad = FunPark.Identity.pure(beth)
FunPark.Ord.Utils.compare(alice_monad, beth_monad)
Lift Eq and Order
```elixir
def lift_eq(custom_eq) do
custom_eq = Eq.Utils.to_eq_map(custom_eq)
%{
eq?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_eq.eq?.(v1, v2)
end,
not_eq?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_eq.not_eq?.(v1, v2)
end
}
end
```
```elixir
def lift_ord(custom_ord) do
custom_ord = Ord.Utils.to_ord_map(custom_ord)
%{
lt?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_ord.lt?.(v1, v2)
end,
le?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_ord.le?.(v1, v2)
end,
gt?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_ord.gt?.(v1, v2)
end,
ge?: fn %__MODULE__{value: v1}, %__MODULE__{value: v2} ->
custom_ord.ge?.(v1, v2)
end
}
end
```
Let’s return to our patrons:
alice = FunPark.Patron.make("Alice", 14, 135, ticket_tier: :vip)
beth = FunPark.Patron.make("Beth", 16, 125)
We have custom ordering based on priority:
priority_ord = FunPark.Patron.ord_by_priority()
Within the context of priority, Alice—with her VIP status—is greater than Beth:
FunPark.Ord.Utils.compare(alice, beth, priority_ord)
We can lift that priority_ord
into the Identity context:
lifted_priority_ord = FunPark.Identity.lift_ord(priority_ord)
And we get the same result for our wrapped patrons:
alice_monad = FunPark.Identity.pure(alice)
beth_monad = FunPark.Identity.pure(beth)
FunPark.Ord.Utils.compare(alice_monad, beth_monad, lifted_priority_ord)
Monadic Logic
```elixir
defimpl FunPark.Monad, for: FunPark.Identity do
def map(%FunPark.Identity{value: value}, function) do
%FunPark.Identity{value: function.(value)}
end
def bind(%FunPark.Identity{value: value}, function) do
function.(value)
end
def ap(%FunPark.Identity{value: function}, %FunPark.Identity{value: value}) do
%FunPark.Identity{value: function.(value)}
end
end
```
```elixir
def add_wait_time(%__MODULE__{} = ride, minutes) when minutes >= 0 do
change(ride, %{wait_time: ride.wait_time + minutes})
end
```
Let’s generate the Tea Cup ride:
tea_cup = FunPark.Ride.make("Tea Cup", wait_time: 10)
We can add 20 minutes:
FunPark.Ride.add_wait_time(tea_cup, 20)
And because add_wait_time/2
is closed under its operation, we can use the pipe operator to compose multiple sensors:
tea_cup
|> FunPark.Ride.add_wait_time(20)
|> FunPark.Ride.add_wait_time(10)
|> FunPark.Ride.add_wait_time(5)
The same applies within the Identity
context. First, let’s lift the Tea Cup ride:
tea_cup_m = FunPark.Identity.pure(tea_cup)
To make things easier, we’ll curry the add_wait_time/2
function:
add_wait = FunPark.Utils.curry_r(&FunPark.Ride.add_wait_time/2)
Now we can then apply it to the monad using map/2
:
FunPark.Monad.map(tea_cup_m, add_wait.(20))
And again, we can compose updates from three sensors:
tea_cup_m
|> FunPark.Monad.map(add_wait.(20))
|> FunPark.Monad.map(add_wait.(10))
|> FunPark.Monad.map(add_wait.(5))
Even so, we can model our sensors so they choose the structure:
sensor_1 = &FunPark.Identity.pure(add_wait.(10).(&1))
sensor_2 = &FunPark.Identity.pure(add_wait.(5).(&1))
sensor_3 = &FunPark.Identity.pure(add_wait.(20).(&1))
We chain Kleisli functions with bind/2
:
tea_cup_m
|> FunPark.Monad.bind(sensor_1)
|> FunPark.Monad.bind(sensor_2)
|> FunPark.Monad.bind(sensor_3)