TinyHooksを解説する
はじめに
この記事は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