Ruby 2.7 で導入される予定の Pattern Matching を触ってみる

 先日福岡で開催された RubyKaigi 2019 に参加してきました。下記の辻本さんのセッションの中で Ruby 2.7 で導入される予定のパターンマッチングについての紹介があったので、セッションの資料に沿って触ってみました。

Pattern matching - New feature in Ruby 2.7 - RubyKaigi 2019

 セッションの資料は SpeakerDeck にアップロードされているのでそちらを参照させていただきました。

speakerdeck.com

 今回使用しているサンプルコードは基本的には上記の資料内のサンプルをそのまま実行しているか、若干変更したものを使用しています。

パターンマッチングとは

 パターンマッチングについてのRubyist向けの説明としては、 case/when + multiple assignment という感じになり、正規表現とかではなくオブジェクトの構造のパターンのマッチングです。下記 issue にて開発が進められています。

bugs.ruby-lang.org

演算子

 パターンマッチング用の演算子としては case が拡張され、 case/when だけでなく case/in が追加されています。

case [0, [1, 2, 3]]
in [a, [b, *c]]
  p a #=> 0
  p b #=> 1
  p c #=> [2, 3]
end

 ちなみに上記のようにパターンマッチングを使用すると、現状は開発中の機能であり、将来的に仕様が変更になるかもしれないということで、下記のように warning が表示されます。

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

 multiple assignment との違いとして、オブジェクトの構造もチェックされ、マッチした部分の変数に値が代入されます。

case [0, [1, 2, 3]]
in [a]
  :unreachable # 配列構造が一致しない
in [0, [a, 2, b]]
  p a #=> 1
  p b #=> 3
end

 対象のオブジェクトとして Hash もサポートされています。

case {a: 0, b: 1}
in {a: 0, x: 1}
  :unreachable # b というキーにマッチしない
in {a: 0, b: var}
  p var #=> 1
end

 JSON形式のデータを扱う際に、期待した構造になっているかチェックし、なっていた場合には値を取り出すという使い方をする時に便利です。

require 'json'

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

case JSON.parse(json, symbolize_names: true)
in {name: "Alice", children: [{name: "Bob", age: age}]}
  p age #=> 2
end

 上記の case 文と同様のことをパターンマッチングなしでやろうとすると、下記のように書く必要があります。

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

仕様 (2019.4.20時点)

 確認した 2019/4/20 時点での仕様としては下記のようになっています。

case obj
in pat [if|unless cond]
  ...
in pat [if|unless cond]
  ...
else
  ...
end
  • 条件にマッチするまでシーケンシャルに評価が行われる
  • マッチする条件がなければ else 句が実行される
  • マッチする条件がなく、 else 句も定義されていない場合、 NoMatchingPatternError が raise される

ガード条件も使用可能で、 in 句の条件にマッチした場合にガード条件が評価されます。

case [0, 1]
in [a, b] unless a == b
  :reachable # まず [a, b] のパターンにマッチするか評価され、そのあとで  a == b かどうかが評価される
end

パターン

 現状パターンとしては下記の6パターンがあります。

  • Value pattern
  • Variable pattern
  • Alternative pattern
  • As pattern
  • Array pattern
  • Hash pattern

 それぞれを簡単に見ていきます。

Value パターン

 pattern と object を比較し、 pattern === object であればマッチします。

pat: literal
   | Constant

 下記の in 句はいずれもマッチします。

case 0
in 0
in -1..1
in Integer
end

Variable パターン

 任意の値とマッチし、変数にその値を代入します。

pat: var
case 0
in a
  p a #=> 0
end

 変数に代入する必要がない場合はアンダースコアを使うことができます。

case [0, 1]
in [_, _]
  :reachable
end

 case/in の外側に同じ変数名が使われていても、上書きしてアサインします。

a = 0
case 1
in a
  p a #=> 1
end

 既存の変数の値をパターンマッチに使いたいときは、 ^ を使用します。

a = 0
case 1
in ^a # 'in 0' という意味になる
  :unreachable
end #=> NoMatchingPatternError が raise される

Alternative パターン

 OR条件でのパターンマッチです。

pat: pat | pat | ...
case 1
in 0 | 1 | 2 # 0, 1, 2 のいずれかにマッチすれば真
  p :reachable #=> :reachable
end

As パターン

 値がパターンにマッチした時に、値を変数に格納します。

pat: pat => pat
case 0
in Integer => a # 値がIntegerだったら a に格納する
  p a #=> 0
end

 複雑なオブジェクトのパターンマッチで、そのオブジェクトの一部を取り出すのに便利です。

case [0, [1, 2]]
in [0, [1, _] => a]
  p a #=> [1, 2]
end

Array パターン

 配列オブジェクトに対してのマッチングです。アスタリスク * で複数の要素にマッチします。下記の in句はいずれもマッチします。

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]

 Struct に対してのマッチングも可能です。

