Pattern Matching in Ruby
I recently realised that despite many new additions to the Ruby language and ecosystem, I've never really had an opportunity to take advantage of many of them. Of course, some new language features are more useful than others, particularly when it comes to maintaining code as a team, but what is also interesting is that they also support less conventional, or immediately-apparent, use-cases.
The first part of this series of posts is all about Pattern Matching.
Pattern matching
Ruby's pattern matching support, introduced experimentally in 2.7, is a lot more powerful than you may expect. All you need is to replace when
with in
and your case
statements become capable of matching against anything.
require 'base64' class ParsedJson; end; def handle_response(response) case response in { code: (300..399) } redirect_to response.headers[:location] in { status: :unauthorized | :forbidden => status } raise NotAllowedError.new(status) in { code: 200, body: ParsedJson => payload } Service.call(payload) in { code: 200, body: String => text, content_type: 'application/base64' } Base64.decode64(text) end end handle_response({ code: 200, body: 'aGVsbG8gd29ybGQ=', content_type: 'application/base64' }) #=> 'hello world' handle_response({ code: 301 }) # => redirect handle_response({ status: :forbidden, code: 403 }) # NotAllowedError (forbidden)
Custom destructuring
You can deeply match any object in Ruby so long as you define a method to represent it as a hash, or a method to represent it as an array. Or both.
class PlayingCard attr_reader :value, :colour, :suit def initialize(value:, colour:, suit:) @value = value @colour = colour @suit = suit end def deconstruct [value, colour, suit] end def deconstruct_keys(*) { value: value, colour: colour, suit: suit } end end
This PlayingCard
class is now capable of pattern matching.
def face_card?(playing_card) case playing_card in { value: 'K' | 'Q' | 'J' } then true else false end end face_card?(PlayingCard.new(value: '3', colour: :red, suit: :spades)) #=> false
Pinning
That's fairly basic, what about pattern matching poker? Matching one card is easy, but suppose you want to match a hand.
class PokerHand attr_reader :cards def initialize(cards: []) @cards = cards end def deconstruct cards end end
Now that a hand of cards is represented, it should be possible to use pattern matching to find a winning play, say… a Royal Flush. For this to work, variable pinning is required, because a Royal Flush requires the colour and suit to be the same for each card.
This particular solution depends on the hand being ordered, but that's fine, a lot of computational problems become simpler if you sort them first. For the sake of example, assume that has already happened.
def royal_flush?(hand) case hand in [['1', c, s], ['10', ^c, ^s], ['J', ^c, ^s], ['Q', ^c, ^s], ['K', ^c, ^s]] true else false end end # alternatively, if golfing in Ruby 3: # def royal_flush?(hand) = !!(hand in [['1', c, s], ['10', ^c, ^s], ['J', ^c, ^s], ['Q', ^c, ^s], ['K', ^c, ^s]] rescue false) my_hand = PokerHand.new(cards: [ PlayingCard.new(value: '1', colour: :black, suit: :hearts), PlayingCard.new(value: '10', colour: :black, suit: :hearts), PlayingCard.new(value: 'J', colour: :black, suit: :hearts), PlayingCard.new(value: 'Q', colour: :black, suit: :hearts), PlayingCard.new(value: 'K', colour: :black, suit: :hearts), ]) royal_flush?(my_hand) # => true
The clever bit here is that the first part of the match ([1, c, s]
) is used to constrain the rest of the pattern. So if c
is :red
, then ^c
also has to be :red
in order to match.
Pattern guards
You'll see this a lot if you're familiar with Elixir or other languages that do pattern matching well. Essentially, you can add conditional logic to your patterns so that a match is only possible if a separate condition is met.
Building on the poker example, maybe it's valid to play the Joker, but only if the dealer has allowed it?
def joker_allowed? true end def valid_call?(card) case card in [:Joker, *] if joker_allowed? puts 'joker allowed' true else true end end valid_call?(PlayingCard.new(value: :Joker, colour: nil, suit: nil)) # => joker allowed # => true
Destructuring assignment without case
One of the odd side-effects of this pattern matching functionality is that you get a new kind of assingment. In fact, in Ruby 3 this gets a syntax of its own with the rightward assignment operator, but you can still use something similar in 2.7.
In fact, this method also allows you to use pattern matching while destructuring. It's not so easy on the eyes, however, as the variable bindings are actually inside the pattern, and not the expression on the left-hand side.
You also have to be absolutely sure you're matching the right thing.
card = PlayingCard.new(value: '7', suit: :diamonds, colour: :red) card in { value: ('1'..'10') => v, suit: :diamonds => s} # v => '7' # s: :diamonds begin card in { value: String, suit: Symbol } rescue NoMatchingPatternError puts 'son, I am disappoint' end
Optimisations
If you recall earlier examples, I defined destructure_keys(*)
, which meant that I was explicitly ignoring the arguments normally passed to the method. This is useful in simple cases, but when dealing with complex objects you might want to be a bit more thoughtful about how you return a value. For example, converting the entire structure of the object into a hash might not be appropriate.
# When used in pattern matching, this class will only destructure into the provided keys class PokerHand def deconstruct_keys(keys) cards.map { |card| card.slice(keys) } end end
—
Well, this doesn't cover the entirety of Ruby's pattern matching fun, but it should at least show you the various things you're now able to do with the feature. If in doubt, RTFM1; Ruby's documentation is absolutely fantastic.
Specifying 'rubydoc' in your Google searches should reveal Ruby's official documentation and not the SEO spam that is ApiDock.
Check in soon to see another deep-dive into Ruby Sorcery.