パターンマッチ・キャッチアップ
Ruby v2.7から追加されたパターンマッチを、今さらキャッチアップする。
自分で考えながら、実践的な使いみちを模索する。
とりあえずリンク集
- 機能実装者(k-tsj氏)による、前身となるライブラリ: http://www.callcc.net/diary/20120303.html
- k-tsj氏がRubyKaigi2017で他の発表者から実装が出てきて触発されたっぽい: http://www.callcc.net/diary/20170921.html
- bugsチケット: https://bugs.ruby-lang.org/issues/14912
- 導入後のRuby2.7のスライド: https://speakerdeck.com/k_tsj/pattern-matching-new-feature-in-ruby-2-dot-7
幸いElixirを少し勉強したことがあるのでおさらい。
Elixir/Erlangでは、パターンマッチは言語のコアに組み込まれていて、one lineはもちろん、if/cond/case/receive文、関数のオーバーロードと引数の組み合わせ等、多くの場面で利用されている。
自分なりに感じたパターンマッチの役割は
- アサーション(one line)
- 例外処理
- 分岐の名前付け(関数との組み合わせ)
- 変数の捕縛
などがあった。
アサーションと例外処理
Elixirでは
{:ok, file} = File.open("foo.txt")
とすると、この行以降ファイルのopenに成功したことが保証される。
もしopenに失敗したら、例外を吐いてプログラムは終了する。
しかし
case File.open("foo.txt") do
{:ok, file} ->
# open成功
{:error, error} ->
# 例外処理
end
とか書くだけで、ファイルがopenできなかった場合の例外処理を追加できる。
openに成功した場合と失敗した場合が横並びの扱いで書ける。
関数の返り値を割と把握しておく必要がありそう。
このように、プログラムの流れを止めない例外処理は都度書きやすくなっているため、Elixirでは大域脱出的な例外を扱うことは珍しいっぽい。
Rubyでは
Rubyだと、アサーションの意味はそのまま使えそう。
# userは`id`と`name`と`address`を持っているはず
# 持ってなければ例外(NoMatchingPatternError)
params => {id:, name:, address:}
p id #=> 123
p name #=> "ksss"
p address #=> "tokyo"
例外処理は、言語のコアとして最初から入っていたわけではないので、自作class等に限定されそう。
ヘルパーを書いたらコアのメソッドでも無理やり使えるが、そこまで良さは感じない。
def tuplize
[:ok, yield]
rescue => err
[:error, err]
end
# NoMatchingPatternError
case tuplize { File.open("./notexists.txt") }
in [:ok, file]
begin
# file.read
ensure
file.close
end
end
case tuplize { File.open("./notexists.txt") }
in [:ok, file]
begin
# file.read
ensure
file.close
end
in [:error, error]
# error handling
pp error
end
分岐の名前付け
Elixirでは
関数のオーバーロードを使って、if文無しで分岐を書けるが、実質if文に名前をつける行為だと思った。
ここではファイルのopenに成功したかどうかの分岐にhandler
という名前をつけている。(名前付けとしてあんまりいい例が思いつかなかった……。)
def handler({:ok, file}) do
# 成功時
end
def handler({:error, error}) do
# 失敗時
end
File.open("foo.txt")
|> handler
Rubyでは
Rubyにオーバーロードの概念はないので、分岐に名前をつけるという意味では使いみちはなさそうかなあ。
変数の捕縛
Elixirでは
Elixirでは変数の捕縛方法がそもそもパターンマッチしかなかった。
Rubyでは
Rubyではパターンマッチを使わなくても変数捕縛なことはできる。
しかし、パターンマッチでかなりコードを分かりやすくできるケースはありそう。
アサーションでも出した例だと、1行で複数の変数が設定できる。
params => {id:, name:, address:}
p id #=> 123
p name #=> "ksss"
p address #=> "tokyo"
パターンマッチがないと
id = params[:id]
name = params[:name]
address = params[:address]
と3行かかる。one lineパターンマッチはまだexperimentalだけど、個人的にはこの使い方のほうが実践で使いやすそうな気がする
実は既に使ってるRubyのパターンマッチっぽい奴ら
keyword arguments
def user(id:, name:, address:)
p id #=> 123
p name #=> "ksss"
p address #=> "tokyo"
end
user(**params)
keyword argumentsでは指定したkeywordが無いとArgumentErrorになる。
しかも変数に設定しておいてくれているので、かなりパターンマッチっぽい。
rescue
begin
# 何が起きるかわからないコード
raise [Errno::ENOENT, ZeroDivisionError, KeyError].sample
rescue Errno::ENOENT => err
pp err
rescue ZeroDivisionError => err
pp err
rescue KeyError => err
pp err
end
rescueは複数書いて、上から条件が一致したらその分岐に入る。しかも=>
で変数として名前をつけれるのでこれもパターンマッチっぽい。
Regexp
正規表現は文字列だけのパターンマッチという感じ。
特に名前付きキャプチャーを使うと、
/(?<id>.*),(?<name>.*),(?<address>.*)/ =~ "123,ksss,tokyo"
p id #=> "123"
p name #=> "ksss"
p tokyo #=> "tokyo"
とパターンマッチっぽさがある。
つまり
これらの既にRubyにある機能を、もっと汎用的にコードで使いたいと思ったときに使い所のヒントがあるのかも???
試しに色々アイデア出し
FizzBuzz
パターンマッチがあるとき
def fizzbuzz(num)
case [num % 3, num % 5]
in [0, 0]
puts 'FizzBuzz'
in [0, _]
puts 'Fizz'
in [_, 0]
puts 'Buzz'
else
puts num
end
end
(1..20).map(&method(:fizzbuzz))
ないとき
def fizzbuzz(num)
fizz = num % 3 == 0
buzz = num % 5 == 0
if fizz && buzz
puts 'FizzBuzz'
elsif fizz
puts 'Fizz'
elsif buzz
puts 'Buzz'
else
puts num
end
end
(1..20).map(&method(:fizzbuzz))
そこはかとなく良さがあるような気もするが、使わなくてもこれはこれで……うーん。
Testing
https://qiita.com/kentaro/items/477c92a57c8aaf694251 にインスパイアされて、Ruby版を書いてみた。
user = {
id: 123_456,
name: "ksss",
profile: {
real_name: "栗原勇樹",
image: {
original: "...",
thumbnail: "...",
}
}
}
def test_user
user => { id: _, name: "ksss", profile: profile }
profile => { real_name: "栗原勇樹", image: image }
image => { original: _, thumbnail: _ }
end
assert { user => { id: _, name: "ksss", profile: profile } }
とできたらpower assertと組み合わせれるかもと思ったが、ここではprofile
がassertの外に出せないので複数行な使い方はできない。
keyの存在確認や、数値や文字列などの簡単な値の確認だけならできるかも。
assert { user => { id: 123_456, name: "ksss", profile: _ } }
type => "foo"
HashのArrayで{type: "foo"}
みたいにtype
キーで他のパラメーターが変わるパターン。
パターンマッチがあるとき
ary.each do |params|
case params
in { type: "text", text: }
# ...
in { type: "image", image: , width:, height: }
# ...
in { type: "url", url: }
# ...
end
end
ないとき
ary.each do |params|
case params[:type]
when "text"
text = params[:text]
when "image"
image = params[:image]
height = params[:height]
width = params[:width]
when "url"
url = params[:url]
end
end
これは結構わかりやすくなったかもしれない。
やはり実戦的なパターンマッチとしてはArrayやHashやObjectが鍵なきがする。
Overloadもどき
パターンマッチがあるとき
class Overload
def method_missing(name, *args)
case [name, args]
in [:hello, []]
puts "hello"
in [:hello, [a]]
puts "hello #{a}"
in [:hello, [a, Integer => i]]
puts("hello #{a}!" * i)
end
end
end
o = Overload.new
o.hello #=> hello
o.hello('ksss') #=> hello ksss
o.hello('ksss', 3) #=> hello ksss!hello ksss!hello ksss!
o.hello('ksss', 'yo') #=> in `method_missing': [:hello, ["ksss", "yo"]] (NoMatchingPatternError)
ないとき
class Overload
def method_missing(name, *args)
case args.length
when 0
puts "hello"
when 1
puts "hello #{args[0]}"
when 2
raise ArgumentError unless args[1].kind_of?(Integer)
puts("hello #{args[0]}!" * args[1])
else
super
end
end
end
o = Overload.new
o.hello #=> hello
o.hello('ksss') #=> hello ksss
o.hello('ksss', 3) #=> hello ksss!hello ksss!hello ksss!
o.hello('ksss', 'yo') #=> in `method_missing': ArgumentError (ArgumentError)
これもやりたいイメージにはパターンマッチが合ったほうが近い感じになる。
Kernel#system
みたいな引数が複雑なメソッドを実装したいときにいいかも。
やはりインプットが薄いと限界があるので、調査班はアマゾンの奥地に潜入した……!
パターンマッチは、高級なif/case文。複雑な記述をスッキリ書くためにある。
静的言語ではパターンが網羅されてるからチェックする機能もある。
rubyの今の文法や実装範囲は本当に色々考えられた結果。採用しなかった仕様も理由をつけて多く語られている。
rubyにオーバーロードは検討されている。(もちろんパターンマッチと組み合わせて)
多重代入や、キーワード引数もパターンマッチだよねと語られている。
JSでもある、欲しい値だけ名前つけて取り出したいときも使えそう。
def api
{ data: "...", error: "...", is_valid: false, func: ->(){ ... } }
end
api => { data: code }
code.each { ... }
このとき、返り値をArrayにする場合は順番を意識する必要があるしsplatが必要。
def api
["...", "...", false, ->(){ ... }]
end
api => [code, *]
code.each { ... }