Skip to content

Latest commit

 

History

History
532 lines (406 loc) · 8.96 KB

article.md

File metadata and controls

532 lines (406 loc) · 8.96 KB

Ruby

Pattern Matching

Was experimental in Ruby 2.7. But is stable since 3.0. Some feature where also added and stable in later versions.

from Ruby doc:

Pattern matching is a feature allowing deep matching of structured values by checking the structure and binding the matched parts to local variables.

In other words: data validation and extraction.

⚠️ It has nothing to do with String nor Regexp but with data strutures, like Array and Hash (but not limited to)

Patterns and expressions

They are 4 different patterns you can use

  • Value
  • Array
  • Hash
  • Find (Stable in 3.2)

Note: Any pattern can be nested inside array/find/hash pattern

And two kind of expressions

case/in

case <expression>
in <pattern1>
  ...
in <pattern2>
  ...
in <pattern3>
  ...
else
  ...
end

Note: Note that in and when branches can NOT be mixed in one case expression

standalone expression with => or in operators

<expression> => <pattern>

<expression> in <pattern>

👮‍♀️

case/in and => expression are exhaustive.

NoMatchingPatternError is raised if no match

Note: Unless there is an else branch for case/in.

checking 🔍

Value pattern

case 1
in Integer
  "it's an int"
in String
  "it's a string"
else
  "something else"
end
#=> "it's an int"

It behaves like case/when and use the === operator

It is meant to be mixed with other patterns

Reminder

Integer === 1 #=> true
1 === Integer #=> false
1 === 1 #=> true
"hello there!" === "hello world!" #=> false, like `==`
(1..10) === 5 #=> true
/^Hello/ ===  "Hello there!" #=> true

Array pattern

case [1, 2]
in [Integer, Integer]
  "matched"
else
  "not matched"
end
#=> "matched

pattern matches the whole Array

Hash pattern

case { a: 1, b: 2, c: 3 }
in { a: Integer }
  "matched"
else
  "not matched"
end
#=> "matched"

matches even if there are other keys

(except for {})

case { name: "Riri", age: 14 }
in { name: String, age: 18.. }
  "and adult"
else
  "not an adult"
end
#=> "not an adult"

Syntaxic sugar FTW 🍭

parenthesis can be omitted

case [1, 2]
 in Integer, Integer
   "matched"
 else
   "not matched"
 end

 case { a: 1, b: 2, c: 3 }
 in a: Integer
   "matched"
 else
   "not matched"
 end

Find pattern

similar to array pattern but it can be used to check if the given object has any elements that match the pattern

experimental since 3.0 ; stable in 3.2

contacts = [
  { name: "Riri" },
  { name: "Fifi" },
  { name: "Feel Good Inc.", company: true },
  { name: "Loulou" }
]
case contacts
in [*, { company: true }, *]
  "at least one company"
else
  "no company at all"
end
#=> "at least one company"

Rest

Both array and hash patterns support rest operator

case [1, 2, 3]
in [Integer, *]
  "matched"
else
  "not matched"
end
#=> "matched"
case { a: 1, b: 2, c:  3 }
in { a: Integer, ** } # same with or without
  "matched"
else
  "not matched"
end
#=> "matched"

if there should be no other keys

case { a: 1, b: 2, c:  3 }
in { a: 1, **nil }
  "matched"
else
  "not matched"
end
#=> not matched

Alternative pattern

case { a: 1, b: 2, c: 3 }
in { a: 1, **nil } | { b: 2, c: 3 }
  "matched"
else
  "not matched"
end
#=> matched

Note: Simply said: combining patterns

binding 🔗

Note: Besides checks, a very important features of pattern matching is binding of matched parts to local variables.

Basic form

<pattern> => <local variable>

case [1, 2]
in Integer => int, Integer
  "matched: #{int}"
else
  "not matched"
end
#=> "matched: 1"
case { a: 1, b: 2, c: 3 }
in a: Integer => int
  "matched: #{int}"
else
  "not matched"
end
#=> "matched: 1"

Note: The basic form of binding is just specifying => variable_name after the matched (sub)pattern (one might find this similar to storing exceptions in local variables in a rescue ExceptionClass => var clause):

More syntaxic sugar ! 🍬

case [1, 2]
in int, Integer
  "matched: #{int}"
else
  "not matched"
end
#=> "matched: 1"
case { a: 1, b: 2, c: 3 }
in a: int
  "matched: #{int}"
else
  "not matched"
end
#=> "matched: 1"

Note: ⚠️ It does not work with alternative pattern

case [1, 2, 3]
in int, *rest
  "matched: #{int}, rest: #{rest}"
else
  "not matched"
end
#=> "matched: 1, rest: [2, 3]"
case { a: 1, b: 2, c: 3 }
in a: int, **rest
  "matched: #{int}, rest: #{rest}"
else
  "not matched"
end
#=> "matched: 1, rest: { :b => 2, :c => 3 }"

=> / in operators

Note: Same behavior but much shorter syntax for simpler use case

<expression> in <pattern>
# true or false
<expression> => <pattern>
# nil or raise NoMatchingPatternError

in as condition

if current_user in { admin: true }
  "user is admin"
else
  "lambda user"
end

=> as guard clause

def do_action
  current_user => { admin: true }

rescue NoMatchingPatternError
  redirect_to root_path, alert: "nope!"
end

Note: I also see as a way to explicitly write expected "hash schema", and avoiding unclear undefined method [] for NilClass

Checking and extracting (inspired from real example)

def extract_full_name_parts(extracted_line)
  extracted_line => { full_name: String => full_name }
  full_name.match(regexp) => { last_name:, first_name: }

  # stuff with `last_name` and `full_name` local variables
rescue NoMatchingPatternError
  extracted_line.error!("full_name is missing or incomplete")
end

variable pinning 📌

Note: Due to the variable binding feature, existing local variable can not be straightforwardly used as a sub-pattern:

expectation = 18

case [1, 2]
in expectation, *rest
  "matched. expectation was: #{expectation}"
else
  "not matched. expectation was: #{expectation}"
end
# expected: "not matched. expectation was: 18"
# real: "matched. expectation was: 1" -- local variable just rewritten
expectation = 18
case [1, 2]
in ^expectation, *rest
  "matched. expectation was: #{expectation}"
else
  "not matched. expectation was: #{expectation}"
end
#=> "not matched. expectation was: 18"

advanced/real life examples 🧠

case find_registered_contact(contact_attributes)
in Contact => contact
  { _other_attrs:, contact_id: contact.id }
in ContactToCreate[String => tmp_contact_id, _line]
  # contact does not exist yet and needs to be created
  { _other_attrs:, tmp_contact_id: }
end

pinning extracted variable within pattern

case { riri: "Duck", fifi: "Duck", loulou: "Duck" }
in { riri: last_name, fifi: ^last_name, loulou: ^last_name }
  "They are all brothers, from #{last_name} family"
else
  "They are not from the same family"
end
#=> "They are all brothers, from Duck family"

if/unless 🤯

extract_company_name = false

contacts_data = [
  { name: "Feel Good Inc.", company: true },
  { name: "Riri", last_name: "Duck" },
  { name: "Fifi", last_name: "Duck" },
  { name: "Loulou", last_name: "Duck" },
  { name: "Charlie" }
]
case contacts_data
in [*, { company: true, name: }, *] if extract_company_name
  "company: #{name}"
in [*, { name:, **nil }, *] if name == "Charlie"
  "I found Charlie!"
in [*, { company: true }, *]
  "at least one company present"
else
  "no company found"
end
#=> "I found Charlie!"

Pattern matching on other kind of objects

use deconstruct / deconstruct_keys

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

  def deconstruct
    puts "deconstruct called"
    [@x, @y]
  end

  def deconstruct_keys(keys)
    puts "deconstruct_keys called with #{keys.inspect}"
    { x: @x, y: @y }
  end
end
case Point.new(1, -2)
in px, Integer  # sub-patterns and variable binding works
  "matched: #{px}"
else
  "not matched"
end
# deconstruct called
#=> "matched: 1"
case Point.new(1, -2)
in x: 0.. => px
  "matched: #{px}"
else
  "not matched"
end
# deconstruct_keys called with [:x]
#=> "matched: 1"

deconstruct implementations

More

Pattern Matching pattern syntax