Initial Thoughts on Ruby Pattern Matching

Tue Jul 2, 2019
~800 Words

This post represents a first attempt to evaluate the new pattern matching feature in Ruby 2.7 with a view to determining what use cases it might be a good fit for, both in general and in the context of Ruby.

Thanks to @mametter for corrections to this post.

Use case: processing JSON

This is perhaps the most directly useful application of pattern matching that I could find. Taken from this slide.

Given the following JSON data:

json = '{
  "name": "Alice",
  "age": 30,
  "children": [
    {
      "name": "Bob",
      "age": 2
    }
  ]
}
'

We parse it, and want to extract the age of Bob, who is the child of Alice:

person = JSON.parse(json, symbolize_names: true)

case person
in {name: "Alice", children: [{name: "Bob", age: age}]}
  p age #=> 2
end

An equivalent implementation without use of pattern matching, for comparison:

person = JSON.parse(json, symbolize_names: true)

if person[:name] == "Alice"
  children = person[:children]

  if children.length == 1 && children[0][:name] == "Bob"
    p children[0][:age] #=> 2
  end
end

The pattern matching implementation is more terse, and overall I find it easier to read. I like the simultaneous matching and extraction of values.

A more complex example–the following JSON has been heavily snipped but still demonstrates the ease with which data can be matched and extracted from multiple levels of a data structure:

json = <<~JSON
  [
  {
    "id": 1,
    "body": "I'm having a problem with this.",
    "user": {
      "login": "octocat"
    },
    "labels": [
      {
        "name": "bug"
      }
    ],
    "milestone": {
      "description": "Tracking milestone for version 1.0",
      "creator": {
        "login": "octocat",
        "site_admin": false
      },
      "open_issues": 4,
      "closed_issues": 8,
      "due_on": "2012-10-09T23:39:01Z"
    },
    "locked": true,
    "active_lock_reason": "too heated",
    "comments": 0,
    "repository": {
      "name": "Hello-World",
      "owner": {
        "login": "octocat"
      },
      "private": false,
      "topics": [
        "octocat",
        "atom",
        "electron",
        "api"
      ],
      "has_issues": true,
      "has_projects": true,
      "has_wiki": true,
      "has_pages": false,
      "has_downloads": true
    }
  }
]
JSON

We now parse the data and extract the topics, only if certain values are matched. First with pattern matching:

data = JSON.parse(json, symbolize_names: true)

case data
in [{id: 1, repository: {has_issues: true, owner: {login: 'octocat'}, topics: topics}}]
  puts topics.inspect
end

And then a (possible) implementation without pattern matching:

if data.dig(0, :id) == 1 && data.dig(0, :repository, :has_issues) && data.dig(0, :repository, :owner, :login) == 'octocat'
  puts data.dig(0, :repository, :topics).inspect
end

As with the simpler example above I find the pattern matching syntax to be much clearer. As the complexity of the data increases the advantage would similarly increase.

Example: Fibonacci

From this StackOverflow answer:

Haskell:

fib 0 = 1
fib 1 = 1
fib n | n >= 2
  = fib (n-1) + fib (n-2)

Ruby with pattern matching:

def fib(n)
  case n
  in 0
    1
  in 1
    1
  in Integer => n if n >= 2
    fib(n-1) + fib(n-2)
  else
    raise ArgumentError
  end
end

(0..4).each { |n|
  puts fib(n)
}

# puts fib(2.0) # ArgumentError
# puts fib(-1) # ArgumentError

Equivalent implementation without pattern matching:

def fib(n)
  return 1 if [0, 1].include?(n)

  raise ArgumentError unless Integer === n && n >= 2

  fib(n-1) + fib(n-2)
end

Example: FizzBuzz

From this reddit post:

Any number divisible by three is replaced by the word Fizz and any divisible by five by the word Buzz. Numbers divisible by 15 become FizzBuzz.

(1..100).map do |n|
  case [n.modulo(3), n.modulo(5), n]
  in [0, 0, _]
    "FizzBuzz"
  in [0, _, _]
    "Fizz"
  in [_, 0, _]
    "Buzz"
  in [_, _, n]
    n
  end
end

Elixir-style pattern matching

From this blog post:

case File.open("hello.txt", [:write]) do
  {:ok, file} ->
    IO.write(file,"hello world")
    File.close(file)

  {:error, error} ->
    IO.puts("Error openings the file: #{inspect error}")
end

By definining a wrapper around File.open we can implement this in Ruby as:

def open_file(path)
  f = File.open(path, 'r')

  [ :ok, f ]
rescue Errno::ENOENT => e
  [ :error, e ]
end

case open_file('missing.txt')
in [ :ok, f ]
  puts 'File found'
in [ :error, e ]
  puts e.message
end

# Prints "No such file or directory @ rb_sysopen - missing.txt"

Conclusions

  • As more standard types gain deconstruct support the feature will become more useful.
  • Pattern matching is essentially about deconstruction: if you do not need to deconstruct the value, or it does not support deconstruction, then pattern is perhaps not the right choice.
  • Simultaneous matching and extraction of data from nested data structures (e.g. JSON) appears to me to be the strongest use case.

General Notes

  • Match on Array is an exact match, match on Hash is a subset match, as this matches the general use cases. reference
  • The design of pattern matching has a long history stretching back to 2012. reference
  • Just a few types have desconstruct/deconstruct_keys methods defined: (Array, Hash, Struct). reference

Main References