Pattern matching is a fundamental feature in Elixir, making your code efficient, readable, and intuitive and easing your cognitive load during development overall. If you’re looking for a way to replace the traditional if
statements, pattern matching offers developers a smart and elegant way to do so.
Pattern matching in Elixir works with diverse data structures. Be it dealing with lists, tuples, or maps … so, let’s take a closer look!
Pattern Matching: What it is and how to use it
Pattern matching in Elixir is the process of comparing a value against a pattern. It is used to destructure data structures and bind variables to their contents. Let’s look at some simple examples:
match?(1, 1)
This is probably the most simple (and boring) example of a pattern match. When executed in iex, the result is true
.
match?/2
will “match” the two parameters given to it, that is, determine if they are “the same”, and return true
if that is the case and false
otherwise. The interesting question is, of course, what “the same” means. Let’s try something that surely is not “the same”.
And sure enough, 1 is not “the same” as 2.
At its core, that is what pattern matching is. Compare two things and do something if they are “the same”.
Let´s continue with more complex examples:
Matching a Simple Tuple:
{a, b} = {1, 2}
IO.puts(a) # Outputs
1 IO.puts(b) # Outputs 2
In this example, the tuple {1, 2}
is matched against the pattern {a, b}
, binding a
to 1
and b
to 2
.
Matching Head and Tail of a List:
[h | t] = [1, 2, 3]
IO.puts(h) # Outputs 1
IO.inspect(t) # Outputs [2, 3]
Here, [h | t]
matches the head of the list to h
and the tail to t
.
Matching with Maps:
%{name: name, age: age} = %{name: "Alice", age: 30}
IO.puts(name) # Outputs "Alice"
IO.puts(age) # Outputs 30
This example matches the keys :name
and :age
in the map, binding their corresponding values to name
and age,
respectively.
With that in mind, a definition of what pattern and pattern matching are can be made:
A pattern describes the structure of data.
Pattern matching is the process of determining if a concrete value is structured as described by a given pattern.
The Power of Pattern Matching
Pattern matching is crucial in control flow structures like the case
statement, allowing for more readable and maintainable code. The case
statement evaluates the expression and matches it against various patterns. Here’s an example:
case {1, 2, 3} do
{1, x, 3} -> "Matched: #{x}"
_ -> "No match"
end
# Outputs "Matched: 2"
In this example, the tuple {1, 2, 3}
is matched against the pattern {1, x, 3}
, binding x
to 2 and returning “Matched: 2”. If the pattern doesn’t match, the fallback clause _
handles it, returning “No match”.
Match as Case
Pattern matching in Elixir can be used instead of conventional branching logic, such as if
or case
. This reduces complexity and enhances readability.
Consider this function, which deduces potential responses based on a user’s status:
def respond_to_user(%{status: :active}) do
"Welcome back!"
end
def respond_to_user(%{status: :inactive}) do
"Please activate your account."
end
def respond_to_user(_) do
"Unknown user status."
end
Instead of using a series of if-else
statements, the function bodies with different patterns handle various cases based on user status.
Variable Binding
As mentioned above, variable binding is another powerful aspect of pattern matching. It binds values extracted from complex data structures directly to variables:
Matching a tuple:
{a, b} = {3, 4}
IO.puts(a) # Outputs 3
IO.puts(b) # Outputs 4
Binding elements from a map:
%{name: person_name, age: person_age} = %{name: "Alex", age: 25}
IO.puts(person_name) # Outputs "Alex"
IO.puts(person_age) # Outputs 25
Pattern matching simplifies conditionally assigning variables and makes the code cleaner and more intuitive.
Monads Made Easy
What is a Monad?
In programming, a Monad is a design pattern used to handle a variety of computational contexts, such as dealing with nullability, handling state, or consistently managing side effects. While Elixir does not have monads in the same way as some other functional languages (like Haskell), it offers constructs that achieve similar goals, namely, clean chaining of operations and handling of results.
Elixir’s with
statement simplifies complex nested operations by allowing you to chain multiple pattern matches and assign operations sequentially. Let’s see an example:
Suppose you need to fetch user data, verify the user’s status, and authorize them – a typical flow in many applications. Here’s how you can do it using with
:
with
{:ok, user} <- fetch_user(user_id),
{:ok, verified_user} <- verify_user(user),
{:ok, authorized_user} <- authorize_user(verified_user) do
{:ok, authorized_user}
else
error -> {:error, error}
end
In this example:
fetch_user/1
attempts to fetch a user and returns either{:ok, user}
or an error tuple.verify_user/1
returns{:ok, verified_user}
or an error tuple.authorize_user/1
returns{:ok, authorized_user}
or an error tuple.
If any of these functions return an error, the error -> {:error, error}
clause in else
will handle it. This construct allows you to sequence operations and make your code more linear and easier to follow.
Chaining Operations
Elixir’s pattern matching coupled with the with
statement helps in chaining operations seamlessly. This ensures a smooth flow of data transformations without deeply nested code blocks.
Consider a scenario where you perform a series of operations on a list of numbers, such as doubling each number and then summing the results. You can chain these operations like so:
def process_numbers do
numbers = [1, 2, 3]
doubled = Enum.map(numbers, fn x -> x * 2 end)
sum = Enum.reduce(doubled, 0, &(&1 + &2))
sum
end
Alternatively, using the with
construct here enhances clarity when dealing with intermediate steps or potential errors:
def process_numbers(numbers) do
with
doubled <- Enum.map(numbers, &(&1 * 2)),
sum <- Enum.reduce(doubled, 0, &(&1 + &2)) do
sum
else
_ -> {:error, "Processing failed"}
end
The with
construct provides a structured yet flexible way to manage sequences of operations, ensuring explicit error handling and streamlined data flow.
Using with
and chaining techniques help to keep Elixir code concise and improve readability, making complex logic more straightforward to manage and debug.
Functions in Elixir
Multiple Function Clauses
One of the strengths of Elixir is its support for multiple function clauses, allowing developers to define clear, concise, and readable functions to handle different scenarios. This is often accomplished using pattern matching directly in function definitions.
Single Function Definition
Typically, functions are written to handle general cases, often resulting in complex nested conditionals. However, with multiple function clauses, each function variant can be as simple or complex as needed, directly addressing specific patterns.
Multiple Function Clauses with Pattern Matching
Elixir allows you to define multiple function clauses that match against different patterns. This feature is particularly useful when handling different cases explicitly without complex conditionals. Here’s an example of a function that greets users based on their status:
def greet(%{status: :active, name: name}) do
"Welcome back, " <> name <> "!"
end
def greet(%{status: :inactive, name: name}) do
"Please activate your account, " <> name <> "." end def greet(_) do "Hello, guest!"
end
In this example:
- The first
greet
function clause matches a map with the key:status
set to:active
and extracts the user’s name, returning a personalized welcome message. - The second
greet
function clause matches a map with the key:status
set to:inactive
and also extracts the user’s name, prompting them to activate their account. - The third
greet
function clause is a default case that matches any other inputs, providing a generic greeting.
Recursive Functions with Multiple Clauses
Elixir’s support for recursion works seamlessly with multiple function clauses. This combination simplifies loops and repetitive tasks. Consider a function that sums all elements in a list:
def sum_list([]) do
0
end
def sum_list([head | tail]) do
head + sum_list(tail)
end
In this example:
- The first
sum_list
function matches an empty list and returns0
. - The second
sum_list
function matches a non-empty list, decomposes it intohead
andtail
, and recursively sums the elements.
Error Handling with Multiple Function Clauses
Multiple function clauses can also streamline error handling by defining specific behaviors for different error cases. Consider a function that parses configuration options:
def parse_config(%{key: value}) when is_integer(value) do
{:ok, value}
end
def parse_config(%{key: value}) when is_binary(value) do
{:ok, String.to_integer(value)}
end
def parse_config(_) do
{:error, "Invalid configuration"}
end
In this example:
- The first
parse_config
function clause matches a map with an integer value and returns it as{:ok, value}
. - The second
parse_config
function clause matches a map with a string value, converts it to an integer, and then returns it. - The third
parse_config
function clause is a fallback that handles any other cases, returning an error tuple.
You can create efficient, clean, and expressive functions that naturally handle various cases in Elixir by utilising multiple function clauses. This makes code easier to read and maintain while adhering to functional programming principles. So let’s go!
You want to learn more about Elixir and what it has to offer? How about taking a look at Elixir Sigils?
Ariadne Engelbrecht
Ari joined Inspired in 2022 and currently works as a Software Engineer. She loves the software industry – whether it's theory, coding or volunteering in a congress.
Alptuğ Dingil
Alptuğ joined Inspired in 2022 as a software engineer. Besides his customer projects he's always looking for a new challenge. So lately he got engaged with Kubernetes and the configuration of a DIY cluster and got certified as a Google professional cloud architect.