Pattern Matching in Ruby 2.7.0

Zawartość

Pattern Matching in Ruby 2.7.0

What is Pattern Matching?

Kazuki Tsujimoto described it in his presentation at RubyKaigi 2019 as

Pattern matching consists of specifying patterns to which some data should conform and then checking to see if it does and deconstructing the data according to those patterns — Learn You a Haskell for Great Good! (Miran Lipovaca)

In other words, with pattern matching we obtain specific parts of data selected on specified rules.

In his presentation we can also find a sentence, that for Rubyists pattern matching is a case/when with a multiple assignment.

Basics and syntax

case expression
in pattern [if | unless condition]
  # here goes the code
in pattern [if | unless condition]
  # here goes the code
else
  # here goes the code
end

Just like in normal case, the patterns are checked in sequence until the first one that matches. It means that if we use a multi-pattern matching, we have to take care about the order of patterns.

As we can see from the syntax in the example, pattern can be followed by a guard expression. The guard expression is considered only if the preceding pattern matches.

If the pattern is not found, the else clause will be executed. In case when there’s no pattern nor else clause found, it will raise the NoMatchingPatternError exception.

Patterns

In patterns we can use all Ruby literals: Booleans, nil, Numbers, Strings, Symbols, Arrays, Hashes, Ranges, Regular Expressions and Procs.

Value pattern

In a value pattern the condition for matching is pattern === object

case 1
in Integer
  "Reached Integer"
in -1..1
  "Reached -1..1"
end

# => "Reached Integer"

As I mentioned before, we have to be aware of the order of the patterns because the sequence will stop on the first match.

case 1
in -1..1
  "Reached -1..1"
in Integer
  "Reached Integer"
end

# => "Reached -1..1"

Variable pattern

This pattern matches any passed value and assigns it to a variable. Here’s the example:

case "value"
in variable
  variable
end

# => "value"

We can also use a variable pattern with arrays to deconstruct the array:

numbers_array = [0, 1]

case numbers_array
in [a, b] # you can omit the brackets and simply put here a, b
  puts "a equals: #{a}, b equals: #{b}"
end

# => a equals: 0, b equals: 1

Or underscore to don’t care:

case ["Hey", "User"]
in _, b
  puts "Hello #{b}"
end

# => Hello User

---------------------

case ["Hi", "User"]
in _, b
  puts "Hello #{b}"
end

# => Hello User

---------------------

case ["hey", "hi"]
in _, _
  puts "Hello :-)"
end

# => "Hello :-)"

Keep in mind that if you defined a variable before and you’ll use it in pattern matching, then this variable will be overwritten.

a = 0
p "Outer value: #{a}"
case 1
in a
  p "Inner value: #{a}"
end
p "Outer value: #{a}"

# Output

# => "Outer value: 0"
# => "Inner value: 1"
# => "Outer value: 1"

When we need to match against the variable we defined before, there is a solution. Variable pattern matching provides the pin operator ^ known from Elixir. By using this pattern with the pin operator — matcher will not overwrite the variable and will use the value of the variable.

Let’s consider two scenarios:

  1. The variable and the case have the same value.
  2. The variable and the case have different values
def match(input)
  a = 0
  p "Outer value: #{a}"
  case input
  in ^a
    p "Inner value: #{a}"
  else
    p "No matching for this case."
  end
  p "Outer value: #{a}"
end

# the "a" variable and the input have the same value
match(0)

# => "Outer value: 0"
# => "Inner value: 0"
# => "Outer value: 0"

# the "a" variable and the input have different values
match(1)

# => "Outer value: 0"
# => "No matching for this case."
# => "Outer value: 0"

Alternative pattern

The alternative pattern achieves a match when any of patterns matches.

case "Dog"
in "Dog" | "Cat" | "Rabbit"
  "It's an animal!"
end

# => "It's an animal!"

----------------------

case 4
in 1..2 | 2...4 | 4..5
  "4 victory!"
end

# => "4 victory!"

As pattern

