Maps, Functions + Pattern Matching = ❤️!
Yo, glad you’re here! This is the third episode of Learn Elixir with a Rubyist! If you haven’t yet, I would recommend you to check the previous episodes to understand what we have discussed so far about Elixir and some of it’s features, on the previous episode we talked more about Actor Model, Modules and functions, what they are, how they work and how to use it, you can check the previous episodes here:
- Episode I - Elixir, Pipe Operator and Pattern Matching
- Episode II - Actor Model, Modules and functions
- Episode III - Maps, Functions + Pattern Matching = ❤️
- Episode IV - Elixir Types, Data Structures and Underscore
- Episode V - Concurrency, Processes and Recursion
- Episode VI - Head + Tail and List Comprehension
- Episode VII - Processes Communication
In this episode we will start to bring things together, now the we already know what Modules and Function are, let’s mix it up with the pattern matching concept and check the results. In order to do it we’ll need to use some of Elixir main data-structures, the Map, so grab a beer and bear with me!
Introduction to Map
Map is one of Elixir main structures, it’s similar to Ruby’s hash but it has some important differences, it basically provides a key: value structure that can have its key as an atom, like this: :atom or string, like this ”string”, both are different and really important to understand, we’ll discuss that in the next episode, but for now let’s just stick with the string one.
Maps can be declared by the following syntax:
%{"key" => "value"}
# or
%{key: "value"}
Pretty simple, but it’ll get way more complex than that further, on next episodes when we discuss Atoms, Structs and other Types and Structures.
Pattern Matching
You probably remember the pattern matching concept we discussed on episode I, if you don’t you should probably read that part again, but let’s check a quick example of what it enable us to do:
# Because of pattern matching you can define variables
# by matching it with patterns from both sides of the equation
[a, b, c] = [5, 9, 13]
IO.puts(a) # => 5
IO.puts(b) # => 9
IO.puts(c) # => 13
# This would also work, because there's still a pattern
[a, 9, c] = [5, 9, 13]
IO.puts(a) # => 5
IO.puts(b) # => 9
IO.puts(c) # => 13
# But this wouldn't work because there is no way of matching
# this pattern. The second elemnt of the array is the integer 9
# not 2
[a, 2, c] = [5, 9, 13]
# ** (MatchError) no match of right hand side value: [5, 9, 13]
As it is, it might look like a silly feature, but it get’s way more interesting than this when this concept is applied to functions and other Elixir features, check this example:
# In Elixir, functions can not exist outside a module
# so let's start by creating an Example module.
defmodule Example do
# A simple function that expects a number and
# doubles it.
#
# You'll realize a difference, on the arguments
# I specify not only the argument's name but also
# its value by using `2 = number`.
#
# It'll apply the same pattern matching concept we saw before,
# but now trying to match `number`(the argument sent) with `2`
def double(2 = number) do
IO.puts "double 2"
number * 2
end
# We'll also declare another `double` function, yes another
# function with the same name! But this one will accept `3`
# as the `number` argumnt
def double(3 = number) do
IO.puts "double 3"
number * 2
end
end
# Let's call our function by passing `2` as the argument
Example.double(2)
# => double 2
# => 4
# Let's call our function by passing `3` as the argument
# now check that it executes the other function we declared
# with the same name but that does accept `3` as argument
Example.double(3)
# => double 3
# => 6
# Now let's try to call it by passing other number as argument
Example.double(4)
# ** (FunctionClauseError) no function clause matching in Example.double/1
# iex:16: Example.double(4)
# And that's an error, it basically means it didn't found
# a function that would match the arguments sent, that
# happens because we only have `double` functions that
# accept `2` or `3` as arguments.
It’s a fun example, but still looks silly, so let’s check a real life case we usually face when developing an application and how we could solve it in Ruby and then in Elixir by using patter matching. Let’s imagine you are developing a forum where users can post topics. In this application any user can post topics, but that are three business rules about it:
- When a regular user tries to create a topic, you want to make sure an admin is notified to approve it or not
- If the user has at least 1 topics created already, then the new topic can be created right away and the admin notified only to check it later
- When an admin creates a topic, there is no need for approval and the topic can be created right away.
Let’s check how this would look like in Ruby:
# Let's start by following and OOP implementation, creating
# a simple Class called Topic that represents a new topic
class Topic
# It will have an user attribute
attr_accessor :user
# Really straightforward only assign @user as the
# argument send upon initialization
def initialize(user)
@user = user
end
# Method responsible for a Topic creation
def create
puts "Starting create method"
# Here we have our first conditional to check if
# the user is and admin or not
if user[:admin]
# If it is, then create the topic right away
do_create_topic
else
# If it isn't, then we need to check if it
# has at least 1 topic created already
if user[:topics_count] > 0
# If it does, then we create the topic and
# notify the admin
do_create_topic
notify_admin
else
# If it has no topics created then we trigger
# a request for admin's approval
request_admin_approval
end
end
end
private
# Private method responsible for creating the topic
def do_create_topic
puts "do_create_topic"
end
# Private method representing the admin notification
def notify_admin
puts "notify_admin"
end
# Private method representing the approval request
def request_admin_approval
puts "request_admin_approval"
end
end
# An admin creating a new topic
admin = { admin: true }
topic = Topic.new(admin)
topic.create
# => Starting create method
# => do_create_topic
# An new user trying to create its first topic
new_user = { admin: false, topics_count: 0 }
topic = Topic.new(new_user)
topic.create
# => Starting create method
# => request_admin_approval
# An user with 1 pre-existing topic trying to create a new one
old_user = { admin: false, topics_count: 1 }
topic = Topic.new(old_user)
topic.create
# => Starting create method
# => do_create_topic
# => notify_admin
The logic looks fine, but those conditionals to check if it’s and admin and has at least one topic created make it way more complex and hard to grasp.
We could do some optimizations, maybe extracting the > 0 topics
logic to a different method, or do even better, by creating two classes an AdminTopic
and UserTopic
, and that can make it looks cleaner and fits even better into a OOP structure, but we would still need to write those conditionals eventually, what s not a problem, it’s just the way we would to solve this issue on Ruby.
Now let’s check how we can tackle this kind of demands on Elixir using pattern matching into functions.
# We start by creating a DataStore module that will keep
# all of data-related functions together
defmodule DataStore do
# This is the first declaration of create method,
# responsible for creating a topic, but it uses pattern
# matching to make sure that the `user` argument has
# a key `"admin"` as `true`
def create(topic, %{"admin" => true} = user) do
IO.puts "Starting create method 1"
do_create
end
# This second declaration also uses pattern matching but
# to make sure the `"admin"` key is `false` and the
# `"topics_count"` is equal `0`
def create(topic, %{"admin" => false, "topics_count" => 0} = user) do
IO.puts "Starting create method 2"
request_admin_approval
end
# Third and last declaration, it only matches the
# `"admin"` key to make sure it's `false`.
#
# The order of the functions here matter, because Elixir
# will try to match it into the order you declared.
#
# In this case if I had declared this third function before
# the second one, it would never match the last function,
# because it would match `%{"admin" => false} = user` first,
# instead of `%{"admin" => false, "topics_count" => 0} = user`.
def create(topic, %{"admin" => false} = user) do
IO.puts "Starting create method 3"
do_create
notify_amdin
end
# Three private methods simulating the internal behaviors
defp do_create do
IO.puts "do_create_topic"
end
defp notify_amdin do
IO.puts "notify_admin"
end
defp request_admin_approval do
IO.puts "request_admin_approval"
end
end
# Fake topic varible to be sent
topic = "Topic Title"
admin = %{"admin" => true}
DataStore.create(topic, admin)
# => Starting create method 1
# => do_create_topic
new_user = %{"admin" => false, "topics_count" => 0 }
DataStore.create(topic, new_user)
# => Starting create method 2
# => request_admin_approval
old_user = %{"admin" => false, "topics_count" => 1 }
DataStore.create(topic, old_user)
# => Starting create method 3
# => do_create_topic
# => notify_admin
Okay let’s dive into the differences between the implementations. When using pattern matching into functions you can declare multiple functions instead of using conditionals, this enable you to have a more clean code, easier to understand and with pretty isolated logic, it also makes the codebase easier to maintain! That’s another key point of Elixir. We will see in further episodes that we can use use pattern matching together with other Elixir structures other then functions, like case or with but let’s not rush into this.
It’s also good to point out how we are still sticking to the rules we defined on previous episodes, making sure we follow the functional paradigm properly. Let’s check some key points about the implementation above:
- You’ll realize that the module name is
DataStore
notTopic
, because creating a new resource is not a topic-related function, but a data-related one. Keep in mind there is no Object on Functional Programming (FP), so functions are gathered by what they do rather than what elements they interact with. - Because there is no objects, there is no initialization methods, as the same way we are used to, therefore we do not send the user first, to later use on other functions like we did on Ruby
Topic.new(admin)
. Remember our second rule, “Functions shouldn’t update nor depend of external variables (other the it’s arguments)”, so all information needed it sent through arguments instead, as you can see on our create functions. - The functions look-up follows the order you declared it, so you need be careful, otherwise you might have a function that’s never called because a previous one is always matched before actually getting there (I mentioned this on the comments as well).
This pattern matching usage is one of the things I like most! Elixir just keeps bringing you a new set of tools that empowers you so much, pushing you to find better ways to have a more clear logic and code in order to sustain maintainability and code quality.
What’s Next?
Well this is the third episode of a series of short bar-like conversations around Elixir, it’s aimed to help mostly Ruby developers trying to understand it. On the next episodes we will take a step back, talk about some basics of Elixir that we will need in the further down the road, we’ll go over its Types, some structures, the tools around documentation and why it’s extremely crucial to Elixir.
If you liked please let me know on the comments bellow and over twitter and keeps me moving forward and motivates me a lot! Thank you for the awesome feedbacks so far!