Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3パターンマッチング応用(3)パターンマッチング高速化マクロ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Ruby 3パターンマッチング応用(3)パターンマッチング高速化マクロ(翻訳)

はじめに

baweaver/matchable - GitHub

私は最近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を支える概念やしくみについての解説です。

難易度と必要な予備知識

  • 難易度: 先に進むに連れて難しくなります。ある程度の高度な知識が求められ、メタプログラミングを深堀りします。

本記事では以下の予備知識が必要です。

今後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シリーズをお読みください。

他のインターフェイスメソッドの定義については後ほど説明しますが、手始めにdeconstructdeconstruct_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よりもかなり複雑になっています。

  1. 最適化のために扱うべきkeyのリストが存在する
  2. keyが意味する応答はキーごとに異なる
  3. keysnilの場合、返せるキーをすべて返す必要がある

そのため、このコードの理解や動的コンパイルがやや難しくなるかもしれませんが、これを動かす方法はまだあります。

まずはコードを見てみましょう。

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

定数とキー

最初にいくつかの定数を定義し、念のためキーをSymbolmapしておきます。

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_KEYSVALID_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に変換して、再びレースに復帰します。この使いみちについては、もう少し後で説明します。

keysnilの場合

先ほどのclass_evalに戻りましょう。ここは以下のようなコードになっています。

matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
  def deconstruct_keys(keys)
    if keys.nil?
      return {
        #{nil_guard_values(sym_keys)}
      }
    end

    # ...

おっと、keysnilの場合はすべての値を返す必要がありますね。さっきの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_valuesvalid_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でコンパイルする楽しさを皆さんが味わってくだされば幸いです。

関連記事

Ruby 3のパターンマッチング応用(2)三目並べ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。