先日福岡で開催された RubyKaigi 2019 に参加してきました。下記の辻本さんのセッションの中で Ruby 2.7 で導入される予定のパターンマッチングについての紹介があったので、セッションの資料に沿って触ってみました。
Pattern matching - New feature in Ruby 2.7 - RubyKaigi 2019
セッションの資料は SpeakerDeck にアップロードされているのでそちらを参照させていただきました。
今回使用しているサンプルコードは基本的には上記の資料内のサンプルをそのまま実行しているか、若干変更したものを使用しています。
パターンマッチングとは
パターンマッチングについてのRubyist向けの説明としては、 case/when + multiple assignment
という感じになり、正規表現とかではなくオブジェクトの構造のパターンのマッチングです。下記 issue にて開発が進められています。
演算子
パターンマッチング用の演算子としては 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 をきっかけに、こうした新しい機能もできるだけ追いかけていきたいと思います。