Ruby 3パターンマッチング応用(3)パターンマッチング高速化マクロ(翻訳)
はじめに
私は最近Matchableという新しいgemをリリースしました。Matchableは、パターンマッチングのインターフェイスに使えるクラスレベルのマクロメソッドを導入します。
class Person
include Matchable
deconstruct :new
deconstruct_keys :name, :age
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
end
本記事は、このMatchable gemを支える概念やしくみについての解説です。
難易度と必要な予備知識
- 難易度: 先に進むに連れて難しくなります。ある程度の高度な知識が求められ、メタプログラミングを深堀りします。
本記事では以下の予備知識が必要です。
- Rubyの「メソッドデコレーション」と「
prepend
」(「Decorating Ruby」シリーズ(英語記事))method_added
フック- モジュールを
prepend
してメソッドをアタッチする
- パターンマッチング(『Pattern Matching Interfaces in Ruby』)
- メソッドデストラクション『Destructuring Methods in Ruby』)
class_eval
(『Idiosyncratic Eval』)
今後eval
についての記事を書くかもしれませんが、それまではこの記事の基礎を理解するのにこれらが役立つはずです。本記事ではこれらの概念のいくつかについて簡単に説明しますが、深掘りはしません。
Matchable gemについて
Matchable gemの屋台骨となるアイデアは、パターンマッチングフック用にいい感じのインターフェイスをRubyクラスのトップレベルに作るというものです。
class Person
include Matchable
deconstruct :new
deconstruct_keys :name, :age
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
end
生成されたコード
上のコードから、以下のような効率の良いコードが生成されます。
class Person
MATCHABLE_KEYS = %i(name age)
MATCHABLE_LAZY_VALUES = {
name: -> o { o.name },
age: -> o { o.age },
}
def deconstruct
to_a
rescue NameError => e
raise Matchable::UnmatchedName, e
end
def deconstruct_keys(keys)
return { name: name, age: age } if keys.nil?
deconstructed_values = {}
valid_keys = MATCHABLE_KEYS & keys
valid_keys.each do |key|
deconstructed_values[key] = MATCHABLE_LAZY_VALUES[key].call(self)
end
deconstructed_values
rescue NameError => e
raise Matchable::UnmatchedName, e
end
end
他の実装であれば、以下のようなより動的なアプローチを採用するでしょう。
class Person
VALID_KEYS = %i(name age).freeze
def deconstruct() = VALID_KEYS.map { public_send(_1) }
def deconstruct_keys(keys)
valid_keys = keys ? VALID_KEYS & keys : VALID_KEYS
valid_keys.to_h { [_1, public_send(_1)] }
end
end
生成されたコードの方が好ましい理由ですか?ベンチマーク結果をご覧ください。有効なキーの数がかなり多いオブジェクトの場合、パフォーマンスの差は40~50%ほどに達する可能性があります。このgemの目標は、100%エレガントなコードを生成することではなく、極めて頻繁に実行される可能性が高い機能向けに、すぐ使えるコードを生成することです。
このコードにはさまざまなものが盛り込まれています。それでは一緒にコードを探検してみましょう。よろしいですか?
prepend
されたモジュール
attr_
メソッドのようなマクロスタイルのメソッドを動かすための最初のコツは、メソッドを動かすためのモジュールをinclude
することです。ここではprepend
しているので、これらのメソッドを明確な名前のエンティティにすべてアタッチして後でバックトレースを少し読みやすくできます。
module Matchable
MODULE_NAME = "MatchableDeconstructors".freeze
def self.included(klass) = klass.extend(ClassMethods)
module ClassMethods
def deconstruct() = nil # TODO
def deconstruct_keys(keys) = nil # TODO
private def matchable_module
if const_defined?(MODULE_NAME)
const_get(MODULE_NAME)
else
const_set(MODULE_NAME, Module.new).tap(&method(:prepend))
end
end
end
end
このようなコードを読み慣れていない方は、ぜひDecorating Rubyシリーズをお読みください。
他のインターフェイスメソッドの定義については後ほど説明しますが、手始めにdeconstruct
とdeconstruct_keys
メソッドを結びつける最初のモジュールを利用可能にしたいと思います。
パターンマッチング用の新しいメソッドは、上のコードにあるmatchable_module
ですべてアタッチします。これは新しいモジュールのコンテナとお考えください。
MatchableDeconstructors > Object > ...
ここでinclude
を代わりに使ってもよいのですが、prepend
を使えばユーザーが後で作る可能性のあるデコンストラクションメソッドにフックできるようになり、使うAPIオプションも少なくて済みます。
ここでのポイントは、deconstruct
がモジュール上でdeconstruct
メソッドを定義し、deconstruct_keys
も同様にdeconstruct_keys
を定義することです。フックができたので、実際のメソッドを見てみましょう。
deconstruct
(ソースコード)
上の見出しでリンクしているソースコードにはみっちりコメントとドキュメントを付けてあります。
deconstruct
はRubyのArray的なマッチに対応していて実装も簡単な方なので、ここから見ていきましょう。
module Matchable
module ClassMethods
def deconstruct(method_name)
return if matchable_module.const_defined?("MATCHABLE_METHOD")
method_name = :initialize if method_name == :new
matchable_module.const_set("MATCHABLE_METHOD", method_name)
if method_defined?(method_name)
attach_deconstructor(method_name)
return [true, method_name]
end
@_awaited_deconstruction_method = method_name
[false, method_name]
end
end
end
定数と再代入
最初に、deconstruct
が定義済みであることを示すためにMATCHABLE_METHOD
定数にバインディングされています。定数が定義されていない場合は、その後すぐに定数を設定して、この部分が複数回呼ばれないようにしています。
また、:new
の場合は:initialize
を再代入しています(一般的に:new
と解釈される可能性が高いため)。このメソッドの特殊な振る舞いについては後ほど解説します。
method_defined?
ひとつ興味深いメソッド呼び出しがあります。
if method_defined?(method_name)
メソッドが定義済みかどうかをチェックする必要がある理由とは何でしょうか?この先を読み進める前に、ちょっと立ち止まって考えてみてください。私もここで数分ほど頭をかきむしってしまいました。
その理由とは、マクロスタイルのメソッドが「クラスの冒頭部分」で定義されているからです。つまり、同じメソッドが初期化されるより「前に」定義されるのです。マクロスタイルのメソッドはattr_
のようなSymbol
メソッドでは動作しないので、最初に存在している必要があります。
つまりメソッドが定義済みかどうかをチェックしないと、たとえば以下のコードが正常に動かなくなります。
class Person
include Matchable
deconstruct :new
attr_reader :name, :age
def initialize(name, age)
end
end
動かない原因は、initialize
がまだ定義されていないためです。このチェックがコードで重要な理由についてはこの後で解説しますが、最初にこの問題を回避する方法について説明しておきます。
method_added
先ほどのコードのすぐ下で、以下のようにフラグが設定されています。
@_awaited_deconstruction_method = method_name
冒頭の予備知識などで読んだことがあるかと思いますが、method_added
にはフックがあり、新しく定義されたメソッドに対して呼び出されます。今はメソッドが存在していなくても、いずれ存在することになるので、これをフックしておきます。
def method_added(method_name)
return unless defined?(@_awaited_deconstruction_method)
return unless @_awaited_deconstruction_method == method_name
attach_deconstructor(method_name)
remove_instance_variable(:@_awaited_deconstruction_method)
nil
end
探しているメソッドが存在すればデコンストラクタをアタッチし、存在しない場合は先に進みます。戻り値がnil
になっているのは、戻り値が重要でないのと、後で戻り値を誰かに使われないようにするためです。
これでメソッドが最終的に作成されたときにメソッドをインターセプト(intercept: 傍受)するしくみができたので、attach_deconstructor
とそのしくみ、そしてこのトリックが必要な理由について見てみたいと思います。
attach_deconstructor
先に進む前に、parameters
が何をするのか、どう役に立つのかについてご存じない方は、もしお読みでなければ『Destructuring Methods in Ruby』に必ず目を通しておいてください。
それではattach_deconstructor
メソッドを見ていくことにしましょう。
private def attach_deconstructor(method_name)
deconstruction_code =
if method_name == :initialize
i_method = instance_method(method_name)
param_names = i_method.parameters.map(&:last)
"[#{param_names.join(', ')}]"
else
method_name
end
matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
def deconstruct
#{deconstruction_code}
rescue NameError => e
raise Matchable::UnmatchedName, e
end
RUBY
nil
end
初期の振る舞いをブランチさせる
最初に行うのは、 initialize
呼び出しであるかどうかの確認です。すべてのパラメタ名を取得してArray
で囲み、メソッドを直接呼び出す代わりにそれを返したいと思います。
Person
の場合は、これを返します。
def deconstruct() = [name, age]
attr_
が存在していることを確認しておきましょう。さもないと問題が発生します。
通常のメソッドの振る舞い
initialize
でない場合は、呼び出すメソッド名を返します。
この動作はalias
と似ていますが、これをカスタム例外でラップしたいので、to_a
の部分は以下のようになります。
def deconstruct
to_a
rescue NameError => e
raise Matchable::UnmatchedName, e
end
これは、これを動かすのに必要なメソッドが不足していることを示すためにNameError
に追加メッセージをラップしています。
class_eval
いよいよ本記事の白眉であるclass_eval
にさしかかりました。
ここではclass_eval
を使って、上で定義したモジュール内で組み立てたコードをもう少し詳しく手動で評価し、それをクラスの手前にprepend
してパターンマッチングメソッドを渡します。
matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
def deconstruct
#{deconstruction_code}
rescue NameError => e
raise Matchable::UnmatchedName, e
end
RUBY
ブロックを使わずに文字列にした理由ですか?ここでやりたいことの中には、RubyコードでRubyコード自身を書かないとできないことがあるからです。より最適化されたメソッドを提供するために、eval
メソッドを用いてさまざまなものを「コンパイル」する必要があります。
なお、コード中の__FILE__
や__LINE__ + 1
は、この評価されたコードがRubyプログラムの適切な流れの中に置かれていることを後で確認するときに、デバッガやバックトレースなどのツールで見つけられるようにするためのものです。
これらを置いた理由について詳しくはこちらの記事をご覧ください。要は、こうしておけばトラブルシューティングが後々ずっとやりやすくなるのです。
deconstruct
については以上です。お次はdeconstruct_keys
セクションですが、ここはちょっとした旅行です。
keys
をデコンストラクトする(ソースコード)
上の見出しでリンクしているソースコードにはみっちりコメントとドキュメントを付けてあります。
ここでは以下のような考慮すべき点がいくつかあるため、deconstruct
よりもかなり複雑になっています。
- 最適化のために扱うべき
key
のリストが存在する key
が意味する応答はキーごとに異なるkeys
がnil
の場合、返せるキーをすべて返す必要がある
そのため、このコードの理解や動的コンパイルがやや難しくなるかもしれませんが、これを動かす方法はまだあります。
まずはコードを見てみましょう。
def deconstruct_keys(*keys)
return if matchable_module.const_defined?('MATCHABLE_KEYS')
sym_keys = keys.map(&:to_sym)
matchable_module.const_set('MATCHABLE_KEYS', sym_keys)
matchable_module.const_set('MATCHABLE_LAZY_VALUES', lazy_match_values(sym_keys))
matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
def deconstruct_keys(keys)
if keys.nil?
return {
#{nil_guard_values(sym_keys)}
}
end
deconstructed_values = {}
valid_keys = MATCHABLE_KEYS & keys
valid_keys.each do |key|
deconstructed_values[key] = MATCHABLE_LAZY_VALUES[key].call(self)
end
deconstructed_values
rescue NameError => e
raise Matchable::UnmatchedName, e
end
RUBY
nil
end
定数とキー
最初にいくつかの定数を定義し、念のためキーをSymbol
にmap
しておきます。
return if matchable_module.const_defined?('MATCHABLE_KEYS')
sym_keys = keys.map(&:to_sym)
matchable_module.const_set('MATCHABLE_KEYS', sym_keys)
matchable_module.const_set('MATCHABLE_LAZY_VALUES', lazy_match_values(sym_keys))
ここではunknown valuesエラーを防ぐためにMATCHABLE_KEYS
をVALID_KEYS
として使っています。これを定義しておけば、このメソッドが既に終了していることがわかるので早期脱出できます。
定義がない場合はシンボルキーを設定します。
それが終わると、MATCHABLE_LAZY_VALUES
という何やら面白そうなものが登場します。今度はこれを見ていきましょう。
MATCHABLE_LAZY_VALUES
この定数はいったい何をしているのでしょうか?これはmethod_name
の値をlazyにフェッチする方法へのマッピングを提供しています
def lazy_match_values(method_names)
method_names
.map { |method_name| " #{method_name}: -> o { o.#{method_name} }," }
.join("\n")
.then { |kv_pairs| "{\n#{kv_pairs}\n}"}
.then { |ruby_code| eval ruby_code }
end
keys
はこのgemのmethod_names
と同義です(値のフェッチにメソッドを使っているので)。method_names
に含まれる名前のそれぞれについて「Hash
のキーバリューペア」「lambdaを指すmethod_name
(このlambdaはObject
を受け取ってそのメソッドを直接呼び出す)」を作成したいと思います。
name
の場合は次のようになります。
name: -> o { o.name }
この呼び出し方法はpublic_send
よりもかなり高速で、マッチしたときに個別の値を直接算出せずに取得する方法を提供します。
同じことを行う元のメソッドは以下のようなものでした。
<<~RUBY
if keys.nil? || keys.include?(:#{method_name})
deconstructed_values[:#{method_name}] = method_name
end
RUBY
こちらは運悪く内部ループが発生し、コードが大幅に遅くなります。
話を戻すと、これらのキーバリューペアができたら、それらを結合して{}
で囲み、eval
で RubyのHash
に変換して、再びレースに復帰します。この使いみちについては、もう少し後で説明します。
keys
がnil
の場合
先ほどのclass_eval
に戻りましょう。ここは以下のようなコードになっています。
matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
def deconstruct_keys(keys)
if keys.nil?
return {
#{nil_guard_values(sym_keys)}
}
end
# ...
おっと、keys
がnil
の場合はすべての値を返す必要がありますね。さっきのif keys.nil? || keys.include?
というチェックはここでは既に不要ですが、nil
の対応がまだでした。
そこで登場するのがnil_guard_value
です。
nil_guard_value
このメソッドは実際には大したことはしていません。
def nil_guard_values(method_names)
method_names
.map { |method_name| "#{method_name}: #{method_name}" }
.join(",\n")
end
これはすべてのメソッド名をキーバリューペアに変換し、部分一致の場合にlazyにならないようにしたうえで、それらをjoin
します。そしてそれを{}
でくくることで、必要なすべてのキーに対応する「ブランチ」が得られます。
deconstructed_values
とvalid_keys
すべてのキーの「ブランチ」を処理した後は、フィルタリングが必要になります。最初に、新しい値の保存場所と、返すべきキーのセットが必要です。
deconstructed_values = {}
valid_keys = MATCHABLE_KEYS & keys
{}
がここに値を追加するためのものであることは見ればわかりますが、valid_keys
はもう少し興味深いものです。ここでやっていることは、マッチする有効なキーと、提供されたキーとの「交点」を見つけることです。これは、処理方法が不明な怪しいキーを取得しないためです。
lazyな値を取得する
次に、これらの有効なキーに基づいて実際に値を取得したいと思います。
valid_keys.each do |key|
deconstructed_values[key] = MATCHABLE_LAZY_VALUES[key].call(self)
end
deconstructed_values
キーのリストをeach
して、先ほど説明したMATCHABLE_LAZY_VALUES
を有効なキーで参照し、deconstructed_values
に値を設定します。これをcall(self)
してオブジェクトから値を取得し、抽出した値をすべて返します。
マクロについての私のつぶやき
警告: ここに書かれているのは本物のRubyではなく、私が空想するRubyです。
public_send
が遅くなるのをマクロで回避する代わりに、以下のように書けたらいいなという気持ちがあります。
valid_keys.each do |key|
deconstructed_values[key] = ${key}
end
deconstructed_values
${}
の部分は、(値をsend
する必要なしに)関連するコードを直接インライン化できるマクロシステムです。Matzはマクロが使える日がいつか来るかもしれないと言っていましたが、現実はまだそうではないので、しばらくはこうやって楽しく回避用マクロを書きながらマクロがやってくる日を夢見ることになりそうです。
訳注
以下のQuora記事でRubyのマクロに関する質問へのMatzからの回答を読めます。
以上でdeconstruct_keys
の説明が終わりましたので、そろそろまとめに入ります。
ベンチマーク
このマクロを使う価値は本当にあるのでしょうか?詳しくはこちらのベンチマークでご覧いただけますが、ここにまとめておきます。
テストされるオブジェクトは2種類で、1つは属性を2つ持ち、もう1つは26の属性を持ちます。Dynamicと表記されているのが通常の方法で、Macroが上記の方法です。
Person (2 attributes):
Full Hash:
Dynamic: 5.027M
Macro: 5.542M
Gain: 9.3%
Partial Hash:
Dynamic: 7.551M
Macro: 8.436M
Gain: 10.5%
Array:
Dynamic: 7.105M
Macro: 10.689M
Gain: 33.5%
BigAttr (26 attributes):
Full Hash:
Dynamic: 984.300k
Macro: 3.248M
Gain: 69.7%
Partial Hash:
Dynamic: 2.594M
Macro: 2.956M
Gain: 12.3%
Array:
Dynamic: 1.488M
Macro: 7.957M
Gain: 81.3%
ご覧のように、属性の数が増えるほど違いが際立ってきます(特にArray)。もっとも、Arrayでこんなにたくさんの属性とマッチさせることに私は反対ですが。
ではこのマクロにそれだけの価値があるでしょうか?正直に申し上げれば、これは元々マクロの効果を確かめるためのテストであり、パフォーマンス向上はうれしいおまけのようなものです。このマクロで得られるメリットは、必ずしもマクロをすべて自前で実装する労力に十分見合っているわけではないと思いますが、このMatchable gemを用いてメリットを享受したい方がいらっしゃれば、もちろん大歓迎です。
まとめ
今回の作業では、メタプログラミングやRubyで面白いことをたくさん経験できました。中には、まともな解決策にたどり着くまでに多少調べたり考えたりが必要なこともありましたが、それはそれで楽しい経験でした。
自分のやっていることが既に完全に分かりきったものだとしたら、果たして私は楽しめたでしょうか?コードの謎を探検したり、新しいことに挑戦したり、プログラミングの可能性の限界を試したりするのは、それ自体が楽しいことです。皆さんもたまには外に出て、こんなふうに自分でやってみましょう。
そういうわけで、今回は以上でおしまいです。動的なコードをeval
でコンパイルする楽しさを皆さんが味わってくだされば幸いです。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。