In this pattern the value is assigned to a variable only if the pattern matches.

class Cat; end
case Cat.new
in Cat => variable
  variable
end

# => #<Cat:0x00005648606f24e0>

We can use it, for example, to pick a specific part of a complex object:

nested_array = [0, [1, 2, 3]]

case nested_array
in [0, [1, 2, 3] => inner_array]
  p inner_array
end

# => [1, 2, 3] # inner_array value

Array pattern

Before the examples, we have to discuss the rules that this pattern has. Firstly, the name of this pattern is a little bit misleading. Array patterns are used not only for array objects. Secondly, as Kazuki Tsujimoto described it, the array pattern matches if:

  • Constant === object returns true . Constant can be an
    Array(1, 2, ...) , an Object[1, 2, ...] or [1, 2, ...] ,
  • The object has a #deconstruct method that returns Array,
  • The result of applying the nested pattern to object.deconstruct is true.

The simplest example of where all patterns are correct is:

case [0, 1, 2]
in Array(0, 1, 2)
in Object[0, 1, 2]
in [0, 1, 2]
in 0, 1, 2
end

So with array pattern we can deconstruct arrays:

case [1, 2, 3]
in Array(a, b, c)
  puts a, b, c
end

# => 1
# => 2
# => 3

Or get one value from it:

case [1, 2, 3]
in 1, a, 3
  a
end

# => 2

Splat operator * can be helpful with arrays of unknown size or when we want to pick only a part of the array.

case [1, 2, 3]
in [*a] # *a will also do the job
  a
end

# => [1, 2, 3]

------------------------

case [1, 2, 3]
in 1, *a
  a
end

# => [2, 3]

The best thing is that we can combine the patterns!

case [1, 2, 3]
in Object[1, 2, 3] => a
  a
end

# => [1, 2, 3]

As I mentioned before, array pattern can be used for other objects, for example: Structs. Let’s say that we have a Struct called Desk and it responds to the .deconstruct method.

Desk = Struct.new(:width, :height, :length)
p Desk[60, 120, 160].deconstruct

# => [60, 120, 160]

We can use array pattern in this case.

desk = Desk.new(60, 120, 160)
case desk
in Desk[w, h, l]
  "#{w} x #{h} x #{l}"
end

# => "60 x 120 x 160"

There’s one more thing I have to notice here. Array pattern is an exact match by default. It will check both values and the structure of data.

case [1, 2]
in [a]
  "This point can't be reached :("
end

1: from (irb):1 NoMatchingPatternError ([1, 2])

Tuple matching with array pattern

Tuples are common in Elixir language. They can be described as a list that can hold any value, e.g. {"John", 25} . This tuple can be matched, so assigned to variables from the left side of =, like this:

{name, age} = {"John", 25}

Tuples are commonly used in Elixir to return a result from a function call. We can call a function and get the {:ok, result} or {:error, errors} tuples as a result. What if we would like to do the same in Ruby? Let’s check.

I created a simple User class with authorization method that checks if the provided password matches with the user password — let’s skip the implementation details. Our user is John Doe and his password is foobar. Now we have to create a method for tuple matching:

def match(input)
  case input
  in :ok, *message
    puts message
  in :error, message
    puts "Error! #{message}"
  end
end

And check the results. Matching with valid password:

input = user.login("foobar") # user implementation is skipped
match(input)

# Output
# => Logged in successfully!
# => Name: John
# => Surname: Doe

Matching with invalid password:

input = user.login("1234")
match(input)

# Output
# => Error! Invalid password!

Looks like we can do tuple matching in more or less the same way as in Elixir. :)

Hash pattern

The situation with hash pattern is quite similar to that of the array pattern. Hash patterns are not only for hash objects and also have rules. This pattern matches if:

  • Constant === object returns true . In this case Constant can be a Hash(one: "one", two: "two", ...) , an Object(one: "one", two: "two", ...) or { one: "one", two: "two", ... },
  • The object has a #deconstruct_keys method and this method returns Hash,
  • The result of applying the nested pattern to object.deconstruct_keys(keys) is true.

