Refinementsをブロックの中だけで有効にするgemを実装しました。
https://rubygems.org/gems/with_refinements
リポジトリはこちら
https://github.com/hanachin/with_refinements
使い方と実装方法を紹介します。
正直な結論: Refinementsをブロックの中だけで有効にするのはむり
Ruby本体をいじらない限りできません。
無理やりの結論: Refinementsをusingした環境でブロックの内容をevalすればいい
- Refinementsをusingした環境を用意する
- ブロックのselfを1で用意した環境に置く
- ブロックから見えるローカル変数を1で用意した環境に置く
- ブロックからブロックの内容の文字列を取得する
- 2で用意したselfのinstance_evalでブロックの内容の文字列をevalする
- 5で変更されたローカル変数の内容をブロックのbindingに書き戻す
これでだいたいのコードはブロックスコープ風な感じで動きそうです。
今回6は実装していません。
使い方(短い例)
gemを入れます
$ gem i with_refinements
こんなコードを用意して実行します
require 'with_refinements'
using WithRefinements
with_refinements(Module.new { refine(Object) { def hi; puts __callee__; end } }) do
hi
# hi
end
hi
ブロックの中ではRefinementsが有効になっていて、ブロックの外ではRefinementsが有効になっておらずエラーが出るのが分かると思います。
$ ruby sample.rb
hi
Traceback (most recent call last):
sample.rb:10:in `<main>': undefined local variable or method `hi' for main:Object (NameError)
実装
短いので全部のせます。
module WithRefinements
class << self
def clean_binding
eval('module Class.new::CleanRoom; binding; end')
end
def code_from_block(block)
iseq = RubyVM::InstructionSequence.of(block).to_a
loc = iseq[4].yield_self {|h| h[:code_range] || h[:code_location] }
path = iseq[7]
File.readlines(path)[loc[0]-1..loc[2]-1].tap {|ls|
ls[0], ls[-1] = ls[0][loc[1]..-1], ls[-1][0..loc[3]]
}.join
end
end
refine(Object) do
def with_refinements(*ms, &block)
# enable refinements
b = WithRefinements.clean_binding
b.local_variable_set(:__modules__, ms)
b.eval('__modules__.each {|m| using m }')
# setup block eval context
bb = block.binding
b.local_variable_set(:__self__, bb.eval('self'))
bb.local_variables.each {|n| b.local_variable_set(n, bb.local_variable_get(n)) }
# eval block code
b.eval("__self__.instance_eval #{WithRefinements.code_from_block(block)}")
end
end
end
clean_binding
Refinementsをusingするためのきれいなbindingを得るため、毎回使い捨てのモジュールを作っています。
def clean_binding
eval('module Class.new::CleanRoom; binding; end')
end
code_from_block
Ruby 2.5からブロックがソースコード中で定義された位置がInstructionSequenceに保持されるようになりました。RubyVM::InstructionSequence.of
にブロックを渡すとそのブロックのInstructionSequenceが取れます。RubyVM::InstructionSequence#to_a
を呼ぶとInstructionSequenceの内容が配列として返ってくるので、そこからソースコードの絶対パスとコード中の位置が取得できます。
ファイルのパスとブロック定義の文字列の位置が正確にわかるので、ファイルを読めば文字列としてブロックのコードを抜き出すことが出来ます
def code_from_block(block)
iseq = RubyVM::InstructionSequence.of(block).to_a
loc = iseq[4].yield_self {|h| h[:code_range] || h[:code_location] }
path = iseq[7]
File.readlines(path)[loc[0]-1..loc[2]-1].tap {|ls|
ls[0], ls[-1] = ls[0][loc[1]..-1], ls[-1][0..loc[3]]
}.join
end
Ruby 2.5ではcode_rangeというキーで取れますがRuby 2.6からはcode_locationというキーに変わったようです (対応しました
with_refinements
この中で冒頭に書いた1〜5までを実行しています。特にめぼしいことはしていませんが、usingするモジュールとブロックのselfを渡すためにローカル変数を2つ(__modules__
, __self__
)汚染しています。特に衝突よけを用意していないので、同じ名前のローカル変数があると不具合が発生する可能性があります。
def with_refinements(*ms, &block)
# enable refinements
b = WithRefinements.clean_binding
b.local_variable_set(:__modules__, ms)
b.eval('__modules__.each {|m| using m }')
# setup block eval context
bb = block.binding
b.local_variable_set(:__self__, bb.eval('self'))
bb.local_variables.each {|n| b.local_variable_set(n, bb.local_variable_get(n)) }
# eval block code
b.eval("__self__.instance_eval #{WithRefinements.code_from_block(block)}")
end
弱点
以下のように1行のブロックだとなぜかファイルの場所や位置がInstructionSequenceから取得できないため、実行するとエラーがでます。
with_refinements { bar }
使い方(長い例)
長い例を書くとこういう感じです
require 'with_refinements'
module Helloable
refine(Object) do
def hello
puts :hello
end
end
end
module Hiable
refine(Object) do
def hi
puts :hi
end
end
end
class Person
using WithRefinements
def initialize(with:)
@things = Array(with)
end
def greet(&block)
with_refinements(*@things, &block)
end
end
a = Person.new(with: Hiable)
b = Person.new(with: Helloable)
a.greet do
hello rescue hi
end
b.greet do
hi rescue hello
end
まとめ
Refinements最高!