Taking pattern matching further in Ruby 2.7
Aaron Christiansen
Posted on December 22, 2019
With Ruby 2.7 just around the corner, and release candidate 2 available now, I was eager to try the new pattern matching feature that has been introduced. As with anything in Ruby, I wanted to see how I could push this feature to its limits.
Note that this is not an introduction to Ruby 2.7's pattern matching; make sure you're already familiar with its purpose and how to use it. The release notes summarise this very well!
How does pattern matching work?
There are two main methods involved with the behaviour behind pattern matching, #deconstruct
and #deconstruct_keys
, which are used for array-style matches and hash-style matches respectively.
#deconstruct
returns an array representing the object which can be matched against. For the Array
class, this is simply aliased to #itself
, as no conversion needs to be done here. Instances of Struct
define #deconstruct
as an array of the field values.
[1, 2, 3].deconstruct # => [1, 2, 3]
Person = Struct.new(:name, :favourite_food)
Person.new('Aaron', 'Pizza').deconstruct # => ['Aaron', 'Pizza']
#deconstruct_keys
is similar, except it returns a hash rather than an array. Additionally, #deconstruct_keys
takes an array of Symbol keys as an argument, indicating which keys of the hash must be returned. It is permitted to a hash with more keys than specified (Hash#deconstruct_keys
is aliased to #itself
so returns all keys) but the additional keys won't be used for the pattern match.
{ a: 3, b: 4 }.deconstruct_keys([:a, :b]) # => { a: 3, b: 4 }
Person = Struct.new(:name, :favourite_food)
me = Person.new('Aaron', 'Pizza')
me.deconstruct_keys([:name]) # => { name: 'Aaron' }
me.deconstruct_keys([:name, :favourite_food]) # => { name: 'Aaron', favourite_food: 'Pizza' }
As the case
may be...
I was surprised to learn that you don't actually need to use the in
operator for pattern matching within a case
statement; you can use it anywhere! This can be used to unpack structs quite elegantly:
Person = Struct.new(:name, :favourite_food)
me = Person.new('Aaron', 'Pizza')
me in { name: my_name, favourite_food: my_favourite_food }
my_name # => 'Aaron'
my_favourite_food # => 'Pizza'
You can even use it as a slightly esoteric form of assertion, as a pattern which doesn't match will raise a NoMatchingPatternError
. I'm not sure I'd recommend this, but it certainly works!
x = 3
x in 3 # Fine!
x in 2 # Raises NoMatchingPatternError
In its most basic form, you can even use pattern matching as a simple assignment. I wonder if DSLs could make use of this?
2 in y
y # => 2
A methodical approach
This was inspired by Brandon Weaver's excellent post about pattern matching. Being able to match against objects which define #deconstruct
or #deconstruct_keys
is great, but what about any other objects where properties are behind methods?
It's really easy to create a wrapper around any object which will dynamically invoke methods on it when #deconstruct_keys
is called, allowing any object's zero-argument methods to be used in pattern matching!
Let's create an example of how this will help us first. I'll define a simple data class:
class Rectangle
def initialize(width, height)
@width = width
@height = height
end
attr_accessor :width, :height
end
Rectangle#width
and Rectangle#height
are methods which will retrieve the properties of any instances of Rectangle
. Unfortunately, pattern matching doesn't know how to match against instances of this class, as it doesn't contain a #deconstruct_keys
definition:
rect = Rectangle.new(2, 5)
rect in { width: w, height: h } # Raises NoMatchingPatternError
Let's define a class which can wrap any object in to allow method calls to be made from a pattern match. I'll call it SendMatch
:
class SendMatch
def initialize(target)
@target = target
end
def deconstruct_keys(keys)
keys.to_h { |key| [key, @target.send(key)] }
end
end
How this works is actually really simple. When constructed, it takes a single object and stores a reference to it as an instance variable @target
. Then, when #deconstruct_keys
is called, it invokes each the methods with the names of each requested key on @target
and creates a hash from the results.
This effectively lets pattern matching work on any object!
rect = Rectangle.new(2, 5)
SendMatch.new(rect) in { width: w, height: h }
w # => 2
h # => 5
We could make this even more concise by monkey-patching Object
, so classes without a definition of #deconstruct_keys
will automatically call methods instead:
class Object
def deconstruct_keys(*args)
SendMatch.new(self).deconstruct_keys(*args)
end
end
rect in { width: w, height: h }
w # => 2
h # => 5
I hope this has given you a good insight into how pattern matching can be used in creative ways in Ruby 2.7! Remember that pattern matching is still an experimental feature, so any of these behaviours could change at any time.
I'd love to hear about how you're using pattern matching to streamline your Ruby code!
Posted on December 22, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.