Rubyパターンマッチを闇の力でアクティブにする
この記事はMisoca+弥生 Advent Calendar 2019の1日目です。
もう12月ですよ、12月!
記事の内容とはなんの関係もありませんが、デレステにM@GICが実装されましたね。
アニメを思い起こさせる最高のMVでした……
💎 Rubyのパターンマッチ
Ruby 2.7で、ついにパターンマッチが導入されますね。
今はRuby 2.7.0-preview3で試すことができます。正式にリリースされるのは、おそらく例年通り12月25日でしょうか。
パターンマッチ機能の使い方をざっくり書くと、case ... in ...
のような形でパターンマッチを行える機能です。
case { key: :value}
in { key: x }
p x # => :value
end
詳しい話はRubyKaigiでの発表スライドのほか、メドピアさんの「Ruby2.7の(実験的)新機能「パターンマッチ」で遊ぶ」がよくまとまっておりわかりやすいです。
🐫 F#のアクティブパターン
ところで、F#ではアクティブパターンという機能を使うことができます。
これは、パターンへの分解を、マッチ対象とは別に定義できるものです。
let (|Even|Odd|) input = if input % 2 = 0 then Even else Odd
let TestNumber input =
match input with
| Even -> printfn "%d is even" input
| Odd -> printfn "%d is odd" input
TestNumber 11 (* 11 is odd *)
TestNumber 32 (* 32 is even *)
上記のように、マッチ対象となる数値とは独立してEven
とOdd
というパターンを定義することができます。
さらに、パターンへの分解方法を変えることも可能です。
let (|RGB|) (col : System.Drawing.Color) =
( col.R, col.G, col.B )
let (|HSB|) (col : System.Drawing.Color) =
( col.GetHue(), col.GetSaturation(), col.GetBrightness() )
let printRGB (col: System.Drawing.Color) =
match col with
| RGB(r, g, b) -> printfn " Red: %d Green: %d Blue: %d" r g b
let printHSB (col: System.Drawing.Color) =
match col with
| HSB(h, s, b) -> printfn " Hue: %f Saturation: %f Brightness: %f" h s b
上記のように、Color
型に対してRGB値で取り出したりHSB値で取り出したりすることができます。
Rubyのパターンマッチでは、パターンへの分解はマッチ対象のdeconstruct
やdeconstruct_keys
を使うため、分解方法を切り替えるにはRefinementを使うのが現実的な落とし所です。
一般的な回答としてはRefinements使ってくださいですかね。その場合RGBかHSVかの使い分けはRefinementsのスコープが最小範囲になりますが。
— k_tsj (@k_tsj) November 19, 2019
deconstruct
などをパターン側ではなくマッチ対象側に持たせた理由は、辻本さんがn月刊ラムダノート Vol.1, No.3に書かれていました。設計判断の話や他言語との比較もいろいろ言及されていて興味深かったです。
……が、それでもF#っぽい書き方をしたい! と思ったのでそれっぽい実装を考えてみよう、というのが今回のテーマです。
ちなみに「闇の力」とタイトルに入っていますが、実装してみたらTracePointとかISeqとかを使わない形に落ち着いたので若干釣りタイトルになっています。
🔑 ハッシュキーでマッチ方法を変える戦略
最初に思いついたのは、Hashパターンでキーを明示すれば柔軟に分解できるのでは? というアイディアです。
たとえば二次元座標を取り出す場合、「直交座標で取り出すか、極座標で取り出すか」を指定する代わりに、x, y
や r, rad
をハッシュキーに 指定します。
p = Point2.new(1, 1)
# 直交座標での取り出し
case p
in { x: x, y: y }
puts "x is #{x}, y is #{y}" # => x is 1, y is 1
end
# 極座標での取り出し
case p
in { r: r, rad: rad }
puts "r is #{r}, radian is #{rad}" # => r is 1.414..., radian is 0.785...
end
これならdeconstruct_keys
をきちんと定義するだけで実現できそうです。
Point2 = Struct.new(:x, :y) do
def r
Math.sqrt(x ** 2 + y ** 2)
end
def rad
Math.atan2(x, y)
end
def deconstruct_keys(_keys)
{ x: x, y: y, r: r, rad: rad }
end
end
これで先ほどのパターンマッチがうまく動くPoint2
を定義できます。
しかし、この実装だと使わないキーも常に計算されてしまうため無駄な処理が多いですし、分解の仕方が増えるとdeconstruct_keys
がどんどん膨らんでしまいます。
MethodMatchable gem
そこでMethodMatchableというgemを作ってみました。
アイディアは非常に単純で、deconstruct_keys
でキー名と同じメソッドが存在する場合、そのメソッドの評価結果を返すというものです。実装箇所を見れば何をしているかわかりやすいと思います。
これを使えば、Point2
の実装は以下のように書き換えられます。
Point2 = Struct.new(:x, :y) do
include MethodMatchable::Base
def r
Math.sqrt(x ** 2 + y ** 2)
end
def rad
Math.atan2(x, y)
end
end
MethodMatchable::Base
をinclude
かprepend
するだけ。簡単ですね。
単純な実装ですが、メソッドの結果をパターンマッチで取り出したり、ある条件を満たすかをパターンに混ぜたりなど、応用の幅が広そうです。
👈 定数指定でマッチ方法を変える戦略
ハッシュキーでの指定でそれっぽくなったとはいえ、見た目がアクティブパターンっぽくないですね……
どうせなら、こういうふうに書きたいところです。
p = Point2.new(1, 1)
# 直交座標での取り出し
case p
in Cartesian[x, y]
puts "x is #{x}, y is #{y}" # => x is 1, y is 1
end
# 極座標での取り出し
case p
in Polar[r, rad]
puts "r is #{r}, radian is #{rad}" # => r is 1.414..., radian is 0.785...
end
この書き方自体はRubyパターンマッチでサポートされていますが、処理の流れは以下のようになってしまいます。
Cartesian
やPolar
の===(p)
メソッドが呼び出され、true
かチェックp.deconstruct
が呼び出され、返された配列でパターンマッチ
このため、Cartesian
やPolar
の指定はdeconstruct
メソッドに伝わっておらず、分解方法を切り替えるといったことはできなくなっています。
これをどうにかする方法としてTracePointで無理やり引数に渡すかと考えていたのですが、@hanachin_さんからグローバル変数に入れればいいのではという指摘をいただきました。
グローバルな状態を介するのでバグりやすそうですが、アドベントカレンダーネタとしてさくっと試したかったので、こちらの方針で実装してみることに。
ActivePattern gem
結果、Active Pattern gemができあがりました。
以下のように ActivePattern::Context[TargetClass]
をextendした上で、pattern
メソッドにパターンへの分解方法を渡します。
Point2 = Struct.new(:x, :y)
module Coordinates
extend ActivePattern::Context[Point2]
Cartesian = pattern { [x, y] }
Polar = pattern { [r, rad] }
end
これで以下のようなマッチングを行うことができます。
p = Point2.new(1, 1)
# 直交座標での取り出し
case p
in Coordinates::Cartesian[x, y]
puts "x is #{x}, y is #{y}" # => x is 1, y is 1
end
# 極座標での取り出し
case p
in Coordinates::Polar[r, rad]
puts "r is #{r}, radian is #{rad}" # => r is 1.414..., radian is 0.785...
end
だいぶマジカルですね。
内部的には、===評価時にグローバル変数にパターンを保存し、deconstruct時にグローバル変数があればそちらを利用するという実装になっています。わりと読みやすい設計になっているはずなので、興味があれば読んでみてください。
ちなみに、pattern
でHash
やArray
を返すとそれぞれdeconstruct_keys
とdeconstruct
に使われるようにしているほか、true
とfalse
を返せば定数パターンでのマッチングのみに利用されます。
例えばFizzBuzzであれば以下のように書くことができます。
module FZPattern
extend ActivePattern::Context[Integer]
FizzBuzz = pattern { self % 15 == 0 }
Fizz = pattern { self % 3 == 0 }
Buzz = pattern { self % 5 == 0 }
Number = pattern { [self] }
end
def fizzbuzz(n)
case n
in FZPattern::FizzBuzz; :FizzBuzz
in FZPattern::Fizz; :Fizz
in FZPattern::Buzz; :Buzz
in FZPattern::Number[n]; n
end
end
fizzbuzz(1) # => 1
fizzbuzz(3) # => :Fizz
fizzbuzz(5) # => :Buzz
fizzbuzz(15) # => :FizzBuzz
パターンが排他的になっていないという問題はありますが、F#のアクティブパターンとかなり近い見た目にできたのではないでしょうか。
🎄 まとめ
F#のアクティブパターンっぽいことをするために、MethodMatchableとActivePatternの2つのgemを作ってみました。
かなり邪道な部分もありますが、使い所がうまくハマれば可読性を高められる……かもしれません。
これらのgemはRubyGemsにもmethod_matchableとactive_patternとしてpushしてあるので、(まだRubyがpreviewですが)bundlerから簡単に入れられます。興味があれば試してみてください。
📆 アドベントカレンダー予告
Misoca+弥生 Advent Calendar 2019、明日の担当はeitoballさんです。お楽しみに!