Skip to content

Latest commit

 

History

History
575 lines (421 loc) · 15.2 KB

advanced_pattern_matching.livemd

File metadata and controls

575 lines (421 loc) · 15.2 KB

Advanced Pattern Matching

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

Navigation

Review Questions

Upon completing this lesson, a student should understand:

  • How to achieve polymorphism with multiple function heads and case statements.
  • How to simplify 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 or a variable 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 on 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 a 2 element 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 pattern matching with multi-clause functions. This essentially uses 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
    "2"
  end

  def run([_, _]) do
    "3"
  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

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 clue 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.

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish Advanced Pattern Matching reading"
$ git push

We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation