class: center, middle # Practical Use Cases for Pattern Matching in Ruby 2.7 ### Stefan Magnuson / @styrmis --- # Overview - (Briefly) What is Pattern Matching? - How pattern matching works in Ruby - A few practical use cases - The (possible) future of pattern matching in Ruby --- # Quick segue: New Features in Ruby - What features did you look forward to using after their announcement? -- - Is Pattern Matching one of them? -- - Nobody mention the pipeline operator please ༼ ͒ ̶ ͒༽ --- # Features I wanted to use - Keyword arguments (Ruby 2.0) - `Hash#dig` and `Array#dig` (Ruby 2.3) - Keyword argument support for `Struct` (Ruby 2.5) - Branch support for code coverage (Ruby 2.5) --- # Features I thought I might use but haven't - `yield_self` (2.5) / `then` (2.6) - Function composition, e.g. `(f >> g).(x)` == `g(f(x))` (2.6) -- - Which in some ways is like Elixir's pipeline operator -- - Don't mention the pipeline operator ༼ ͒ ̶ ͒༽ --- # And then randomly... ```ruby puts 1..10 |> map {|n| n.clamp(3, 9) }.inspect ``` ```ruby [3, 3, 3, 4, 5, 6, 7, 8, 9, 9] ``` - Thanks to Andy Croll's recent post on this --- # What is Pattern Matching? ### In short, it's a way to *match on* and optionally *extract* parts of complex objects -- ### Leads to concise notation for complex matches (including extraction) -- ### Later we'll briefly look at some examples in Elixir and Haskell --- class: center, middle ## "Pattern matching is a form of dispatch based on the *shape* of the value" --- # Pattern Matching in Ruby - Proposed by Kazuki Tsujimoto - First PoC published in February 2012 - First discussion with Matz in September 2016 - Released in Ruby 2.7.0-preview1 in May 2019 - Following slides borrow from [1] [1] https://speakerdeck.com/k_tsj/pattern-matching-new-feature-in-ruby-2-dot-7 --- ## Ruby Pattern Matching Overview - Value pattern - Variable Pattern - Array Pattern - Hash Pattern - Lambdas - Regexes - Pattern alternation - Guards --- ## Value Pattern - "A value pattern matches an object such that `pattern === object`" ```ruby case 0 in 0 in -1..1 in Integer end ``` --- ## Variable Pattern - "A variable pattern matches any value and binds the variable name to that value" ```ruby case 0 in a p a #=> 0 end ``` --- ## Variable Pattern - As in Elixir, you can use `_` to drop/ignore values ```ruby case [0, 1] in [_, y] p y #=> 1 end ``` --- ## Variable Pattern - Variable binding happens even if a local of that name exists - The value will be overwritten ```ruby a = 0 case 1 in a p a #=> 1 end ``` --- ## Variable Pattern - To access the existing value, using the `^` operator - Equivalent to Elixir's pin operator ```ruby a = 0 case 1 in ^a # effectively `in 0` :unreachable end #=> NoMatchingPatternError ``` --- ## **As** Pattern - An `as` pattern binds the variable to the value if the pattern matches ```ruby case 0 in Integer => a a #=> 0 end ``` --- ## **As** Pattern - Such patterns can be used to extract part of a complex object: ```ruby case [0, [1, 2]] in [0, [1, _] => a] p a #=> [1, 2] end ``` --- ## Array Pattern - Array patterns match when: - `pattern === object` - Or, object implements `#deconstruct` and the relevant part(s) match the given pattern(s) --- ## Array Pattern - We first extend `Array` with pattern matching support: ```ruby class Array def deconstruct self end end ``` --- ## Array Pattern ```ruby case [0, 1, 2, 3] in Array(0, *a, 3) in Object[0, *a, 3] in [0, *a, 3] in 0, *a, 3 end p a => [1, 2] ``` - All of the patterns match and extract the middle two elements of the array --- ## Array Pattern: Matching on a user-defined object ```ruby Point = Struct.new(:x, :y) do alias :deconstruct, :to_a end ``` --- ## Array Pattern: Matching on a user-defined object ```ruby p = Point.new(1, 2) case p in Point[0, y] :on_y_axis in Point[x, 0] :on_x_axis in Point[x, y] "At (#{x}, #{y})" end ``` --- ## Hash Pattern - Hash patterns match when: - `pattern === object` - Or, object implements `#deconstruct_keys(keys)` and the relevant part(s) match the given pattern(s) --- ## Hash Pattern - We first extend `Hash` with pattern matching support: ```ruby class Hash def deconstruct_keys(keys) slice(*keys) end end ``` --- ## Hash Pattern ```ruby case {a: 0, b: 1} in Hash(a: a, b: 1) in Object[a: a] in {a: a} in a: in {a: a, **rest} p rest #=> {b: 1} end ``` --- ## Hash Pattern - Author's aim was to support duck typing and avoid specifying types - Anything that deconstructs to the appropriate keys works ```ruby class Time def deconstruct_keys(keys) parts = %i{sec min hour day month year wday yday isdst zone} parts.zip(to_a).to_h.slice(*keys) end end case Time.now in year: 2019 :is_2019 in wday: 6, isdst: true :dst_on_saturday end ``` --- ## Hash Pattern - Great for pulling apart (highly) complex objects - Author gives a great example using `RubyVM::AbstractSyntaxTree` --- ## Hash Pattern - What should `{}` match? ```ruby case obj in {} :match end ``` --- ## Hash Pattern - `{}` will only match `{}`, despite otherwise being a subset match ```ruby case {a: 1, b: 2} in {a: 1} :match in {} :no_match end ``` --- ## Exact vs. Subset Matches - Array matches must match all parts of the object (an *exact* match) - Hash matches need only match a *subset* of the object's values - Though `{}` will only match `{}` --- ## Matching Lambdas ```ruby p case Time.now in ->(t) { t.isdst && t.zone == 'BST' } :match end ``` - Useful when dealing with types which don't (yet) deconstruct - Suspect that most such cases would be better expressed as patterns --- ## Matching Regexes ```ruby case '2019-08-17T00:12:01' in /^2019.*/ :match end ``` - As with the lamda example, if the value could be a type which deconstructs then the implementation will likely be clearer --- ## Pattern Alternation ```ruby p case 1 in 2 | ->(n) { n.odd? } :match end ``` --- ## Guards Example: Fibonacci Haskell: ```haskell fib 0 = 1 fib 1 = 1 fib n | n >= 2 = fib (n-1) + fib (n-2) ``` - Here the pipe can be read as 'such that' --- ## Fibonacci in Ruby with pattern matching ```ruby def fib(n) case n in 0 1 in 1 1 in n if n >= 2 fib(n-1) + fib(n-2) end end (0..4).each { |n| puts fib(n) } # puts fib(2.0) # NoMatchingPatternError # puts fib(-1) # NoMatchingPatternError ``` --- ## Without pattern matching ```ruby def fib(n) return 1 if [0, 1].include?(n) raise ArgumentError unless n >= 2 fib(n-1) + fib(n-2) end ``` -- - Cases are slightly less clear (perhaps?) -- - Difference might be clearer with a more complex function --- ## More complex example: atan2 $$ \operatorname{atan2}(y,x) = \begin{cases} \arctan(\frac y x) &\text{if } x > 0, \cr\\ \arctan(\frac y x) + \pi &\text{if } x < 0 \text{ and } y \ge 0, \cr\\ \arctan(\frac y x) - \pi &\text{if } x < 0 \text{ and } y < 0, \cr\\ +\frac{\pi}{2} &\text{if } x = 0 \text{ and } y > 0, \cr\\ -\frac{\pi}{2} &\text{if } x = 0 \text{ and } y < 0, \cr\\ \text{undefined} &\text{if } x = 0 \text{ and } y = 0. \end{cases} $$ - where \\(\operatorname{atan2}(y,x)\\) is the argument (angle) of the complex number \\(x + \mathbf{i}y\\) --- ## atan2 in Haskell ```haskell atan2 y x | x > 0 = atan (y/x) | x == 0 && y > 0 = pi/2 | x < 0 && y > 0 = pi + atan (y/x) |(x <= 0 && y < 0) || (x < 0 && isNegativeZero y) || (isNegativeZero x && isNegativeZero y) = -atan2 (-y) x | y == 0 && (x < 0 || isNegativeZero x) = pi -- must be after the previous test on zero y | x==0 && y==0 = y -- must be after the other double zero tests | otherwise = x + y -- x or y is a NaN, return a NaN (via +) ``` --- ## A quick (broken!) Ruby implementation ```ruby def atan2(y, x) case [ y, x ] in y, x if x > 0 Math.atan(y/x) in y, x if x < 0 && y >= 0 Math.atan(y/x) + Math::PI in y, x if x < 0 && y < 0 Math.atan(y/x) - Math::PI in y, 0 if y > 0 Math::PI / 2 in y, 0 if y < 0 -(Math::PI / 2) in 0, 0 raise ArgumentError, 'Result is undefined' end end ``` - In Ruby, `-0.0 >= 0.0 #=> true` - Also `(-0.0).negative? #=> false` - We would benefit from something like `isNegativeZero` from the Haskell implementation --- ## Borrowing from Haskell ```ruby def isNegativeZero(x) return x.zero? && 1.0/x == -Float::INFINITY end def atan2(y, x) case [ y, x ] in y, x if x > 0 Math.atan(y/x) in y, 0 if y > 0 Math::PI / 2 in y, x if x < 0 && y > 0 Math.atan(y/x) + Math::PI in y, x if (x <= 0 && y < 0) || \ (x < 0 && isNegativeZero(y)) || \ (isNegativeZero(x) && isNegativeZero(y)) -atan2(-y, x) in 0, x if x < 0 || isNegativeZero(x) Math::PI in 0, 0 y in _, _ raise ArgumentError, 'Result is undefined' end end ``` --- ## Use case: processing JSON Given the following JSON data: ```json 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: ```ruby person = JSON.parse(json, symbolize_names: true) case person in {name: "Alice", children: [{name: "Bob", age: age}]} p age #=> 2 end ``` --- ## What if Alice has more children? ```json json = '{ "name": "Alice", "age": 30, "children": [ { "name": "Bob", "age": 2 }, { "name": "Jim", "age": 4 } ] } ' ``` ```ruby case person in {name: "Alice", children: [{name: "Bob", age: age}]} p age end ``` - Raises `NoMatchingPatternError`! - Array matches must be exact... --- ## Ignoring the other element ```json json = '{ "name": "Alice", "age": 30, "children": [ { "name": "Bob", "age": 2 }, { "name": "Jim", "age": 4 } ] } ' ``` ```ruby case person in {name: "Alice", children: [{name: "Bob", age: age}, _]} p age #=> 2 end ``` --- ## Adding another child: matching fails again ```json json = '{ "name": "Alice", "age": 30, "children": [ { "name": "Bob", "age": 2 }, { "name": "Jim", "age": 4 }, { "name": "Jane", "age": 8 } ] } ' ``` ```ruby case person in {name: "Alice", children: [{name: "Bob", age: age}, _]} p age end ``` - Not enough to drop a single value --- ## Matching target irrespective of number of children ```json json = '{ "name": "Alice", "age": 30, "children": [ { "name": "Bob", "age": 2 }, { "name": "Jim", "age": 4 }, { "name": "Jane", "age": 8 } ] } ' ``` ```ruby case person in {name: "Alice", children: [{name: "Bob", age: age}, *]} p age #=> 2 end ``` --- ## Matching target irrespective of number of children ```ruby case person in {name: "Alice", children: [{name: "Bob", age: age}, *]} p age #=> 2 end ``` - Splat could also be `*rest` or `*_` if you prefer - We now extract the age of Bob irrespective of the other element in the array --- ## Without pattern matching ```ruby 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 ``` - Pattern matching implementation is more terse, easier to read (?) - Simultaneous matching and extraction of values is a plus --- A more complex example--though the following JSON has been heavily snipped--we want to extract the topics only if certain conditions are met. ```ruby { "id": 1, "body": "I'm having a problem with this.", "user": { "login": "octocat" }, ... "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, ... } } ``` --- ## With pattern matching We now parse the data and extract the topics, only if certain values are matched. First with pattern matching: ```ruby data = JSON.parse(json, symbolize_names: true) case data in [{id: 1, repository: {has_issues: true, owner: {login: 'octocat'}, topics: topics}}, *] p topics end ``` --- ## Without pattern matching ```ruby match = data.detect { |repository| repository[:id] == 1 && repository.dig(:repository, :has_issues) && \ repository.dig(:repository, :owner, :login) == 'octocat' } p match.dig(:repository, :topics) ``` - I prefer the pattern matching implementation - Scales well in readability if more conditions were to be added - Humans are good at pattern/shape matching - Nested conditionals harder work to follow --- ## Example: FizzBuzz ### Practical, **if** you're going for an interview... 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. ```ruby (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 ### Caveat: I don't use Elixir ```ruby 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 ``` --- ## Ruby implementation By definining a wrapper around File.open we can implement this in Ruby as: ```ruby 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" ``` --- ## Thoughts on the future of pattern matching (in Ruby) - As more standard (and library) types gain `deconstruct` support the feature will become more useful. -- - Support for `ActiveRecord` objects could be interesting -- - 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 so far. --- # Questions & Discussion ## Main References - [Ruby 2.7 — Pattern Matching — First Impressions](https://medium.com/@baweaver/ruby-2-7-pattern-matching-first-impressions-cdb93c6246e6) - [Ruby 2.7 — Pattern Matching — Destructuring on Point](https://medium.com/@baweaver/ruby-2-7-pattern-matching-first-impressions-cdb93c6246e6) - [Pattern matching - New feature in Ruby 2.7](https://speakerdeck.com/k_tsj/pattern-matching-new-feature-in-ruby-2-dot-7)