💎

TinyHooksを解説する

2021/12/27に公開

はじめに

この記事はRuby Advent Calendar 2021の16日目の記事です…が、事情により猛烈に遅延してしまっております。

背景

この記事の背景はRubyWorld Conference 2021でメソッドに関する発表をしたことです。内容はRubyConf2021で発表したものと同じで、メソッド周りのあれこれを紹介するものです。この記事が公開されるはずだった12月16日はRubyWorld Conferenceの開催日だったのですよね…失敗した…

ともあれ、メソッドについて色々調べて話したわけですが、その中で具体的な利用例として自作のTinyHooksを紹介しました。発表では時間の都合で解説し切れなかった部分がありますので、今回はそちらを解説したいと思います。

TinyHooksについて

TinyHooksは既存のメソッドを変更せずにフック(コールバック)を追加するためのgemです。フックにはbefore, after, aroundの3種類あり、ブロックとしてフックの内容を渡すことができます。具体的な使い方をREADMEから引用します。

class MyClass
  include TinyHooks

  def my_method
    puts 'my method'
  end

  define_hook :before, :my_method do
    puts 'my before hook'
  end
end

MyClass.new.my_method
# => "my before hook\nmy method\n"

このように、include TinyHooksをしたクラスではdefine_hookメソッドが使えるようになります。

Module#prependとの違い

さて、ここで一つの疑問があります。Module#prependでも同じ目的は達成できるのではないでしょうか。

class MyClass
  def my_method
    puts 'my method'
  end

  prepend Module.new {
    def my_method
      puts 'my before hook'
      super
    end
  }
end

MyClass.new.my_method
# => "my before hook\nmy method\n"

確かに同じ結果となりました。つまり、少なくともこのような単純なケースではどちらを選んでも大差はないということになります。afterと aroundを使う場合もほぼ同様のコードとなります。

class BeforeHook
  include TinyHooks
  def my_method
    puts 'my method'
  end

  define_hook :before, :my_method do
    puts 'my before hook'
  end
end

class BeforeClass
  def my_method
    puts 'my method'
  end

  prepend Module.new {
    def my_method
      puts 'my before hook'
      super
    end
  }
end

class AfterHook
  include TinyHooks
  def my_method
    puts 'my method'
  end

  define_hook :after, :my_method do
    puts 'my after hook'
  end
end

class AfterClass
  def my_method
    puts 'my method'
  end

  prepend Module.new {
    def my_method
      super
      puts 'my after hook'
    end
  }
end

class AroundHook
  include TinyHooks
  def my_method
    puts 'my method'
  end

  define_hook :around, :my_method do |original|
    puts 'my before hook'
    original.call
    puts 'my after hook'
  end
end

class AroundClass
  def my_method
    puts 'my method'
  end

  prepend Module.new {
    def my_method
      puts 'my before hook'
      super
      puts 'my after hook'
    end
  }
end

なお、念のためベンチマークを計測したところ、TinyHooksはprepend方式と比べて約5~15倍低速でした。なぜこれほどの差があるのかは不明ですが、速度にこだわるならprependのほうがよいケースは多くあるでしょう。

オーバーライドではないので引数を簡略化できる

では、それでもTinyHooksを使う理由はあるのでしょうか。一つ考えられるのは複雑な引数を取るメソッドに対してフックを定義したいケース、特にその引数と関わらずに文字列のみを出力するようなケースです。

class ComplexClass
  def complex_method(arg, default_arg = 42, *rest, **kwargs, &block)
    puts 'complex method'
  end
end

class ComplexBeforeHook < ComplexClass
  include TinyHooks

  define_hook :before, :complex_method do
    puts 'before complex method'
  end
end

ComplexBeforeHook.new.complex_method('this is positional arg', 100)
# => "before complex method\ncomplex method\n"

define_hookに渡すブロックに引数の情報が一切ないことに注目してください。これはRubyのブロックは引数を厳密には処理せず、足りなければnilで埋めるし多ければ溢れたものは消えるという性質を利用しています。実際にはメソッド呼び出しの際に渡した引数はブロックにも渡っているのですが、ブロックは単にそれを無視しているのです。この結果、単にログを出力するなどのためだけであれば目的のメソッドの複雑さに関係なくフックの定義はシンプルに保てます。

class ComplexPrepend < ComplexClass
  def complex_method # 引数定義が違う
    puts 'before complex method'
    super
  end
end

ComplexPrepend.new.complex_method('this is positional arg', 100)
# => wrong number of arguments (given 2, expected 0) (ArgumentError)

一方、継承してsuperを使う通常のやり方ではメソッドの引数について親クラスと合わせる必要があります。これは複雑なメソッドについてはかなりの手間となります。

復元

もう1つの理由としては元のメソッドの復元ができるという点があります。

class Foo
  include TinyHooks
  def a
    puts 'a'
  end
  define_hook :before, :a do puts 'before a' end
end

Foo.new.a
# => before a
# => a

Foo.restore_original :a

Foo.new.a
# => a

このように、TinyHooksではフックを定義する前のメソッドを簡単に復元できます。一方、prependを使った場合は元に戻すことはできません。

基本はprependなどでも十分だが、細かな制御をしたいケースに向く

TinyHooksは元々Albaの開発中に思いついたもので、ライブラリ作者が自分のライブラリにフックのポイントを設定できるようにするのが主な目的です。そのため、パブリックメソッドのみをフックの対象にするような機能や正規表現でフック可能なメソッドを指定する機能を持っています。
Rubyが持っているModule#prependや継承を使えばあらゆるメソッドをフックすることは可能ですが、逆に「このメソッドはいじってほしくない」という状況には対応が困難です。
そういった意味ではかなりニッチな用途に向いているのがTinyHooksであるといえるでしょう。

最後に

(それにしてもprepend使ったときの速度はすごい、CRubyの最適化には尊敬しかない…)

Discussion