The simplest example of where all patterns are correct is:

case { x: 1, y: 2}
in Hash(x: 1, y: 2)
in Object[x: x, y: y]
in { x: x }
in y:  # y: is a syntactic sugar for y: y
end

I’m sure you have already noticed that I used y: here. Why the x: value is not used? The answer is: because it’s not necessary. The hash pattern is a subset match. It means that we can achieve a match with only a subset of fields in pattern.

case { name: "Bob", age: 21, city: "Toronto" }
in name:
  name
end

# => "Bob""

In array pattern we used a splat operator to grab a bigger part of data.In this pattern we also have the possibility to do that — the double splat operator **.

case { name: "Bob", age: 21, city: "Toronto" }
in name:, **the_rest
  the_rest
end

# => {:age=>21, :city=>"Toronto"}

Hash pattern will work with any object that responds to #deconstruct_keys method. Let’s test it! We’ll create a simple Struct named Person.

Person = Struct.new(:name, :age, :city, keyword_init: true) do
  def deconstruct_keys(keys)
    {
      name: name, age: age, city: city
    }
  end
end

Let’s see how hash pattern will behave in this case.

person = Person.new(name: "John", age: 21, city: "Tokyo")
case person
in name:, age:, city:
  puts name, age, city
end

# => "John"
# => 21
# => "Tokyo"

It works pretty well. I was really curious what exactly was sent to the deconstruct_keys method as keys parameter. As you can guess, it was (:name, :age, :city). But, what if we will use the double splat operator and check the keys? Let’s add the p keys at the beginning of #deconstruct_keys method and see what’s the value.

Person = Struct.new(:name, :age, :city, keyword_init: true) do
  def deconstruct_keys(keys)
    p keys
    {
      name: name, age: age, city: city
    }
  end
end
person = Person.new(name: "John", age: 21, city: "Tokyo")
case person
in name:, **the_rest
  the_rest
end

# Output

# nil
# => {:age=>21, :city=>"Tokyo"}

The keys parameter contains nil. This behavior is also described in Kazuki Tsujimoto presentation, but for me it’s a little bit strange. I would expect that the keys variable contains at least the :name key because I asked for it.

When we’re using a double splat operator in a hash pattern, we also have to keep in mind that we should return all key-value pairs in deconstruct_keys method. It makes no sense to ask for the rest of the hash if we don’t provide that, right? 😄

There’s one more thing about this pattern. What should {} match? Hash pattern is a subset match so we suppose it will match any hash object. On the other hand, we might expect that {} will match only with empty hash object. Both ways seem correct, depending on what we need. However, Ruby does it in the second way.

def match_with_empty_hash_pattern(object)
  case object
  in {}
    "Matched"
  else
    "Not matched"
  end
end

match_with_empty_hash_pattern({})
# => "Matched"

match_with_empty_hash_pattern({a: 1})
# => "Not matched"

match_with_empty_hash_pattern({a: 1, b: 900.12, c: "Hello"})
# => "Not matched"

JSON example

Since we went through all the patterns, we can start with a more practical example and convince ourselves — if you’re not convinced yet — that pattern matching is awesome.

Thanks to SWAPI we have a structure like this.

{
  "name":"Millennium Falcon",
  "passengers":"6",
  "pilotConnection":{
     "pilots":[
        {
           "name":"Chewbacca",
           "species":{
              "name":"Wookiee"
           }
        },
        {
           "name":"Han Solo",
           "species":{
              "name":"Human"
           }
        },
        {
           "name":"Lando Calrissian",
           "species":{
              "name":"Human"
           }
        },
        {
           "name":"Nien Nunb",
           "species":{
              "name":"Sullustan"
           }
        }
     ]
  }
}

Let’s store it in a file and load it into a variable.

require 'json'

json = File.read('input.json')

Now we can go to exercise. I know that Chewbacca was a pilot of the Millennium Falcon, but I forgot which race he belongs to… Fortunately, I can get this information from the file using matching pattern!

