Guard Clauses – Additional Level of Matching
There is an additional level of pattern matching we haven’t touched on yet. A “guard clause” can be used in a function clause to further define the pattern for a match.
To define a guard clause, we use the keyword when
. This is how the function declaration is defined and where the guard clause goes.
def function_name(arg1) when guard_clause do
# function body
end
Let’s look at a guard clause that checks if the value passed in is an integer.
defmodule Testing do
def greet_integer(value) when is_integer(value), do: "Hello #{value}"
def greet_integer(_other), do: "meh"
end
Testing.greet_integer(123)
#=> "Hello 123"
Testing.greet_integer("Jim")
#=> "meh"
You should note that the is_integer(value)
guard clause includes the bound variable value
from the argument. A guard clause can be used on any bound variable to help define more about the pattern you want.
A guard clause expression must evaluate to true
or false
. It is being used to determine if the function should be executed.
Contents
Guard Clauses and Pattern Matching
Remember there are 3 parts to pattern matching:
- Match the data type
- Match the data shape
- Bind variables to values
Guard clauses can help us with parts 1 & 2.
Guard Clauses Can Help Match Type
A guard clause can be used to help match the data’s type. Let’s say I want to write a function called to_string/1
that converts a value to a string. Thankfully that already exists, but if we wanted to implement something like it, how could we do that with pattern matching? The problem comes when trying to tell the difference between 1
, "1"
, :one
, and 1.0
. Without a guard clause, we can’t define a pattern that says an argument must be an integer, string, atom, float, or some other general data type.
This is where a guard clause can help us. Let’s look at our to_string/1
function we could create.
defmodule Testing do
def to_string(value) when is_binary(value), do: value
def to_string(value) when is_integer(value), do: Integer.to_string(value)
def to_string(value) when is_atom(value), do: Atom.to_string(value)
def to_string(value) when is_float(value), do: Float.to_string(value)
end
Testing.to_string("123")
#=> "123"
Testing.to_string(123)
#=> "123"
Testing.to_string(:one)
#=> "one"
Testing.to_string(12.3)
#=> "12.3"
Notice that the first pattern match tests for is_binary(value)
. An Elixir string is a binary, so this test determines there is nothing to do and returns the value as-is.
The other function clauses test the data type using guard clauses. They use is_integer/1
, is_atom/1
, and is_float/1
.
The full list of supported types can be found on the Kernel module. They are the functions that start with is_*
. Here’s a shorter list to give you a convenient idea.
- is_atom(term)
- is_binary(term)
- is_boolean(term)
- is_float(term)
- is_function(term)
- is_integer(term)
- is_list(term)
- is_map(term)
- is_nil(term)
- is_number(term)
- is_tuple(term)
Let’s do some practice exercises to play with guard clauses and matching data types.
Type Practice Exercises
The following exercises continue using the Pattern Matching project. We will continue focusing on making a single test pass at a time.
The tests we are focusing on are in test/guard_clauses_test.exs
. This first set should go pretty fast for you.
Exercise #1 – GuardClauses.return_numbers/1
Given a variety of data inputs, return the value when it is a number. If not a number, return the atom :error
.
mix test test/guard_clauses_test.exs:20
Exercise #2 – GuardClauses.return_lists/1
In previous exercises, you matched lists where they had various patterns like empty, a single item, at least 1 item, etc. There isn’t a way to just match “if it is a list” without guard clauses. Now you can!
Given a variety of data inputs, return the value when it is a list. If not a list, return the atom :error
.
mix test test/guard_clauses_test.exs:35
Exercise #3 – GuardClauses.return_any_size_tuples/1
In previous exercises, matching tuples was very specific to the number of elements in the tuple. Using guard clauses you can now match if it’s a tuple of any size!
Given a variety of data inputs, return the value when it is a tuple. If not a tuple, return the atom :error
.
mix test test/guard_clauses_test.exs:45
Exercise #4 – GuardClauses.return_maps/1
Given a variety of data inputs, return the value when it is a map. If not a map, return the atom :error
.
TIP: After you have your solution, make sure to check out the hidden solution as it has an extra tip on how this can be done!
mix test test/guard_clauses_test.exs:60
Exercise #5 – GuardClauses.run_function/1
In Elixir, functions are first-class data types. It is common to pass functions as arguments. Using guard clauses you can check that an argument is a function.
Given a variety of data inputs, if the argument is a function, execute the function and return the function’s result. If not a function, return the atom :error
.
mix test test/guard_clauses_test.exs:75
TIP: When a function is passed as an argument, it is treated the same as an anonymous function. This example code shows how to declare an anonymous function with no arguments that returns the value “Hello!” when executed.
Note: to execute an anonymous function, you use a period after the name. You also must include the parenthesis.
greet = fn -> "Hello!" end
greet.()
#=> "Hello!"
Guard Clauses Can Help Match Shape
Guard clauses can also help match the shape of your data. A guard clause can be very helpful in defining a less specific shape for a pattern. We’ve looked at many examples where the pattern defines a shape like matching a specific value.
%User{active: true} = data
Some operators are safe for guard clauses and let us define a range of values to accept in our pattern. Here’s a shortened list of some of the most common and helpful operators.
Safe for Guard Clauses
Let’s look at a reduced set of the functions and operators you can use in guard clauses. We already looked at the “is_*” functions for data types.
- ==
- !=
- +
- –
- *
- /
- <
- <=
- >
- >=
- and
- or
- not
- in
For the full list, see the “Guards” section of Kernel Module documentation.
A particularly interesting operator is the in
operator. In guard clauses it can only work with ranges (ie 1..5
) and lists. Here’s an example of how this can be used in an interesting way.
defmodule TestingGuard do
def place_order(%{status: status} = order) when status in ["pending", "cart"] do
"Placing order for #{order.customer_id}!"
end
def place_order(_order) do
"Not placing order"
end
end
order_1 = %{status: "pending", total: 100, customer_id: 10}
order_2 = %{status: "cancelled", total: 75, customer_id: 12}
TestingGuard.place_order(order_1)
#=> "Placing order for 10!"
TestingGuard.place_order(order_2)
#=> "Not placing order"
Keep in mind that if you had a list with 100,000 items in it, this could impact your application as the in
operator stops when the first match is found, but if the value isn’t in the list, it’s an exhaustive search to determine that the function doesn’t match. But it works great with small, bounded sets.
Likewise, the not
operator can be very helpful. Expressions like value not in [1, 2, 3]
and not is_nil(value)
can be very helpful.
Only a reduced set of operators and functions are allowed to be guard clauses. The BEAM will not allow a function call in a guard clause that creates side-effects. Just imagine side-effects like creating a database record, writing to a file, or making an HTTP call to an external service. Those side-effects would be created while trying to decide if a function clause should be executed. The function clause may not match, the function body doesn’t execute, but the side-effect remains! That would be a horrible buggy system!
To be safe, the BEAM only permits a small set of “known safe” functions to be used in guard clauses. This limited set of functions can still do a lot of powerful work for you.
Practice Exercise #6 – GuardClauses.classify_user/1
Let’s practice using guard clauses to define a pattern for the shape of the data we want.
Given a variety of User structs, we need to classify the user as a legal adult or a minor. For this example we’ll use the US definition of age 18 and older to be a legal adult. A User between the ages of 0 and less than 18 is a minor. We should return an error if given a non-user data type, a nil
age or a negative age. The unit tests cover all these scenarios.
mix test test/guard_clauses_test.exs:85
mix test test/guard_clauses_test.exs:92
mix test test/guard_clauses_test.exs:98
mix test test/guard_clauses_test.exs:105
mix test test/guard_clauses_test.exs:110
Make sure to check out the solution below after you have your own working code!
Custom Guard Clauses
Now is a good time to introduce how you can create your own custom guard clauses. Remember that we are limited in the functions and operations we can use, but we can combine those things together to create helpful, reusable solutions.
Elixir provides a helper command for creating our own custom guard clauses. The defguard
command looks like this:
defguard clause_name(arg1) when guard_clause
Let’s return to the GuardClauses.classify_user/1
example and see how we can improve our solution.
@adult_age 18
defguard is_adult?(age) when age >= @adult_age
defguard is_minor?(age) when age >= 0 and age < @adult_age
def classify_user(%User{age: age} = _user) when is_adult?(age) do
{:ok, :adult}
end
def classify_user(%User{age: age} = _user) when is_minor?(age) do
{:ok, :minor}
end
After defining the guard clause, we can use it in a pattern match!
The value of a custom guard clause is to prevent duplicating business logic in multiple places.
Custom guard clauses help to clarify the intent of a pattern match. The greatest benefit is when it helps define a pattern that has business logic meaning in your application. Especially when you would be repeating that logic in multiple places! If you aren’t reusing a guard clause in multiple places, then it may not be adding value.
There is also a defguardp
command that works similar to defp
, creating a private guard clause that is only available in the defining module.
Import Guard Clauses to Reuse
When you have guard clauses that help define a business logic pattern, you want to be able to reuse them in your application! To do this, you create a module that defines the guard clauses. In our case, like this:
defmodule PatternMatching.User.Guards do
@moduledoc """
Define guard clauses for working with Users.
"""
@adult_age 18
defguard is_adult?(age) when age >= @adult_age
defguard is_minor?(age) when age >= 0 and age < @adult_age
end
All that’s left is to import this module into the modules where we want to use it. Those guard clauses are now available for use in our functions!
defmodule PatternMatching.GuardClauses do
alias PatternMatching.User
import PatternMatching.User.Guards
def classify_user(%User{age: age} = _user) when is_adult?(age) do
{:ok, :adult}
end
def classify_user(%User{age: age} = _user) when is_minor?(age) do
{:ok, :minor}
end
end
Guard Clauses can Match Other Arguments
One powerful aspect of guard clauses is that we can match a piece of data from one argument to another argument’s data. Without guard clauses, we couldn’t do this in a function clause pattern match. Let’s look at an example to help visualize what we are talking about.
defmodule Testing do
def compare_args(arg1, arg2) when arg1 == arg2, do: :equal
def compare_args(arg1, arg2) when arg1 > arg2, do: :greater_than
def compare_args(arg1, arg2) when arg1 < arg2, do: :less_than
end
Testing.compare_args(1, 1)
#=> :equal
Testing.compare_args(2, 1)
#=> :greater_than
Testing.compare_args(1, 2)
#=> :less_than
Any variable we bind in one argument can be compared to any value bound for another argument! Without guard clauses, this would be done inside a function using an if
statement. With guard clauses, we are able to keep the filtering of the data on the boundary of the function clause. This lets us keep the function body straight-forward and clear.
Practice Exercise #7 – GuardClauses.award_child_points/3
In this final exercise, we will conditionally award a user additional points if they are within a desired age range. If the user matches the age pattern, increase their points and return an updated user struct. If the user does not match the pattern, return the user unmodified.
There are two tests to focus on.
mix test test/guard_clauses_test.exs:125
mix test test/guard_clauses_test.exs:130
Recap
Guard clauses add another powerful layer to pattern matching in Elixir. We covered a lot here, it’s worth taking a moment to mention some of the highlights to keep in mind:
- Guard clauses further define a pattern for data type and shape.
- Custom guard clauses make reusable business patterns easy to use.
- Guard clauses allow us to define a pattern that combines multiple function arguments.
Additional Resources on Guard Clauses
You can find more resources on guard clauses here:
- https://hexdocs.pm/elixir/1.11.3/patterns-and-guards.html#guards – Higher-level documentation on how guards work, what can be used in them, how they fail, etc.
- https://hexdocs.pm/elixir/Kernel.html#guards – Kernel module documentation on guard clauses. This is the list of functions and operations defined on the Kernel Module that are safe to be used in guard clauses.
- https://hexdocs.pm/elixir/Kernel.html?#defguard/1 – Documentation on
defguard
command
6 Comments
Comments are closed on this static version of the site.
Comments are closed
This is a static version of the site. Comments are not available.