Ruby のパターンマッチング機能を MinRuby で試す
Ruby 2.7 で導入予定で,すでに Ruby リポジトリの trunk (いわゆる master ブランチのこと) にマージ済みの「パターンマッチング」機能を試してみたので,そのメモ書きです. 特に包括的に検証したわけではないので注意してください.
パターンマッチング
(わざわざ解説することでもないけど)
パターンマッチングは if
文や case
文のようなプログラムの分岐に使うプログラミング機能. if
文が真偽値を返す条件式 (e.g. a > 0 && x == 'hoge'
) の結果により分岐し,case
文が指定した変数の値によって分岐するのに対し,パターンマッチングは指定した変数のデータ構造によって分岐する.
例えば Ruby に導入されたパターンマッチングだと次のようになる:
case var # var のデータ構造により分岐
in []
puts "var is empty list"
in [a]
puts "var is singleton: #{a}" # 変数 a に値を代入する
in [:hoge, a, b]
puts "var is hoge list: #{[a, b]}" # 一要素目が :hoge の3要素リスト
else
puts "No match: #{var}" # else はどれにもマッチしないとき
end
このようにデータ構造(例えば配列の要素数など)によって分岐かつ変数への代入が可能になる. パターンマッチングは様々なデータを扱うようなプログラミングを行う時に極めて簡潔にかつ直感的にプログラムを記述することができる.
ちなみに,パターンマッチングがあれば基本的に if
文も case
文も要らない. どちらもパターンマッチングの糖衣構文として表現でき,現に Haskell ではそうなっている(たぶん).
Ruby のパターンマッチング
ちょこちょこ既に記事があるが,RubyKaigi 2019 でも作者からの発表があり参考になる:
すでに YouTube で動画も公開された. ちなみに,2012 ぐらいからずっと作っていたらしい.
Elixir のピンパターン(^var
)など,数多くのパターンマッチング機能がある(後発の利点ですね). ただし,変数のスコープが個人的には思ってたのと違った:
001:0> case [1, 2]
irb(main):002:1> in [a, 3] then p a
irb(main):003:1> in [b, c] then p c
irb(main):004:1> end
irb(main):2
=> 2
005:0> [a,b,c]
irb(main):=> [1, 1, 2]
in ..
ごとにスコープは閉じてるのが一般的な気がするけど Ruby でそれは難しいのだろうか(if
文や case
文でもこんな感じの挙動).
試す
Ruby2.7-dev
前述した通り,パターンマッチングは trunk にマージされているので Ruby2.7-dev で試すことができる. trunk を試す方法はいくつかあると思うが,僕は手っ取り早く rbenv を使った.
$ rbenv install 2.7.0-dev
MinRuby
パターンマッチングを試す対象として,「Ruby で学ぶ Ruby」という連載で作っている,かなり簡易的な Ruby のサブセット処理系 MinRuby を利用する.
最終的な処理系は Ruby コード一枚でできている(一番めんどくさい構文解析を ripper とそのラッパー minruby というのに任せているので):
# interp.rb
require "minruby"
def evaluate(tree, genv, lenv)
case tree[0]
when "lit"
[1]
treewhen "+"
[1], genv, lenv) + evaluate(tree[2], genv, lenv)
evaluate(treewhen "-"
[1], genv, lenv) - evaluate(tree[2], genv, lenv)
evaluate(treewhen "*"
[1], genv, lenv) * evaluate(tree[2], genv, lenv)
evaluate(treewhen "/"
[1], genv, lenv) / evaluate(tree[2], genv, lenv)
evaluate(treewhen "%"
[1], genv, lenv) % evaluate(tree[2], genv, lenv)
evaluate(treewhen "<"
[1], genv, lenv) < evaluate(tree[2], genv, lenv)
evaluate(treewhen "<="
[1], genv, lenv) <= evaluate(tree[2], genv, lenv)
evaluate(treewhen "=="
[1], genv, lenv) == evaluate(tree[2], genv, lenv)
evaluate(treewhen "!="
[1], genv, lenv) != evaluate(tree[2], genv, lenv)
evaluate(treewhen ">="
[1], genv, lenv) >= evaluate(tree[2], genv, lenv)
evaluate(treewhen ">"
[1], genv, lenv) > evaluate(tree[2], genv, lenv)
evaluate(treewhen "stmts"
= 1
i = nil
last while tree[i]
= evaluate(tree[i], genv, lenv)
last = i + 1
i end
lastwhen "var_assign"
[tree[1]] = evaluate(tree[2], genv, lenv)
lenvwhen "var_ref"
[tree[1]]
lenvwhen "if"
if evaluate(tree[1], genv, lenv)
[2], genv, lenv)
evaluate(treeelse
[3], genv, lenv)
evaluate(treeend
when "while"
while evaluate(tree[1], genv, lenv)
[2], genv, lenv)
evaluate(treeend
when "func_def"
[tree[1]] = ["user_defined", tree[2], tree[3]]
genvwhen "func_call"
= []
args = 0
i while tree[i + 2]
[i] = evaluate(tree[i + 2], genv, lenv)
args= i + 1
i end
= genv[tree[1]]
mhd if mhd[0] == "builtin"
[1], args)
minruby_call(mhdelse
= {}
new_lenv = mhd[1]
params = 0
i while params[i]
[params[i]] = args[i]
new_lenv= i + 1
i end
[2], genv, new_lenv)
evaluate(mhdend
when "ary_new"
= []
ary = 0
i while tree[i + 1]
[i] = evaluate(tree[i + 1], genv, lenv)
ary= i + 1
i end
arywhen "ary_ref"
= evaluate(tree[1], genv, lenv)
ary = evaluate(tree[2], genv, lenv)
idx [idx]
arywhen "ary_assign"
= evaluate(tree[1], genv, lenv)
ary = evaluate(tree[2], genv, lenv)
idx = evaluate(tree[3], genv, lenv)
val [idx] = val
arywhen "hash_new"
= {}
hsh = 0
i while tree[i + 1]
= evaluate(tree[i + 1], genv, lenv)
key = evaluate(tree[i + 2], genv, lenv)
val [key] = val
hsh= i + 2
i end
hshend
end
= minruby_load()
str
= minruby_parse(str)
tree
= {
genv "p" => ["builtin", "p"],
"require" => ["builtin", "require"],
"minruby_parse" => ["builtin", "minruby_parse"],
"minruby_load" => ["builtin", "minruby_load"],
"minruby_call" => ["builtin", "minruby_call"],
}
= {}
lenv evaluate(tree, genv, lenv)
コードを見て分かるように(?),配列の一引数目のリテラルで case
文による分岐をし,分岐先で配列の要素を引っ張っている. このようにデータ構造 + case
文による分岐はパターンマッチングにうってつけのユースケースだ.
MinRuby + パターンマッチング
作業リポジトリはこれ:
pattern-match
というブランチにパターンマッチングで書き換えたコードがある. パターンマッチングで書き換えたのは evaluate
関数だけなのでそこだけ載せる:
def evaluate(tree, genv, lenv)
case tree
in "lit", lit
litin "+", exp1, exp2
+ evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "-", exp1, exp2
- evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "*", exp1, exp2
* evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "/", exp1, exp2
/ evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "%", exp1, exp2
% evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "<", exp1, exp2
< evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "<=", exp1, exp2
<= evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "==", exp1, exp2
== evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "!=", exp1, exp2
= evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) !in ">=", exp1, exp2
>= evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in ">", exp1, exp2
> evaluate(exp2, genv, lenv)
evaluate(exp1, genv, lenv) in "stmts", *stmts
= nil
last = 0
i while stmts[i]
= evaluate(stmts[i], genv, lenv)
last = i + 1
i end
lastin "var_assign", var_name, var_value
[var_name] = evaluate(var_value, genv, lenv)
lenvin "var_ref", var_name
[var_name]
lenvin "if", cond, exp1, exp2
if evaluate(cond, genv, lenv)
evaluate(exp1, genv, lenv)else
evaluate(exp2, genv, lenv)end
in "while", cond, exp
while evaluate(cond, genv, lenv)
evaluate(exp, genv, lenv)end
in "func_def", func_name, func_args, func_body
[func_name] = ["user_defined", func_args, func_body]
genvin "func_call", func_name, *func_args
= []
args = 0
i while func_args[i]
[i] = evaluate(func_args[i], genv, lenv)
args= i + 1
i end
= genv[func_name]
mhd if mhd[0] == "builtin"
[1], args)
minruby_call(mhdelse
= {}
new_lenv = mhd[1]
params = 0
i while params[i]
[params[i]] = args[i]
new_lenv= i + 1
i end
[2], genv, new_lenv)
evaluate(mhdend
in "ary_new", ary_values
= []
ary = 0
i while ary_values[i]
[i] = evaluate(ary_values[i], genv, lenv)
ary = i + 1
i end
in "ary_ref", ary_exp, idx_exp
= evaluate(ary_exp, genv, lenv)
ary = evaluate(idx_exp, genv, lenv)
idx [idx]
aryin "ary_assign", ary_exp, idx_exp, value_exp
= evaluate(ary_exp, genv, lenv)
ary = evaluate(idx_exp, genv, lenv)
idx = evaluate(value_exp, genv, lenv)
val [idx] = val
aryin "hash_new", *key_values
= {}
hsh = 0
i while key_values[i]
= evaluate(key_values[i], genv, lenv)
key = evaluate(key_values[i + 1], genv, lenv)
val [key] = val
hsh= i + 2
i end
hshend
end
配列にマッチさせる場合,in [a, b, c]
の []
を省くことができる. また,in "hoge", *rest
は配列の残りの要素全てを *rest
にマッチさせる構文だ. 他は特別な機能を使ってないのできっと読めるでしょう.
おまけ: minruby + パターンマッチング
試しに minruby
もパターンマッチで書き換えてみた. 差分はこれ. めちゃくちゃやっつけで作ったので穴があるかもしれない.
ここでは新しく Alternative Pattern を使っている. こういうのだ:
# Alternative Pattern: hoge | fuga
in (:program | :bodystmt), exp1, *_
make_stmts(exp1)
Alternative Pattern には注意点があって,このパターンでは変数へのマッチを利用することができない:
# Error: illegal variable in alternative pattern
in (:program exp1, *_ | :bodystmt exp1, *_),
make_stmts(exp1)
ここからは余談. MinRuby は ruby interp.rb interp.rb fizzbuzz.rb
のように自身を自身で評価することが可能だ(そのため map
や foreach
などを使わずに少し冗長なコードになっている). しかし,パターンマッチングを導入しちゃうとこれができない. なんとかできないかなぁと思って minruby
をパターンマッチングで書き換えてみたけど,まぁ無理でした. いいアイデアあったら教えて.
おしまい
次は型検査も試したいですね.