Ruby2.7の新機能PatternMatchingが最高でした

RubyKaigi2019で聞いたRuby2.7から入るPattern Matchの機能に感動したのですが、セッション中は理解しきれない部分があったので、スライドを読み、コードを動かしてみました。
そしたら改めて感動した、という記事です!

speakerdeck.com

スライドのはじめに、下記の記載があります。

  • PatternMatchingは2.7.0からの新機能ですが、trunkにはもうcommit済
  • 仕様はまだ策定中
  • 試してフィードバックくださいね!

なお、githubにサンプルコードを置いています。

1. 準備

Ruby2.7.0(dev)はビルドしなきゃかな…と思ってたらrbenvがもう対応してました。
はやい!うれしい!

$ brew upgrade rbenv ruby-build

これで無事2.7.0-devがリストに出てくるようになります。

$ rbenv install --list
  (略)
  2.6.1
  2.6.2
  2.6.3
  2.7.0-dev
  (略)
  
$ rbenv install 2.7.0-dev
(略)
Installed ruby-trunk to /Users/makicamel/.rbenv/versions/2.7.0-dev

$ rbenv versions
* system
  2.7.0-dev

$ rbenv local 2.7.0-dev

$ rbenv version
2.7.0-dev (set by /Users/makicamel/pattern_match/.ruby-version)

これで準備完了です!

試しにPatternMatchingを書いてみると動くようになりました!
この時warningが出るのですが、ほんと開発中って感じが、わくわくします…!

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

2. PatternMatchingとは?

case句に対して複数の値を割り当てられること。

従来のcase句ってこんな感じ。完全一致です。

case [0, [1, 2, 3]]
when [0]
  :unreachable
when [0, [1]]
  :unreachable
when [0, [1, 2, 3]]
  p 'here'
end
# => here

PatternMatchingが導入されるとこうなります。(in節になります)

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

パターンマッチされるだけでなく、マッチした変数名でそのままマッチした値が取り出せます。

*(スプラット演算子)を使ってこんな風にもかけます。

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

ちゃんと構造もチェックしてくれるので、こうなります。

case [0, [1, 2, 3]]
in [a]
  :unreachable
in a
  p a # => [0, [1, 2, 3]]
end

ハッシュも使えます。

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

すごい使い所ありそう!
セッションによると、JSONのデータを扱う時に便利ですよ、とのこと。

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

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

シンプル!!

3. 仕様について

Syntax

  1. 最初にマッチするまで実行される
  2. どのパターンもマッチしない場合、else 節が実行される
  3. どのパターンもマッチせずelse節もない場合、NoMatchinPatternErrorが発生する。

1と2は現状のcaseと同じですね。
どの条件にも一致しない場合は例外が発生するので、パターンマッチを使う時には網羅性を確認してください、とのことでした。

4.if/unlessで条件づけができる

case [0, 1]
in [a, b] unless a == b
  p a # => 0
  p b # => 1
end

a, bが先に評価され、マッチした時にif/unlessが評価されます。
なので、マッチした値を利用した評価が可能。すごい!

Pattern

1. ValuePattern

case/whenと同様に、===で評価されます。

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

どれともマッチします。whenと同じ挙動。

2. VariablePettern

先程までの例のように値をマッチさせ、その変数と値が結び付けられます。
また、_を使うと値を捨てて、ワイルドカード的に使えます。

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

注意点としては、in節で変数を使うと、case句の外で変数を定義していてもパターンマッチされてしまうこと。
パターンマッチではなく定義された変数として使いたい場合は^を使います。

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

a = 0
case 1
in ^a
  :unreachable
end
# => NoMatchingPatternError

3. AlternativePattern

case文を書き始めてしばらくして、こんな感じで書けたら…と思った記憶があります。
現実になった!すごい!

case 0
in 0 | 1 | 2
  :reachable
end

4. As Pattern

パターンがマッチした時、=>で変数名を指定することで変数と値を結び付けられます。
この使い勝手のよさがすごい。
ValuePatternだけじゃなくて、他のパターンでも使えます。

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

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

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

5. ArrayPattern

ArrayPatternとはいうけれど、Array以外でも使えます。
以下の3つを満たす時にマッチします。

  1. Constant === objectの時
  2. objectがArrayを返すdeconstructメソッドを持つ時
  3. ネストしたobjectが2の条件を満たす時

また、パターンの書き方は下記のいずれも可能です。

