Messing Around with Ruby Pattern Matching

tl;dr Ruby pattern matching is really cool.

When I saw a list of Ruby’s upcoming 2.7 features, one of ’em jumped out at me: pattern matching. It’s marked as experimental for 2.7, but based on the level of polish already present, and the way it seamlessly fits into idiomatic Ruby, I won’t be surprised to see this experiment land.

Pattern matching has been around for a long time. Prolog and Haskell have had it forever. Clojure has a couple libraries for it. Recently, Elixir and Rust have seemed to further popularize the idea. I know in the little bit of Rust coding I’ve got up to that pattern matching is ubiquitous. It made me curious to see how Ruby’s implementation worked.

In a simple case, you can use in as a form of destructuring assignment. It’s a little weird though: the right-hand side lists the local variable names being set, which is the complete opposite of how assignment works in virtually every other context in every other mainstream language. For example:

[1, 2, 3] in [a, b, c]

While that will directly return nil (seems to be more statement than expression), if you check the values of a, b, and c, you’ll see 1, 2, and 3, respectively.

It also works with hashes.

{a: 1, b: 2, c: 3} in {a: a, b: b, c: c}

There’s also a shorthand for that to save some typing.

{a: 1, b: 2, c: 3} in {a:, b:, c:}

Interestingly, pattern matching inside hashes seems restricted to symbol keys. Not sure if that’s a long-term problem, or just part of the experimental status. That is, the following does not work (it’s a syntax error):

{"a" => 1, "b" => 2, "c" => 3} in {"a" => a, "b" => b, "c" => c}

Nesting works, which seems like a boon for rooting around in JSON structures.

h = {a: [{b: [1, 2, 3]}, {b: [4, 5, 6]}, {b: [7, 8, 9]}]}
h in {a: [_, {b: [_, x, _]}, _]}
x # => 5

Splats work too.

words = %w[foo bar baz qux]
words in [_, *middle, _]
middle
["bar", "baz"]

Pattern matching really shines in case statements, where they’ve been integrated beautifully. Note that it’s just a matter of using the in keyword instead of when.

words = %w[foo bar baz]
case words
in [a, b] then (a + b + "?").downcase
in [a, _, c] then (a + c + "!").upcase
else "nope"
end
"FOOBAZ!"

You can get up to some type-checking shenanigans with the hash rocket.

list = ["foo", 3]
case list
in [String => str, repeat] then str * repeat
in [Integer, *_] => nums then nums.sum
end
"foofoofoo"

And changing the list variable from the previous example to activate the other branch:

list = [1, 2, 3]
case list
in [String => str, repeat] then str * repeat
in [Integer, *_] => nums then nums.sum
end
6

Where it makes sense, you can even get your own objects in on the fun. If there’s a reasonable array representation of the object, implement the deconstruct method. Take the following example Point class.

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def deconstruct
    [x, y]
  end
end

Now this will work:

[Point.new(3, 4), [5, 6]].map do |point|
  case point
  in [x, y]
    "(#{x}, #{y})"
  end
end
["(3, 4)", "(5, 6)"]

But if you want to get more type strict, you can type hint it:

[Point.new(3, 4), [5, 6]].map do |point|
  case point
  in Point[x, y]
    "(#{x}, #{y})"
  end
end
["(3, 4)", nil]

And you can also get hash-style pattern matching too, with the deconstruct_keys method:

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def deconstruct
    [x, y]
  end

  def deconstruct_keys(keys)
    {x: x, y: y}
  end
end

In action:

[Point.new(3, 4), {x: 5, y: 6}].map do |point|
  case point
  in {x:, y:}
    "(#{x}, #{y})"
  end
end
["(3, 4)", "(5, 6)"]

The type-strict version (note that it’s wrapped in parens, not curly braces):

[Point.new(3, 4), {x: 5, y: 6}].map do |point|
  case point
  in Point(x:, y:)
    "(#{x}, #{y})"
  end
end
["(3, 4)", nil]

With hash pattern matching, only a subset of the possible keys needs to be specified. The requested keys are what gets passed in via the keys parameter. You can use that to save the possible work you need to do to satisfy the request. Here, I’ll simply extend the toy example to give the gist.

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def deconstruct
    [x, y]
  end

  def deconstruct_keys(keys)
    {x: x, y: y}.slice(*keys)
  end
end

And in use:

p = Point.new(3, 4)
case point
in y: ❶
  y
end
4

I’ll just note via ❶ that it is possible to elide the wrapping curly braces when matching a hash. Similarly, it’s possible to leave off the square brackets when matching against arrays.

I’m very intrigued by the possibilities presented by Ruby’s new pattern-matching feature. It may only be experimental, but the API already feels pretty well thought-out to me. The breadth and depth of the feature is already impressive in these early days; hats off to the Ruby core team. Hopefully I get the opportunity to use it in some real code soon.

This slide deck provided a lot of the information I used to drive my experiments while working on this post.

Questions? Comments? Contact me!

Tools Used

Ruby
2.7.0-rc2