Powered by AppSignal & Oban Pro

Advanced Pattern Matching

reading/advanced_pattern_matching.livemd

Advanced Pattern Matching

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.8.0", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

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.

Review Questions

Upon completing this lesson, a student should understand:

  • How to achieve polymorphism with multiple function heads and case statements.
  • How to simpify our program’s control-flow using polymorphism.
  • How to use pattern matching with enumeration.

Overview

Polymorphism

In functional programming, polymorphism refers to the ability of program to behave differently under different conditions.

There are many ways to achieve polymorphism in Elixir such as pattern matching, higher-order functions, and multi-clause functions.

In data-based polymorphism, a function can work with multiple types of inputs. For example, the Enum module is polymorphic, because it is data-agnostic and works on any enumerable data structure.

Pattern Matching

Pattern matching with the = coerces the left side of the operator to match the right side.

left = right

We can use this to match on values in the right side of the expression and bind them to variables.

{a, b} = {1, 2}

In addition to binding values, we can use pattern matching to trigger functionality.

For example, when we use pattern matching with a case statement, we can trigger a different case clause depending on the shape of the data.

case {1, 2} -> do
  [a, b] -> "behavior for list" 
  {a, b} -> "behavior for tuple"
end

See the previous reading material if you need a refresher on how to pattern match on each data type.

Pattern Matching With the Match Operator

We can pattern match using the match operator.

{:ok, one} = {:ok, 1}

We’re able to use the match operator in more places than you might think. Anytime we have an Elixir term bound to a parameter we can use the = operator. This is useful for binding the entire parameter of a function while still using pattern matching to match on values within the parameter.

defmodule PatternParamExample do
  def inspect([a, b, c] = param1) do
    IO.inspect(a, label: "a")
    IO.inspect(b, label: "b")
    IO.inspect(c, label: "c")
    IO.inspect(param1, label: "c")
  end
end

PatternParamExample.inspect([1, 2, 3])

Sometimes we use this pattern to validate that a particular parameter is the shape of data that we expect. For example, the following confirms the parameter in the function is a map.

defmodule MapsOnly do
  def inspect(%{} = map) do
    IO.inspect(map)
  end
end

MapsOnly.inspect(%{})

Any non-map data type would crash the function with a FunctionClauseError.

MapsOnly.inspect("this should crash")

We can do the same inside of a case statement, or with other data types such as lists.

case [1, 2, 3] do
  [head | tail] = list ->
    IO.inspect(head, label: "head")
    IO.inspect(tail, label: "tail")
    IO.inspect(list, label: "list")
end

We can use the match operator anytime we have a value bound to a parameter or variable that we want to match on. Pattern matching can also be used with control flow to trigger application behavior based if the pattern matches.

Your Turn

Create a case statement that will return the first element in a 2 element tuple, or the first element in 2 element a list.

Use pattern matching to ensure the Check.must_have_elements!/1 function returns true when called with a list that has more than one element and otherwise causes a FunctionClauseError.

Example Solution

defmodule Check do
  def must_have_elements!([_head | _tail] = list) do
    true
  end
end
defmodule Check do
  @doc """
  Doubles a list

  ## Examples

      iex> Check.must_have_elements!([1, 2, 3])
      true

      iex> Check.must_have_elements!([1])
      true

      iex> Check.must_have_elements!([])
      ** (FunctionClauseError) no function clause matching in Check.must_have_elements!/1
  """
  def must_have_elements!(list) do
    true
  end
end

Pattern Matching in A Function Clause

We can omit the = when pattern matching in a function.

defmodule Coords do
  def inspect({x, y}) do
    IO.inspect(x, label: "x axis")
    IO.inspect(y, label: "y axis")
  end
end

Coords.inspect({1, 2})

Multi-Clause Functions

We can use with pattern matching with multi-clause functions. This essentially using multi-clause functions to replicate the same behavior as a single function with a case statement.

defmodule SingleCaseExample do
  def run(param) do
    case param do
      [] -> "1"
      [_] -> "2"
      [_, _] -> "3"
    end
  end
end

SingleCaseExample.run([]) |> IO.inspect(label: "first")
SingleCaseExample.run([1]) |> IO.inspect(label: "second")
SingleCaseExample.run([1, 1]) |> IO.inspect(label: "third")
defmodule MultiClauseExample do
  def run([]) do
    "1"
  end

  def run([_]) do
    "1"
  end

  def run([_, _]) do
    "1"
  end
end

MultiClauseExample.run([]) |> IO.inspect(label: "first")
MultiClauseExample.run([1]) |> IO.inspect(label: "second")
MultiClauseExample.run([1, 1]) |> IO.inspect(label: "third")

This is often used for advanced control flow.

Anonymous Functions

We can also pattern match in multiple function heads in an anonymous callback function.

