Powered by AppSignal & Oban Pro

Compose in Context with Monads

chapters/chap_06_monad.livemd

Compose in Context with Monads

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 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(&amp;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 = &amp;FunPark.Identity.pure(add_wait.(10).(&amp;1))
sensor_2 = &amp;FunPark.Identity.pure(add_wait.(5).(&amp;1))
sensor_3 = &amp;FunPark.Identity.pure(add_wait.(20).(&amp;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)