Constant(pattern, pattern, ..., *var, pattern)
Constant[pattern, pattern, ..., *var, pattern]
[pattern, pattern, ..., *var, pattern]
case [0, 1, 2]
in Array(0, *a, 2)
in Object[0, *a, 2]
in [0, *a, 2]
in 0, *a, 2 # `[]`は省略できる
end
p a # => [1]

この4つは全て同じ結果。
ふと気になったのが、in [0, *a, 2]って最初の例と同じ。
deconstructメソッド実装しなくていいの?と思って確認してみると、[].methods.include? :deconstruct # => trueでした。
Ruby2.6.2では[].methods.size188、2.7.0-devでは189になっていて、おお…となりました。

さて、先述した通り、ArrayPatternはArray以外でも使えます。
objectがArrayを返せばOKなので、実装してあげます。

class Struct
  alias deconstruct to_a
end

Color = Struct.new(:r, :g, :b)
color = Color[0, 10, 20]
p color.deconstruct # => [0, 10, 20]
case color
in Color[0, 0, 0]
  p 'black'
in Color[255, 0, 0]
  p 'red'
in Color[r, g, b]
  p "#{r}, #{g}, #{b}"
end

StructがArrayを返すよう、deconstructto_aエイリアスに設定しています。

スライドにはASTの例もありましたが、理解が追いつかないのでレベルアップしてから再挑戦します。

6. HashPattern

HashPatternも、Hash以外でも使えます。
以下の3つを満たす時にマッチします。

  1. Constant === objectの時
  2. objectがHashを返すdeconstruct_keysメソッドを持つ時
  3. ネストしたobjectが2の条件を満たす時

また、パターンの書き方もArray同様下記のいずれも可能です。

Constant(id: pattern, id: pattern, ..., **var)
Constant[id: pattern, id: pattern, ..., **var]
{id: pattern, id: pattern, ..., **var}

Arrayに引き続き確認すると、{}.methods.include? :deconstruct_keys # => true
ただし今回はRuby2.6.2では{}.methods.size174、2.7.0-devでは176
ひとつはdeconstruct_keysとして、もうひとつは?と確認してみると、:tallyでした。

tally配列の要素数を要素毎に数え上げるEnumerableモジュールのメソッド
こちらも楽しみです😊

さて、コードを見てみます。

case {a: 0, b: 1}
in Hash(a: a, b: 1)
  p a # => 0
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

また、deconstruct_keysの実装で思いがけない値を返すと逆効果になるので、実装に注意してくださいとのことでした。

  • deconstruct_keysに渡されるkeysはパターンに含まれるkeyの配列
  • keysに含まれていないkeyは無視してOK
  • **restがパターンに含まれる場合はnilが渡る
  • その場合、全てのkey-valueセットを返さなければならない

コードを見たほうがわかりやすそうです。

class Time
  VALID_KEYS = %i(year month)

  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}
    end
  end
end

now = Time.now # 2019-05-07 ...
case now
in year: # now.deconstruct_keys([:year])を呼ぶ
  p year # => {year: 2019}
in **rest # now.deconstruct_keys(nil)を呼ぶ
  p rest  # => {year: 2019, month: 5}
end

ArrayとHashの違いで気をつけたいのは、Arrayは完全一致、Hashはサブセットマッチなこと
ArrayとHashでは使い所が違うから、とのことでした。

case [0, 1]
in [a]
  :unreachable
in [a, *]
  :reachable
end

case {a: 0, b: 1}
in {a: a}
  :reachable
end

デザイン

Historyは時間がなくてセッション中端折られてしまったのですが、スライドによると2012年にgemを作ったことから端を発して、7年かけて作られた機能。
最初はmatch/patternだったりcase/=>になっていったり、時間をかけて様々考えられ、磨きぬかれていった様にわくわくします!

キーワードも、matchでなくcaseしたのは、matchだと既存のコードを壊してしまう可能性があるから。
新しい予約語を使わないために最初は記号を使ったりしようとしていたのだけど、そうだ、僕らにはfor/ininがあるじゃないか!と思いついた話がすごく好きです。

なるべく自然に書けるように、でも既存のコードを壊さないように。
コードを試し書きしていて何度も気持ちいいなあ、と思ったのですが、Rubyの「開発者が楽しい」ってこうして守られ、作られているんだなあ、と改めて感じ入りました。

また、まだもっとよくしたいんだけど、どう思う?というスライドもたくさんあって、わくわくしました。

パターンマッチング、楽しみです!