case JSON.parse(json, symbolize_names: true)
in pilotConnection: { pilots: [{name: "Chewbacca", species:}, *rest_of_array]}
  p species
end

# Output

# => {:name=>"Wookiee"}

Fantastic! Now I recall that he’s a Wookiee! But wait… If I just wanted to get the information about Chewbacca, then why I also added the *rest_of_array? Let’s delete that unnecessary part.

case JSON.parse(json, symbolize_names: true)
in pilotConnection: { pilots: [{name: "Chewbacca", species:}]}
  p species
end

# Output

{:name=>"Millennium Falcon", :passengers=>"6", :pilotConnection=>{:pilots=>[{:name=>"Chewbacca", :species=>{:name=>"Wookiee"}}, {:name=>"Han Solo", :species=>{:name=>"Human"}}, {:name=>"Lando Calrissian", :species=>{:name=>"Human"}}, {:name=>"Nien Nunb", :species=>{:name=>"Sullustan"}}]}} (NoMatchingPatternError)

Oops, we get the NoMatchingPatternError. It’s because the Array pattern is an exact match, as we discovered before, so we have to handle the rest of the array too.

Chewbacca was a first element of pilots array, so it was an easy peasy to match his data. What if I would like to get the same information about Lando Calrissian? Let’s check it.

case JSON.parse(json, symbolize_names: true)
in pilotConnection: { pilots: [*pilots_before, {name: "Lando Calrissian", species:}, *pilots_after]}
  p species
end

# => syntax error, unexpected *
...Lando Calrissian", species:}, *pilots_after]}

It will not work this way. So how can I get this desired information? I’ve played around a bit and I haven’t found any nice solution using pattern matching, so I went plain ruby.

case JSON.parse(json, symbolize_names: true)
in pilotConnection: { pilots: }
  lando = pilots.detect { |pilot| pilot[:name] == 'Lando Calrissian' }
  p lando.dig(:species, :name)
end

# => "Human"

As we can see, pattern matching is not a solution for all problems. However, it’s still an easier and more pleasant way to handle JSON data than an army of conditional statements. At least for me.

Work in progress

Let’s go back to the hash pattern. Have you noticed that in every single example I’ve used symbol as a key? It’s because this pattern doesn’t support the non-symbol keys at the moment. Why? It’s not a trivial problem, so once again I will refer to Kazuki Tsujimoto presentation, where you can find a nice explanation.

Another thing is that combining some patterns with guard expression can result with strange behavior. Literally, we can check the value of a variable even if the match failed.

case [1, 2]
in [a, b] if b == 3
  "Not reached"
in [c, d]
  "Reached"
end
p a, b

# Output

# => "Reached"
# => 1   # a value
# => 2   # b value

The last thing I’ve noticed is that we can’t use complex expressions (e.g. method calling) in patterns. Let’s define a Cat class and a cat object.

class Cat
  def meow
    "Meow!"
  end
end
cat = Cat.new

Sanity check before we do a pattern matching.

cat.meow === "Meow!"
# => true

When we use a cat.meow in pattern matching, it will throw a syntax error. To see the error we have to run the code from a file.

class Cat
  def meow
    "Meow!"
  end
end
cat = Cat.new

case "Meow!"
in cat.meow
  "Reached"
end
~/projects/ruby-pattern-matching$ ruby meow.rb
meow.rb:9: syntax error, unexpected '.', expecting `then' or ';' or '\n'
  in cat.meow
meow.rb:11: syntax error, unexpected `end', expecting end-of-input

Of course we can achieve the match with a little change in code.

meow = cat.meow
case "Meow!"
in meow
  "Reached"
end

# => "Reached"

Pattern matching is a great and powerful tool that we can use in many, many, many ways. I really enjoyed testing its power. However, at the end of the day we have to remember one thing that irb told me a billion times.

(irb):1: warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

Thank you for reading this article. I hope you enjoyed it. Feel free to leave a comment and start a discussion!