Define Logic with Predicates
Mix.install([
{:fun_park,
git: "https://github.com/JKWA/funpark_notebooks.git",
branch: "main"
}
])
Advanced Functional Programming with Elixir
|
Interactive Examples from Chapter 5 Advanced Functional Programming with Elixir. |
Simple Predicates
```elixir
def online?(%__MODULE__{online: online}), do: online
```
```elixir
def long_wait?(%__MODULE__{wait_time: wait_time}), do: wait_time > 30
```
Let’s start by generating a ride:
tea_cup = FunPark.Ride.make("Tea Cup", online: true, wait_time: 100)
The Tea Cup is online:
FunPark.Ride.online?(tea_cup)
And it has a long wait:
FunPark.Ride.long_wait?(tea_cup)
```elixir
def p_not(predicate) do
fn value -> not predicate.(value) end
end
```
```elixir
def short_wait?, do: p_not(&long_wait?/1)
```
Combine Predicates
All
```elixir
defmodule FunPark.Monoid.PredAll do
defstruct pred: fn _ -> true end
end
defimpl FunPark.Monoid, for: FunPark.Monoid.PredAll do
def empty(_), do: %FunPark.Monoid.PredAll{}
def append(%{pred: pred_a}, %{pred: pred_b}) do
%FunPark.Monoid.PredAll{pred: fn value -> pred_a.(value) and pred_b.(value) end}
end
def wrap(_, pred), do: %FunPark.Monoid.PredAll{pred: pred}
def unwrap(%{pred: pred}), do: pred
end
```
Any
```elixir
defmodule FunPark.Monoid.PredAny do
defstruct pred: fn _ -> false end
end
defimpl FunPark.Monoid, for: FunPark.Monoid.PredAny do
def empty(_), do: %FunPark.Monoid.PredAny{}
def append(%{pred: pred_a}, %{pred: pred_b}) do
%FunPark.Monoid.PredAny{pred: fn value -> pred_a.(value) or pred_b.(value) end}
end
def wrap(_, pred), do: %FunPark.Monoid.PredAny{pred: pred}
def unwrap(%{pred: pred}), do: pred
end
```
```elixir
def p_all(predicates) when is_list(predicates) do
FunPark.Monoid.Utils.m_concat(%FunPark.Monoid.PredAll{}, predicates)
end
def p_any(predicates) when is_list(predicates) do
FunPark.Monoid.Utils.m_concat(%FunPark.Monoid.PredAny{}, predicates)
end
def p_not(predicate) do
fn value -> not predicate.(value) end
end
```
```elixir
def suggested?(%__MODULE__{} = ride) do
p_all([&online?/1, p_not(&long_wait?/1)]).(ride)
end
```
The Tea Cup ride is not suggested because it has a wait time of 100 minutes:
tea_cup = FunPark.Ride.make("Tea Cup", online: true, wait_time: 100)
FunPark.Ride.suggested?(tea_cup)
Later, the wait time for the Tea Cup ride shortens to 10 minutes, making it a suggested ride:
tea_cup = FunPark.Ride.change(tea_cup, %{wait_time: 10})
FunPark.Ride.suggested?(tea_cup)
Predicates That Span Contexts
```elixir
def tall_enough?(%Patron{} = patron, %__MODULE__{} = ride) do
Patron.get_height(patron) >= ride.min_height
end
```
```elixir
def old_enough?(%Patron{} = patron, %__MODULE__{} = ride) do
Patron.get_age(patron) >= ride.min_age
end
```
```elixir
def eligible?(%Patron{} = patron, %__MODULE__{} = ride),
do: p_all([&tall_enough?/2, &old_enough?/2]).(patron, ride)
```
Let’s start with a patron and a ride:
roller_mtn = FunPark.Ride.make(
"Roller Mountain", min_height: 120, min_age: 12
)
alice = FunPark.Patron.make("Alice", 13, 119)
Alice meets the age requirement but does not meet the height requirement:
alice |> FunPark.Ride.old_enough?(roller_mtn)
alice |> FunPark.Ride.tall_enough?(roller_mtn)
This means Alice is not eligible to ride Roller Mountain:
alice |> FunPark.Ride.eligible?(roller_mtn)
However, if Alice grows a bit over the summer, she will be eligible:
alice = FunPark.Patron.change(alice, %{height: 121})
alice |> FunPark.Ride.eligible?(roller_mtn)
Compose Multi-Arity Functions with Curry
```elixir
def curry(fun) do
case :erlang.fun_info(fun, :arity) do
{:arity, 1} ->
fn arg -> fun.(arg) end
{:arity, arity} ->
fn arg ->
curry(fn args -> fun.(arg, args) end)
end
end
end
```
```elixir
def eligible?(%Patron{} = patron, %__MODULE__{} = ride) do
p_all([
Utils.curry(&tall_enough?/2),
Utils.curry(&old_enough?/2)
]).(patron, ride)
end
```
```elixir
def suggested?(%Patron{} = patron, %__MODULE__{} = ride) do
p_all([
&suggested?/1,
Utils.curry(&eligible?/2)
]).(patron, ride)
end
```
Now we can check whether Roller Mountain is a suggested ride for Alice:
alice |> FunPark.Ride.suggested?(roller_mtn)
If we take Roller Mountain offline, the result changes:
roller_mtn = FunPark.Ride.change(roller_mtn, %{online: false})
alice |> FunPark.Ride.suggested?(roller_mtn)
Harness Predicates for Collections
Let’s start by defining a mixture of online and offline rides:
thunder_loop = FunPark.Ride.make("Thunder Loop")
ghost_hollow = FunPark.Ride.make("Ghost Hollow", online: false)
rocket_ridge = FunPark.Ride.make("Rocket Ridge")
jungle_river = FunPark.Ride.make("Jungle River", online: false)
nebula_falls = FunPark.Ride.make("Nebula Falls")
timber_twister = FunPark.Ride.make("Timber Twister", online: false)
rides = [
thunder_loop,
ghost_hollow,
rocket_ridge,
jungle_river,
nebula_falls,
timber_twister
]
online? = &FunPark.Ride.online?/1
Predicate Checks
Are all rides online? If not, are any available?
rides |> Enum.all?(online?)
rides |> Enum.any?(online?)
Counting
To monitor availability, it helps to know how many rides are currently online:
rides |> Enum.count(online?)
Finding Elements
Sometimes it’s useful to locate the first ride that’s online—both the ride itself and its position in the list:
rides |> Enum.find(online?)
rides |> Enum.find_index(online?)
Filtering Elements
Sometimes we just want a clean list of rides that are currently online:
rides |> Enum.filter(online?)
Rejecting Elements
Or the opposite—a list of all rides that are currently not online:
rides |> Enum.reject(online?)
Taking and Dropping While
To reason about ride availability at the top of the list, we can isolate the initial online segment:
rides |> Enum.take_while(online?)
And then examine everything that comes after—starting with the first offline ride:
rides |> Enum.drop_while(online?)
Splitting a List
Rather than taking or dropping, we can split the list at the first offline ride:
rides |> Enum.split_while(online?)
Suggested Rides
```elixir
def suggested_rides(%Patron{} = patron, rides) do
Enum.filter(rides, &suggested?(patron, &1))
end
```
Let’s start by generating some rides and patrons:
tea_cup = FunPark.Ride.make("Tea Cup")
roller_mtn = FunPark.Ride.make("Roller Mountain", min_height: 120)
haunted_mansion = FunPark.Ride.make("Haunted Mansion", min_age: 14)
rides = [tea_cup, roller_mtn, haunted_mansion]
alice = FunPark.Patron.make("Alice", 13, 150)
beth = FunPark.Patron.make("Beth", 15, 110)
Alice is tall enough for Roller Mountain, but not old enough for the Haunted Mansion. Her suggested rides include Tea Cup and Roller Mountain:
alice |> FunPark.Ride.suggested_rides(rides)
Beth meets the age requirement for Haunted Mansion but isn’t tall enough for Roller Mountain. Her suggested rides include Tea Cup and Haunted Mansion:
beth |> FunPark.Ride.suggested_rides(rides)
Later, the wait time for Tea Cup increases, making it no longer eligible for Beth:
tea_cup = FunPark.Ride.change(tea_cup, %{wait_time: 40})
rides = [tea_cup, roller_mtn, haunted_mansion]
beth |> FunPark.Ride.suggested_rides(rides)
Model the FastPass
FastPass Management in the Patron Context
```elixir
def add_fast_pass(%__MODULE__{} = patron, %FastPass{} = fast_pass) do
change(patron, %{fast_passes: List.union([fast_pass], patron.fast_passes)})
end
```
```elixir
def remove_fast_pass(%__MODULE__{} = patron, %FastPass{} = fast_pass) do
change(patron, %{fast_passes: List.difference(patron.fast_passes, [fast_pass])})
end
```
Let’s start by generating a fast pass and a patron:
tea_cup = FunPark.Ride.make("Tea Cup")
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass = FunPark.FastPass.make(tea_cup, datetime)
alice = FunPark.Patron.make("Alice", 13, 150)
Alice can add a fast pass for the Tea Cup ride:
alice = FunPark.Patron.add_fast_pass(alice, fast_pass)
And she can remove it when it’s no longer needed:
alice = FunPark.Patron.remove_fast_pass(alice, fast_pass)
Validity Rules in the FastPass Context
```elixir
def get_ride(%__MODULE__{ride: ride}), do: ride
```
```elixir
def valid?(%__MODULE__{} = fast_pass, %Ride{} = ride) do
Eq.eq?(get_ride(fast_pass), ride)
end
```
Fast Lane Access
```elixir
def fast_pass?(%Patron{} = patron, %__MODULE__{} = ride) do
patron
|> Patron.get_fast_passes()
|> Enum.any?(&FastPass.valid?(&1, ride))
end
```
Let’s start by creating a ride, a fast pass, and a patron:
haunted_mansion = FunPark.Ride.make("Haunted Mansion", min_age: 14)
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass = FunPark.FastPass.make(haunted_mansion, datetime)
alice = FunPark.Patron.make("Alice", 13, 150)
Without a fast pass, Alice is not eligible for a the Haunted Mansion:
alice |> FunPark.Ride.fast_pass?(haunted_mansion)
Let’s give her one:
alice = FunPark.Patron.add_fast_pass(alice, fast_pass)
Now she’s eligible:
alice |> FunPark.Ride.fast_pass?(haunted_mansion)
```elixir
def fast_pass_lane?(%Patron{} = patron, %__MODULE__{} = ride) do
has_fast_pass = curry(&fast_pass?/2).(patron)
is_eligible = curry(&eligible?/2).(patron)
p_all([has_fast_pass, is_eligible]).(ride)
end
```
```elixir
def vip?(%__MODULE__{ticket_tier: :vip}), do: true
def vip?(%__MODULE__{}), do: false
```
```elixir
def curry_r(fun) do
case :erlang.fun_info(fun, :arity) do
{:arity, 1} ->
fn arg -> fun.(arg) end
{:arity, 2} ->
fn arg2, arg1 -> fun.(arg1, arg2) end
end
end
```
```elixir
def fast_pass_lane?(%Patron{} = patron, %__MODULE__{} = ride) do
has_fast_pass_or_vip = p_any([
curry_r(&fast_pass?/2),
fn _ -> Patron.vip?(patron) end
])
is_eligible = curry_r(&eligible?/2)
p_all([has_fast_pass_or_vip, is_eligible]).(ride)
end
```
Let’s regenerate our patrons, a ride, and a fast pass:
alice = FunPark.Patron.make("Alice", 13, 150)
beth = FunPark.Patron.make("Beth", 15, 110)
haunted_mansion = FunPark.Ride.make("Haunted Mansion", min_age: 14)
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass = FunPark.FastPass.make(haunted_mansion, datetime)
We’ll give Alice a fast pass to the Haunted Mansion:
alice = FunPark.Patron.add_fast_pass(alice, fast_pass)
Although Alice has a fast pass, she’s too young to ride the Haunted Mansion. Meanwhile, Beth—who doesn’t have a fast pass—is also ineligible for the fast lane:
alice |> FunPark.Ride.fast_pass_lane?(haunted_mansion)
beth |> FunPark.Ride.fast_pass_lane?(haunted_mansion)
Later, Beth upgrades her ticket. As a VIP, she’s now eligible for the fast lane—even without a fast pass:
beth = FunPark.Patron.change(beth, %{ticket_tier: :vip})
beth |> FunPark.Ride.fast_pass_lane?(haunted_mansion)
Fold Conditional Logic
```elixir
defimpl FunPark.Foldable, for: Function do
def fold_l(pred, true_fun, false_fun) when is_function(pred, 0) do
if pred.() do
true_fun.()
else
false_fun.()
end
end
def foldr(pred, true_fun, false_fun) do
fold_l(pred, true_fun, false_fun)
end
end
```
Let’s start by generating the Tea Cup ride with a 100-minute wait:
tea_cup = FunPark.Ride.make("Tea Cup", online: true, wait_time: 100)
Earlier, our Ride
expert introduced suggested?/1
, a predicate that returns true
for rides that are online and have a wait under 30 minutes. With a 100-minute wait, the Tea Cup doesn’t qualify:
FunPark.Ride.suggested?(tea_cup)
We can fold the predicate result into a string—"Yes"
or "No"
—using fold_l/3
:
yes_or_no = fn val, pred ->
FunPark.Foldable.fold_l(fn ->
pred.(val) end, fn -> "Yes" end, fn -> "No" end) end
This lets us convert branching logic into a single result:
yes_or_no.(tea_cup, &FunPark.Ride.suggested?/1)