anonymous_run = fn
  [] -> "1"
  [_] -> "2"
  [_, _] -> "3"
end

anonymous_run.([]) |> IO.inspect(label: "first")
anonymous_run.([1]) |> IO.inspect(label: "second")
anonymous_run.([1, 1]) |> IO.inspect(label: "third")

Your Turn

Use multi-clause functions to create a Greeter module which says different greetings based on what’s provided as input to the hello/1 function.

defmodule Greeter do
  @moduledoc """
  Greeter
  """

  @doc """
  Return different greetings based on the number of elements in the list provided.

  ## Examples

      iex> Greeter.hello(["Russel"])
      "Hi Russel!"

      iex> Greeter.hello(["Icia", "Stephen"])
      "Hi Icia, Hello Stephen!"

      iex> Greeter.hello(["Swamy", "Jeff", "Jeremy"])
      "Hello everyone!"
  """
  def hello(names) do
  end
end

Pattern Matching in Enumeration

We can combine pattern matching in a function with enumeration to achieve polymorphic behavior with an enumerable data structure.

enumerable = [double: 1, double: 2, triple: 3, quadruple: 4]

Enum.map(enumerable, fn
  {:double, value} -> value * 2
  {:triple, value} -> value * 3
  {:quadruple, value} -> value * 4
end)

The same can be done with other Enum functions that accept a callback function such as Enum.filter/2 and Enum.reduce/3.

enumerable = [add: 1, subtract: 2, add: 4, multiply: 3]

Enum.reduce(enumerable, 0, fn
  {:add, value}, acc -> acc + value
  {:subtract, value}, acc -> acc - value
  {:multiply, value}, acc -> acc * value
end)
enumerable = [keep: 1, remove: 2, keep: 4, remove: 1]

Enum.filter(enumerable, fn
  {:keep, _} -> true
  {:remove, _} -> false
end)

Your Turn

Create a Converter module which uses pattern matching with enumeration and multiple function clauses in an anonymous function to convert the a list of integers into their named string representation.

defmodule Converter do
  @doc """

  """
  def to_named_strings(integers) do
  end
end

Your Turn

Use Enum.map/2 with pattern matching and multi-clause functions to double {:double, integer} tuples and divide (div) {:halve, integer} tuples in the following list.

[{:double, 2}, {:halve, 10}, {:double, 4}] -> [4, 5, 8]

Example Solution

Enum.map([{:double, 2}, {:halve, 10}, {:double, 4}], fn
  {:double, integer} -> integer * 2
  {:halve, integer} -> div(integer, 2)
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 MessageIfExample 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 MessageNestedIfExample 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

MessageNestedIfExample.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 MessageMatchExample do
  def send(%{is_admin: true}, "") do
    {:error, :empty_message}
  end

  def send(%{is_admin: true}, message) do
    {:ok, message}
  end

  def send(%{is_admin: false}, _) do
    {:error, :not_authorized}
  end
end
MessageMatchExample.send(%{is_admin: true}, "")
MessageMatchExample.send(%{is_admin: false}, "Error!")
MessageMatchExample.send(%{is_admin: true}, "Successful!")

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

We’ll also use the pin operator when triggering control flow with pattern matching. For example, we might use it in a case statement.

pinned_value = 1

case {:ok, 1} do
  {:ok, ^pinned_value} -> "clause 1"
  {:ok, generic_value} -> "clause 2"
end

If we don’t pin the value, the bound variable will be treated as though we were re-binding the variable, and the first case clause will always match.

pinned_value = 1

# despite being 2, not 1, clause 1 is triggered because we didn't pin the value.
case {:ok, 2} do
  {:ok, pinned_value} -> "clause 1"
  {:ok, generic_value} -> "clause 2"
end

Your Turn

Use the pin operator to make the following code crash with a MatchError because expected does not match actual, rather than rebinding expected as it is currently doing.

Example Solution

expected = {"hello"}
actual = {"hello", "hi"}

^expected = actual
expected = {"hello"}
actual = {"hello", "hi"}

expected = actual

Further Reading

Consider the following resource(s) to deepen your understanding of the topic.

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

save_name =
  case Path.basename(__DIR__) do
    "reading" -> "advanced_pattern_matching_reading"
    "exercises" -> "advanced_pattern_matching_exercise"
  end

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, save_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(
      progress_path,
      Jason.encode!(Map.put(existing_progress, save_name, completed), pretty: true)
    )
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout -b advanced-pattern-matching-reading
$ git add .
$ git commit -m "finish advanced pattern matching reading"
$ git push origin advanced-pattern-matching-reading

Create a pull request from your advanced-pattern-matching-reading branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your teacher by including @BrooklinJazz in your PR description to get feedback. You (or your teacher) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.

Up Next

Previous Next
Games: Menu Treasure Matching