Pattern Matching
Mix.install([
{:kino, github: "livebook-dev/kino", override: true},
{:kino_lab, "~> 0.1.0-dev", github: "jonatanklosko/kino_lab"},
{:vega_lite, "~> 0.1.4"},
{:kino_vega_lite, "~> 0.1.1"},
{:benchee, "~> 0.1"},
{:ecto, "~> 3.7"},
{:math, "~> 0.7.0"},
{:faker, "~> 0.17.0"},
{:utils, path: "#{__DIR__}/../utils"}
])
Navigation
Setup
Ensure you type the ea
keyboard shortcut to evaluate all Elixir cells before starting. Alternatively you can evaluate the Elixir cells as you read.
Pattern Matching
We’ve already used pattern matching for matching values in a data structure.
[_, two, _] = [1, 2, 3]
two
We can use pattern matching in a wide variety of other cases.
Pattern Matching With Case
Previously, we talked about how the case statement checks for a match.
case "same" do
"same" -> "this will return"
end
We’re also able to use pattern matching for case statements. The first valid match will execute, and you can use the variables you assign in the instruction.
case [1, 2] do
[] -> nil
[one] -> one
[one, two] -> one + two
end
Your Turn
Trigger the "A"
case. Replace nil
with your answer.
case_match =
case nil do
[_, _, 3] -> "A"
_ -> "default"
end
Utils.feedback(:case_match, case_match)
Pattern Matching Multi-Clause Functions
You’ve already learned about using multi-clause functions with different arities or different guards to create polymorphic behavior.
You can also create multi-clause functions with pattern matching.
defmodule Greeter do
def greet([name1, name2]) do
"Hello, #{name1} and #{name2}"
end
def greet(%{name: name, identity: identity}) do
"Hi #{identity} err..I mean #{name}"
end
def greet(name) do
"Hello, #{name}"
end
end
Greeter.greet("Peter")
Greeter.greet(["Peter", "Bruce"])
Greeter.greet(%{name: "Batman", identity: "Bruce Wayne"})
Your Turn
Use pattern matching to create a Hello.everyone/1
function which only accepts a list with three or more elements.
Remember that you can use the [head | tail]
syntax with a list.
Replace nil
with your pattern match statement.
Hint
[one, two, three | tail] = list
defmodule Hello do
def everyone(nil) do
"Hello, everyone!"
end
end
Utils.feedback(:hello_match, Hello)
Pattern Matching in Anonymous Functions
We can use pattern matching in anonymous functions.
hello = fn
"Peter" -> "Hey Spidey!"
name -> "Hello, #{name}."
end
hello.("Bruce")
hello.("Peter")
Pattern matching becomes seriously powerful when leveraged with the Enum
module.
list = [1, 2, 3]
Enum.map(list, fn
1 -> "one"
int -> int * 2
end)
Now we can enumerate and handle different patterns of data separately.
list = [{:add, 4}, {:subtract, 2}, {:multiply, 2}, 10, %{add: 2}]
Enum.reduce(list, 0, fn
{:add, int}, acc -> acc + int
{:subtract, int}, acc -> acc - int
{:multiply, int}, acc -> acc * int
%{add: int}, acc -> acc + int
int, acc -> acc + int
end)
Pattern Matching Vs. If
Often we have many tools to accomplish the same action. For example, let’s say we’re building an application where users send each other messages. However, only admin users are allowed to send messages.
Using if
, we could write the following.
defmodule Message do
def send(user, message) do
if user.is_admin do
message
else
{:error, :not_authorized}
end
end
end
Let’s say we also need to handle empty messages.
defmodule Message do
def send(user, message) do
if user.is_admin do
if message == "" do
{:error, :empty_message}
else
message
end
else
{:error, :not_authorized}
end
end
end
Message.send(%{is_admin: true}, "")
Nested if
statements are generally a cue that we should consider an alternative implementation.
Let’s see how we could solve this problem with pattern matching.
defmodule Message do
def send(%{is_admin: true}, "") do
{:error, :empty_message}
end
def send(%{is_admin: true}, message) do
message
end
def send(%{is_admin: false}, _) do
{:error, :not_authorized}
end
end
Message.send(%{is_admin: true}, "")
Pattern matching can help reduce the complexity of our control flow.
This implementation uses function clauses, but let’s dive deep into the options we have for how to write this same code.
We could also use a case
statement with pattern matching.
defmodule Message do
def send(user, message) do
case {user, message} do
{%{is_admin: true}, ""} -> {:error, :empty_message}
{%{is_admin: true}, message} -> message
{%{is_admin: false}, _} -> {:error, :not_authorized}
end
end
end
We could even implement a solution with guards.
defmodule Guards do
def send(user, message) when user.is_admin and message == "" do
{:error, :empty_message}
end
def send(user, message) when user.is_admin do
message
end
def send(user) do
{:error, :not_authorized}
end
end
By showing you alternative solutions, we hope to bring your attention to the options you have so that you can make informed decisions about improving code clarity.
We do not hope to tell you that one solution is always better than another. Context matters!
With
with
is often used with pattern matching to create “happy path” code.
It’s useful whenever you have a series of cases or values that rely on each other.
You can use with
to check some preconditions before executing instructions.
flowchart LR
with --> 1
1 --> 2
2 --> 3
3 --> 4
1[pre-condition]
2[pre-condition]
3[pre-condition]
4[instruction]
If any of the preconditions fail, the with statement will stop and return the value of the failed precondition.
flowchart LR
1[pre-condition]
2[pre-condition]
3[pre-condition]
4[instruction]
with --> 1
1 --> 2
2 --> 3
3 --> 4
1 --> 5[failed pre-condition]
2 --> 5
3 --> 5
Alternatively, you can use else
to handle the result of a failed precondition.
flowchart LR
1[pre-condition]
2[pre-condition]
3[pre-condition]
4[instruction]
with --> 1
1 --> 2
2 --> 3
3 --> 4
1 --> 5[failed pre-condition]
2 --> 5
3 --> 5
5 --> 6[else]
Here’s a minimal example with a single precondition. is_admin
must be true to delete a user. We’re using pseudo-code and simply returning the "delete user"
string.
is_admin = true
with true <- is_admin do
"delete user"
end
The with
statement checks is_admin
. If true
, it returns "delete_user"
.
If any other value, it returns the value of variable is_admin
.
flowchart LR
with --> is_admin --> 3["delete user"]
is_admin --> 4[is_admin]
with
uses pattern matching to check if the left side of the <-
matches the right side.
The example above is probably better served using a simple if
statement, so let’s make it
more realistic and store is_admin
in a boolean on a user map.
user = %{is_admin: true}
with true <- user do
"delete user"
end
Because %{is_admin: true}
does not match true
, the with statement returns %{is_admin: true}
.
Let’s correct that.
user = %{is_admin: true}
with %{is_admin: true} <- user do
"delete user"
end
Great! That’s working. But this is still probably better handled by an if
or case
statement.
user = %{is_admin: true}
if user.is_admin do
"delete user"
end
with
is ideal for checking a series of preconditions.
Let’s change our example to sending an email. To send an email, we need to ensure:
- The sending user is an admin.
- The receiving user has an email.
- The email has a title and a body.
We also need the name
of the sender and receiver and their emails.
Here’s how we can handle this with case
. Arguably the
nested case
statements reduce the clarity of the code.
sending_user = %{name: "Batman", email: "notbrucewayne@bat.net", is_admin: true}
receiving_user = %{name: "Robin", email: "boywonder@bat.net"}
email = %{title: "ROBIN!", body: "WE'RE OUT OF BAT SNACKS!"}
case sending_user do
%{is_admin: true, name: sender_name, email: sender_email} ->
case receiving_user do
%{name: receiver_name, email: receiver_email} ->
case email do
%{title: title, body: body} ->
"from #{sender_name}:#{sender_email} to #{receiver_name}:#{receiver_email} #{title}, #{body}"
end
end
end
with
replaces the need for nested case statements.
Here’s the same code using with
. There’s still some natural complexity, but with
improved the code clarity.
sending_user = %{name: "Batman", email: "notbrucewayne@bat.net", is_admin: true}
receiving_user = %{name: "Robin", email: "boywonder@bat.net"}
email = %{title: "ROBIN!", body: "WE'RE OUT OF BAT SNACKS!"}
with %{is_admin: true, name: sender_name, email: sender_email} <- sending_user,
%{name: receiver_name, email: receiver_email} <- receiving_user,
%{title: title, body: body} <- email do
"from #{sender_name}:#{sender_email} to #{receiver_name}:#{receiver_email} #{title}, #{body}"
end
Right now, if a value doesn’t match the precondition, it returns the value. For example,
if the sender is nil
, we return nil
.
sending_user = nil
receiving_user = %{name: "Robin", email: "boywonder@bat.net"}
email = %{title: "ROBIN!", body: "WE'RE OUT OF BAT SNACKS!"}
with %{is_admin: true, name: sender_name, email: sender_email} <- sending_user,
%{name: receiver_name, email: receiver_email} <- receiving_user,
%{title: title, body: body} <- email do
"from #{sender_name}:#{sender_email} to #{receiver_name}:#{receiver_email} #{title}, #{body}"
end
Sometimes we want to return the value. Other times we want to handle the error in an else
block.
sending_user = "batman"
receiving_user = %{name: "Robin", email: "boywonder@bat.net"}
email = %{title: "ROBIN!", body: "WE'RE OUT OF BAT SNACKS!"}
with %{is_admin: true, name: sender_name, email: sender_email} <- sending_user,
%{name: receiver_name, email: receiver_email} <- receiving_user,
%{title: title, body: body} <- email do
"from #{sender_name}:#{sender_email} to #{receiver_name}:#{receiver_email} #{title}, #{body}"
else
error -> "Email not sent because #{error} did not match expected format"
end
You can match multiple cases to handle different errors.
sending_user = %{name: "Joker", email: "joker@jokesonyou.haha"}
receiving_user = %{name: "Robin", email: "boywonder@bat.net"}
email = %{title: "HAHA!", body: "HAHAHAHAHA"}
with %{is_admin: true, name: sender_name, email: sender_email} <- sending_user,
%{name: receiver_name, email: receiver_email} <- receiving_user,
%{title: title, body: body} <- email do
"from #{sender_name}:#{sender_email} to #{receiver_name}:#{receiver_email} #{title}, #{body}"
else
%{name: "Joker"} -> "Get out of here Joker!"
error -> "Email not sent because #{error} did not match expected format"
end
with
statements can use values from previous conditions in future conditions.
triangle = [3, 3, 3]
with [side1, side2, side3] <- triangle, true <- side1 == side2 && side2 == side3 do
"all sides are equal!"
end
Your Turn
Use with
to check that scores
:
- Has two elements
- Each element is an integer.
- Each element is ten or above.
If so, sum the two scores together. If not, return {:error, :invalid}
.
Points.tally([10, 20])
30
Points.tally(10)
{:error, :invalid}
Points.tally([1, 10])
{:error, :invalid}
Points.tally([10, 20, 30])
{:error, :invalid}
defmodule Points do
def tally(scores) do
end
end
Utils.feedback(:with_points, Points)
Pin Operator
The pin operator allows us to use variables as hard-coded values, rather than rebinding a variable.
Often we use the pin operator when testing our code to assert that the value is correct.
For example, the following will rebind the received variable to [1, 2, 3]
.
received = [1, 2]
expected = [1, 2, 3]
received = expected
But instead, we might use the match operator to check that the received value matches the expected value.
received = [1, 2]
expected = [1, 2, 3]
^received = expected
By using the pin operator above, we accomplish the same as if we had written:
[1, 2] = [1, 2, 3]
We can also use this for internal values in a collection. The following is the same
as [1, 2, 3] = [2, 2, 3]
first = 1
actual = [2, 2, 3]
[^first, 2, 3] = actual
And the following is the same as [1, 2, 3] = [1, 2, 3]
first = 1
actual = [1, 2, 3]
[^first, 2, 3] = actual
Your Turn
Use the pin operator to make the following code crash with a MatchError
.
expected = {"hello"}
actual = {"hello", "hi"}
expected = actual
Commit Your Progress
Run the following in your command line from the project folder to track and save your progress in a Git commit.
$ git add .
$ git commit -m "finish pattern matching section"