class Struct
  alias deconstruct to_a
end

Color = Struct.new(:r, :g, :b)
p Color[0, 10, 20].deconstruct #=> [0, 10, 20]

color = Color.new(255, 0, 0)

case color
in Color[0, 0, 0]
  puts "Black"
in Color[255, 0, 0]
  puts "Red" #=> Red
in Color[r, g, b]
  puts "#{r}, #{g}, #{b}"
end

 下記は RubyVM::AbstractSyntaxTree::Node を使った Array パターンの例です。RubyVM::AbstractSyntaxTree を使った Power Assert の実装サンプルになっています。

class RubyVM::AbstractSyntaxTree::Node
  def deconstruct
    [type, *children, [first_lineno, first_column, last_lineno, last_column]]
  end
end

ast = RubyVM::AbstractSyntaxTree.parse('1 + 1')
p ast.type #=> :SCOPE
p ast.children #=> [[], nil, #<RubyVM::AbstractSyntaxTree::Node:OPCALL@1:0-1:5>]
p ast.deconstruct #=> [:SCOPE, [], nil, #<RubyVM::AbstractSyntaxTree::Node:OPCALL@1:0-1:5>, [1, 0, 1, 5]]

node = RubyVM::AbstractSyntaxTree.parse('assert { 3.times.to_a.include?(3) }')
pp node #=> (SCOPE@1:0-1:35
        #    tbl: []
        #    args: nil
        #    body:
        #      (ITER@1:0-1:35 (FCALL@1:0-1:6 :assert nil)
        #         (SCOPE@1:7-1:35
        #          tbl: []
        #          args: nil
        #          body:
        #            (CALL@1:9-1:33
        #               (CALL@1:9-1:21 (CALL@1:9-1:16 (LIT@1:9-1:10 3) :times nil) :to_a
        #                  nil) :include? (ARRAY@1:31-1:32 (LIT@1:31-1:32 3) nil)))))

case node
in :SCOPE, _, _, [:ITER, [:FCALL, :assert, _, _], body, _], _
  pp body #=> (SCOPE@1:7-1:35
          #    tbl: []
          #    args: nil
          #    body:
          #      (CALL@1:9-1:33
          #         (CALL@1:9-1:21 (CALL@1:9-1:16 (LIT@1:9-1:10 3) :times nil) :to_a nil)
          #         :include? (ARRAY@1:31-1:32 (LIT@1:31-1:32 3) nil)))
end

Hash パターン

 Hash パターンと言っていますが Hash オブジェクト以外にも使われるパターンです。下記条件を満たすとマッチします。

  • Constant === object が true を返す
  • object が Hash を返す #deconstruct_keys メソッドを持っている
  • object.deconstruct_keys(keys) への nested pattern の適用結果が true を返す

 Hash オブジェクトでのパターンマッチング例は下記の通りです。下記の in句は全てマッチします。

class Hash
  def deconstruct_keys(keys)
    self
  end
end

case {a: 0, b: 1}
in Hash(a: a, b: 1)
in Object[a: a]
in {a: a}
in {a: a, **rest}
  p rest #=> {:b=>1}
end

 中括弧は省略可能です。また、 a:a: a のシンタックスシュガーです。

case {a: 0, b: 1}
in a:, b:
  p a #=> 0
  p b #=> 1
end

 keys は効率的な実装のためのヒントになる情報を提供します。また #deconstruct_keys メソッドの見当違いな実装は非効率的な結果となることがあります。

class Time
  def deconstruct_keys(keys)
    {
      year: year, month: month,
      asctime: asctime, ctime: ctime,
      yday: yday, zone: zone
    }
  end
end

case Time.now
in year:
  p year #=> 2019
end

 keys はパターンの中で指定された key を含む配列を参照します。 keys に含まれない key は無視することができます。もしパターンの中で **rest が指定されている場合、代わりに nil が渡されます。その場合には全てのキーバリューペアを返さなければなりません。

class Time
  VALID_KEYS = %i(year month asctime, ctime, yday, zone)

  def deconstruct_keys(keys)
    if keys
      (VALID_KEYS & keys).each_with_object({}) do |k, h|
        h[k] = send(k)
      end
    else
      {
        year: year, month: month,
        asctime: asctime, ctime: ctime,
        yday: yday, zone: zone
      }
    end
  end
end

now = Time.now
case now
in year: # now.deconstruct_keys([:year]) が呼ばれ、{year: 2019} が返される
  p year #=> 2019
end

まとめ

 今回はとりあえず紹介されているサンプルで、 Ruby2.7 で実装される予定の Pattern Matching を触ってみました。仕様はまだ変わる可能性がありますが、 JSON オブジェクトに対してのマッチングは直感的に指定することができそうで、かつ値の取り出しも同時にできるということで、便利に使えそうです。 RubyKaigi をきっかけに、こうした新しい機能もできるだけ追いかけていきたいと思います。