Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Pattern Matching

pattern_matching.livemd

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

Return Home Report An Issue

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 &amp;&amp; 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"