Ruby: パターンマッチングをカスタムオブジェクトで実装するときの注意点(翻訳)
こんにちは皆さん。
私はハルキウ出身のウクライナ人Rubyistです。私の国ではまだ戦争が続いています。ロシアは2/24に全面侵攻を開始し、8年間におよぶハイブリッド戦争が継続しています。私の街は数か月前よりも前線から遠ざかり、直接的な脅威は減ったものの、ロシアの砲撃によって先週の金曜日にも全面的に停電しました(03/03と03/15の記事もご覧ください)。
そんな状況にもかかわらず、Ruby 3.2のリリースを控えている今、仕事と家庭とボランティア活動の合間に少し時間が取れたので、Rubyについての執筆を緩やかに再開しつつあります。私の記事はSubstackでフォローいただけます。
今回は、クラスでパターンマッチングを正しく実装するときに考慮すべき点を解説する(やや)小さめの実用記事です。
🔗 私たちがパターンマッチングでやっていること
Ruby 2.7で真の強力なパターンマッチングが実験的機能として導入され、3.0〜3.1で磨きをかけてきました。パターンマッチングによって、データの構造に応じて分岐できるようになります。
case user
in role: 'admin', name:
puts "Hello, mighty #{name}!"
in role: 'user', registered_at: ...Date.new(2022)
puts "Hi, old friend"
else
puts "I don't know you well yet"
end
データ構造のチェックにも使えます。
if post in status: 'pending', author: {role: 'editor'}
# ...
既知の構造からデータを取り出すこともできます。
config => {db: {name:, user:, password:}, logger: {target:}}
# ここでは`name`、`user`、`password`、`target`変数が利用可能で、
# configから特定の値を設定する
# 期待される構造とconfigがマッチしない場合は、
# NoMatchingPatternが発生する
この方法の最も素晴らしい点は、素の配列やハッシュにマッチできるだけではなく、#deconstruct
や#deconstruct_keys
を実装する任意のオブジェクトにマッチできることです。
12/24にリリース予定のRuby 3.2で、私はRubyのTime
とDate
およびDateTime
にパターンマッチングによるデータの分解(deconstruction)機能を追加しました。
本記事では、これを独自オブジェクトで行いたい場合に役に立つと思われる一般的な所見をいくつか紹介したいと思います。
たとえばruby-head
や、本記事でこれから書くコードでは、基本的に以下のようなことができるようになります。
require 'date'
case Date.today
in year: ...2022
puts "昔むかし..."
in month: 6..8, day:
puts "今日は美しい夏の月の#{day}日..."
in wday: 3
puts "水曜日だよ、おい!"
# ...etc.
これについて、いくつかの簡単なルールを紹介します。
🔗 1: 良いキーを選ぶこと
ハッシュパターン(case
ブランチとin
または=>
)でマッチするオブジェクトの場合、Rubyはそのオブジェクトが#deconstruct_keys
メソッドに応答するかどうかをチェックしてから、そのパターンに含まれるキーでこのメソッドを呼び出します。
たとえば以下の場合、
# yearが2022の場合にのみマッチする
# monthとdayはローカル変数にunpackされる
Date.today in year: 2022, month:, day:
RubyはDate#deconstruct_keys
メソッドを[:year, :month, :day]
というキーで呼び出し、これらのキーと、対応する値のハッシュを返すことが期待されます。
実装は難しくありません。
class Date
# このメソッドの書き方は"山ほど"あるが、
# あえてうんと昔の書き方を選んだ
def deconstruct_keys(keys)
res = {}
keys.each do |k|
case k
when :year then res[k] = year
when :month then res[k] = month
when :day then res[k] = day
end
end
res
end
end
これを、たとえばRuby 3.1(Date#deconstruct_keys
をネイティブで搭載していない)で実行すると、ちゃんと動くことがわかります。
if Date.today in year: 2022, month: 12, day:
p "Dec #{day}!"
end
実はRuby 3.0で試す場合は、パターンを{}
で囲まなければなりませんでした。
if Date.today in {year: 2022, month: 12, day:}
p "Dec #{day}!"
end
これができれば十分でしょうか?日付データのすべての要素を分解して取り出せるなら、他に欲しいものがあるでしょうか?しかし優れた日付データ分解機能は最小限と決まっているわけではないので、たとえばwday
(曜日)をサポートしてみてはどうでしょう?(Ruby 3.2の実装ならできます)
class Date
def deconstruct_keys(keys)
res = {}
keys.each do |k|
case k
when :year then res[k] = year
when :month then res[k] = month
when :day then res[k] = day
when :wday then res[k] = wday
end
end
res
end
end
すると、「2022年の各月の第1木曜日」を以下のようにワンライナーで書けるようになるのです。
# 年の始めの日から+1日するシーケンスを無限にproduceする
Enumerator
.produce(Date.new(2022)) { _1 + 1 }
.lazy
.select { _1 in day: ..7, wday: 4 } # その中から、その月の最初の7日間にある
# 木曜日を取り出す
.take_while { _1 < Date.today }
.to_a
#=> [#<Date: 2022-01-06>,
# #<Date: 2022-02-03>,
# #<Date: 2022-03-03>,
# ...
🔗 2: 未知のキーが来たときに失敗させないこと
有効な入力の有限集合に限定される(つまりDate#deconstruct_keys
で特定のキーだけをサポートする)ことがわかってくると、サポートされていない入力が来たら例外をraise
してエラーから保護したい誘惑にかられます。
しかし、#deconstruct_keys
の場合はおそらく間違っています。上のcase
に以下を追加した場合を想像してみましょう。
# ...
when :day then res[k] = day
when :wday then res[k] = wday
else raise ArgumentError, "Unsupported key #{k}"
時刻と日付がさまざまな形式で入り混じった集合に対して何らかの複雑な処理を行うときに、以下のような感じでやってみたとします。
case creation_mark
in year:,month:,day:,hour:
# ...
in year:,month:,day:
# ...
in String
# ...
end
すると、未知のキーを単に無視する実装は(正しく)最初の分岐で早々にスキップされますが、raise
する実装はマッチング全体がUnsupported key hour (ArgumentError)
で壊れてしまうでしょう。おそらくユーザーが最も期待しそうなのは「マッチするな、このブランチを単にスキップしろ!」という振る舞いだったでしょう。
この問題を踏んでしまう別のケースは、メタプログラミングでスマートに処理しようとした場合です。スマートに実装するのは以下のように簡単です。
class Date
# 超モダンなRuby(ワンライナーメソッドと暗黙のブロック引数を備える)に
# なつかしのメタプロを併用する
def deconstruct_keys(keys) = keys.to_h { [_1, public_send(_1)] }
end
この場合、以下は動きます。
Date.today in year: 2022 # OK
しかし、たちまちこうなります。
timestamp = Date.today
# ...
timestamp in hour:, min: # 謎のNoMethodError
ところで、この「認識されないキー」で興味深いのは、認識されないキーが存在すればこのパターンは間違いなくマッチしなくなるので、データをまったく返さないのはOKだという点です。なので以下はOKです。
# ...
when :day then res[k] = day
when :wday then res[k] = wday
else return {} # それ以上キーを調べようとせず即座にreturnする
余談: 仮に誰かが本当にパターンマッチングで
Date
とTime
を混ぜたとしても、Rubyは分解処理はもちろん、どんなクラスが期待されているかを明確にするうまい方法も提供しています(いつでも使えるので、そのための実装は不要です)。case creation_mark in Time(year:,month:,day:,hour:) # ... in Date(year:,month:,day:) # ... in String # ... end
🔗 3: だからといって未知のキーを誤ってサポートしないこと
オブジェクトの知識にまったく存在しないキーが来たらraise
するのは悪手ですが、かといってそれをそのまま吐き出すのも同じぐらい悪手です。上述の「クールなメタプログラミング版」を以下のように修正したくなるかも知れません。
class Date
def deconstruct_keys(keys)
keys.to_h { [_1, public_send(_1) if respond_to?(_1)] }
end
end
# すると...
Date.today.deconstruct_keys(%i[year month day hour])
#=> {:year=>2022, :month=>12, :day=>19, :hour=>nil}
この実装の問題点は、構造のパターンマッチングが「このデータは期待される構造とマッチするか?」というチェックをさんざん繰り返した末に、Date
の知識に存在しないキーを返すので、約束が果たされないことです。
以下のようなコードを考えてみましょう。
def print_time(tm)
tm => hour:, min:
puts "%02i:%02i" % [hour, min]
end
print_time(Time.now)
# 出力: "22:01"
# ただしRuby 3.2でのみ有効。それ以前のバージョンではTimeを分解できません :)
このメソッドの1行目は興味深い効果があります。すなわち、tm
が契約どおりであるかのチェック(tm
にはhour
とmin
があるという契約が果たされなければ即座にNoMatchingPattern
をraise
する)と、それらをローカル変数に代入して扱いやすくするという2つの処理を(右代入=>
で)1度に行っていることです。
上述の欠陥実装はどんなキーを与えてもnil
を返し、壊れたときのデバッグも極めて難しくなってしまいます。
print_time(Date.today) # in `%': can't convert nil into Integer (TypeError)
一方、正しい実装(未知のキーは返さない)でもエラーは起きますが、こちらはメソッド作者の期待通りであり、有益な情報を出力します。
in `print_time': #<Date: 2022-12-19 ...>: key not found: :hour (NoMatchingPatternKeyError)
実を言うと、「スマートな」実装の方は他にも問題が山盛りです(
d in iso8601:
のようにおかしなものにも応答してしまうなど)が、あくまで余談です。
🔗 4: 「任意のキー」がオプションで渡される可能性を忘れないこと
パターンマッチング句では、以下のように「ハッシュの残りの部分はこの変数に代入せよ」と(**
で)指定できます。
Date.today in year: 2022, **rest
この場合、#deconstruct_keys
メソッドはnil
を受け取り、サポートするすべてのキーを返すことが期待されるので、実際の「正しい」実装は以下のようになります。
class Date
def deconstruct_keys(keys)
if keys
res = {}
keys.each do |k|
case k
when :year then res[k] = year
when :month then res[k] = month
when :day then res[k] = day
when :wday then res[k] = wday
end
end
res
else
{year:, month:, day:, wday:}
end
end
end
Date.today in year: 2022, **rest
pp rest #=> {:month=>12, :day=>19, :wday=>1}
ここで興味深い点を指摘しておきたいと思います。実は、#deconstruct_keys
のkeys
引数は最適化のために導入されたものなのです。パターンに含まれるキーが多くない場合、そのオブジェクトは、返すものを制限する「可能性」が生じます(制限する義務はありません)。しかし、常に既知のキーをすべて返す可能性もあります。これはエラーにはなりません!
#deconstruct_keys
でkeys
引数が必要となるのは、計算に時間のかかる値がある場合がほとんどですが、オブジェクトがDate
のようにシンプルな場合は、この最適化の意味はあまりありません。仮に私がRubyで小さいDate
的なカスタムクラスに#deconstruct_keys
を実装するなら、以下のようにシンプルに書いて済ませるでしょう。
class Date
def deconstruct_keys(*) = {year:, month:, day:, wday:}
end
🔗 5: やりすぎないこと
#deconstruct_keys
を実装するのはそれほど難しくありませんし、パターンマッチは見た目も麗しいので、少々やりすぎてみたい誘惑にかられるかもしれません。
たとえば、曜日番号は何かと覚えにくいので(0
が日曜日ってなぜ?)、以下のように書いたとします。
class Date
def deconstruct_keys(*)
{year:, month:, day:, wday:, wday_name: strftime('%A')}
end
end
続いて以下のように、いい感じに覚えやすくしたとします。
Date.today in wday_name: 'Monday' #=> true
これはこれで、状況によってはクールで表現力を感じられるかもしれませんが、パターンマッチングが本当に輝くのは大量のデータを処理するときなのです。
私たちはたった今、ほんの1、2箇所を「ちょっといい感じ」にしたいがために、それと引き換えにDate
をマッチングするたびに発生するパフォーマンス上のペナルティを導入してしまったのです、おそらく。
(strftime
は地上で最も遅いメソッドではありませんが、間違いなくシンプルなDate
ゲッターメソッドより遅くなります。ここで私が強調しておきたい原則は「deconstruct_keys
で余計な値を分解する前によーく考えること」です。余分な計算が必要な値もそうですし、DBからフェッチする値はなおさらです。)
🔗 分解の結果を配列にする方法
これまでにお話ししたのは、キー:
やキー: パターン
といったハッシュパターンへのマッチングを処理する#deconstruct_keys
メソッドでした。
(訳注: Ruby 3.2のTime
やDate
に)実装される可能性があったもう1つのメソッドは、配列を分解する#deconstruct
メソッドです。このメソッドのプロトコルはシンプルで、オブジェクト表現を配列として返します。何らかの論理的順序が定義されていれば、その順序で配列を返します。
class Date
def deconstruct = [year, month, day]
end
たとえば上のようにすると、以下のように書けます。
if Date.today in [2022, month, *]
puts "#{month} month of this year"
end
# => "12 month of this year"が出力される
#deconstruct
にはいくつか注意点があります。
- ハッシュパターンの場合と異なり、配列パターンは
deconstruct
の「すべての戻り値」にのみマッチします。つまり、以下はマッチしません。
Date.today in 2022, month
#=> false (マッチするものがmonthより後ろにないため)
- 配列パターンを使う意味があるのは、オブジェクト全体に対応する明確な論理的順序が存在する場合だけです(サンプル実装には曜日を置く場所が"ない"ので混乱するかもしれません)。
私たちがRuby 3.2のTime
やDate
を実装したときは、最初にTime
から手を付けました。そのとき、「Time
のすべてのコンポーネントの順序や集合はそこまで明確ではない」というMatzの決定が下されたため、Rubyのそれらのクラスには#deconstruct
は存在しません。
でも、自分のクラスに実装するなら意味があるかもしれません!
🔗 終わりの言葉
記事の末尾で「本記事を形にできたのは、ひとえに○○株式会社/組織/財団のお力添えのおかげです」などの謝辞を見たことがあるでしょう。
私の場合はこうです。私がハルキウの自宅でくつろぎながら腰掛け、いくばくかの自由時間と電力を使って本記事を書けたのは(そしてRubyコアでこれらの変更を実装できたのも)、ひとえに以下の方々のお力添えのおかげなのです。
- ウクライナ軍
- 国際的な兵器供与/義援金/広報のサポート
どうかご寄付を、そして声を上げてください!
Слава Україні! 🇺🇦
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。deconstructの仮訳は「分解」としています。
参考:
Data#deconstruct
(Ruby 3.2 リファレンスマニュアル)参考:
Data#deconstruct_keys
(Ruby 3.2 リファレンスマニュアル)なお、記事中のサンプルコードにある
Date.today
メソッドを使うにはrequire 'date'
が必要です。参考:
Date.today
(Ruby 3.2 リファレンスマニュアル)また、記事中のサンプルコードの中には、現在が2022年12月であることを前提としているものもいくつかあります。コード中の年や月が
2022
や12
のままだと期待通りに動作しないものもあるので、サンプルコードを動かすときは現在の年や月(2023
や3
など)に適宜変更してください。