Ruby Pattern Matching
Pattern Matching
is finally coming to Ruby
in version 2.7
, and I’m very excited for it to be one more tool in the toolbelt of us Rubyists.
You might or might not already know what Pattern Matching
is; either way, this post is meant to give a very simple crash-course or go-to reference.
Kazuki Tsujimoto presented very straightforward definitions in RubyKaigi 2019:
Pattern matching consists of specifying patterns to which some data should conform and then checking to see if it does and deconstructing the data according to those patterns — Learn You a Haskell for Great Good! (Miran Lipovaca)
Especially for Rubyists, he stated:
It’s basically “case/when” in conjunction with “multiple assignment”
Syntax
case expr
in pattern [if|unless condition]
...
in pattern [if|unless condition]
...
else
...
end
- Like
case
,patterns
are run in sequence until there is a match NoMatchingPatternError
is raised, if no match and noelse
clause exists- When using guards, the
guard
expression is evaluated if the precedingpattern
matches
Here is a very simple example:
case [0, 1]
in [a, b] unless a == b
:match
end
#=> :match
Patterns
VALUE PATTERN
A value pattern
matches an object
such that pattern === object
. The best definition for thesubsumption operator (===)
I could find is:
“If I have a drawer labelled pattern would it make sense to put object in that drawer?”
case 0
in 0
:match
end
#=> :matchcase 0
in -1..1
:match
end
#=> :matchcase 0
in 3..6
:no_match
end
#=> NoMatchingPatternError (0)case 0
in Integer
:match
end
#=> :matchcase 0
in String
:no_match
end
#=> NoMatchingPatternError (0)
VARIABLE PATTERN
When using a variable pattern
it binds the variable
to the value
that matches the pattern.
case 0
in a
a
end
#=> 0
A variable pattern
always binds the variable
even if you have anouter variable
with the same name.
a = 0
case 1
in a
a
end
#=> 1
To overcome this, use^
:
a = 0
case 1
in ^a # this translates to: `in 0`
:no_match
end
#=> NoMatchingPatternError
Using _
can be useful to drop/skip values
:
case [0, 1]
in [_, a]
a
end
#=> 1
ALTERNATIVE PATTERN
An alternative pattern
matches if any of the patterns
matches.
case 0
in 0 | 1 | 2
:match
end
#=> :match
AS PATTERN
An as pattern
can be used to assign the value
that matches the pattern
that lives on the left side of the hash rocket (=>)
, to the variable
that lives on the right side.
case [0, 1]
in [0, a => b]
b
end
#=> 1
ARRAY PATTERN
Here are some basic array pattern
examples:
case [0, 1, 2]
in [0, a, 3]
:no_match
end
#=> NoMatchingPatternError ([0, 1, 2])case [0, 1, 2]
in [0, a, 2]
a
end
#=> 1case [0, 1, 2]
in [0, *tail]
tail
end
#=> [1, 2]
There is syntactic sugar that allows us to do:
case [0, 1, 2]
in Array(0, a, 2)
a
end
#=> 1case [0, 1, 2]
in Object[0, a, 2]
a
end
#=> 1
We can even omit the brackets []
:
case [0, 1, 2]
in 0, a, 2
a
end
#=> 1
Actually, array pattern
is not solely for Array
objects.
An array pattern
will also match if object
has a #deconstruct
method that returns Array
, and pattern
matches the Array
that object.deconstruct
returns.
Here is an example with Struct
:
class Struct
def deconstruct
to_a
end
endColor = Struct.new(:r, :g, :b)color = Color[0, 10, 20]
color.deconstruct
#=> [0, 10, 20]
In the following example, pattern
will need to match color.deconstruct
which results in [0, 10, 20]
.
case color
in Color[0, 0, 0]
:no_match
in Color[255, 0, 0]
:no_match
in Color[r, g, b]
"#{r}, #{g}, #{b}"
end
#=> "0, 10, 20"
HASH PATTERN
Here are some basic hash pattern
examples:
case {a: 0, b: 1, c: 2}
in {a: 0, b: b, c: 3}
:no_match
end
#=> NoMatchingPatternError ({:a=>0, :b=>1, :c=>2})case {a: 0, b: 1, c: 2}
in {a: 0, b: b, c: 2}
b
end
#=> 1case {a: 0, b: 1, c: 2}
in {a: 0, **rest}
rest
end
#=> {:b=>1, :c=>2}
There is syntactic sugar that allows us to do:
case {a: 0, b: 1, c: 2}
in Hash(a: 0, b: b, c: 2)
b
end
#=> 1case {a: 0, b: 1, c: 2}
in Object[a: 0, b: b, c: 2]
b
end
#=> 1
And b:
is equivalent to b: b
:
case {a: 0, b: 1, c: 2}
in {a:, b:, c:}
b
end
#=> 1
We can even omit the curly brackets {}
:
case {a: 0, b: 1, c: 2}
in a: 0, b: b, c: 2
b
end
#=> 1case {a: 0, b: 1, c: 2}
in a:, b:, c:
b
end
#=> 1
One big difference that hash pattern
has with array pattern
, is that hash pattern
matches subsets of Hashes
, while array pattern
matches exact Arrays
.
case {a: 0, b: 1, c: 2}
in b:
b
end
#=> 1
Actually, hash pattern
is not solely for Hash
objects.
A hash pattern
will also match if object
has a #deconstruct_keys
method that returns Hash
, and pattern
matches the Hash
that object.deconstruct_keys(keys)
returns.
Here is an example with Date
:
class Date
def deconstruct_keys(keys)
{year: year, month: month, day: day}
end
end
The keys
argument that deconstruct_keys
receives is the set of keys present in the pattern
.
In the implementation above, we are not actually leveraging the keys
argument. But, it’s very important thatdeconstruct_keys
is implemented as efficiently as possible to improve the overall efficiency of case
. Therefore, here is a smarter implementation where only the required keys are deconstructed:
class Date
VALID_KEYS = %i(year month day) def deconstruct_keys(keys)
if keys
(VALID_KEYS & keys).each_with_object({}) do |k, h|
h[k] = send(k)
end
end
end
endtoday = Date.today
today.deconstruct_keys([:year])
#=> {year: 2019}
If **rest
is specified in the pattern
, nil
is passed in the keys
argument. In that scenario, we need to return all key-value pairs. Let’s add an else
clause:
class Date
VALID_KEYS = %i(year month day) def deconstruct_keys(keys)
if keys
(VALID_KEYS & keys).each_with_object({}) do |k, h|
h[k] = send(k)
end
else
{year: year, month: month, day: day}
end
end
endtoday = Date.today
today.deconstruct_keys(nil)
#=> {year: 2019, month: 9, day: 1}
Finally, in the following example, pattern
will need to match today.deconstruct_keys([:year])
which results in {year: 2019}
.
case today
in year:
year
end
#=> 2019
Conclusion
It becomes obvious how this is going to come in handy. It becomes easier to deconstruct complex objects and work with their data. This will help us reduce the complexity of the code and increase its maintainability.
Things might still change around Pattern Matching
; but don’t worry, Ruby
makes sure to remind this to us whenever we use it:
warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!