Taking pattern matching further in Ruby 2.7

aaronc81

Aaron Christiansen

Posted on December 22, 2019

Taking pattern matching further in Ruby 2.7

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']
Enter fullscreen mode Exit fullscreen mode

#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' }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
aaronc81
Aaron Christiansen

Posted on December 22, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related