7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Refinementsをブロックの中だけで有効にする

Last updated at Posted at 2018-06-01

Refinementsをブロックの中だけで有効にするgemを実装しました。
https://rubygems.org/gems/with_refinements

リポジトリはこちら
https://github.com/hanachin/with_refinements

使い方と実装方法を紹介します。

正直な結論: Refinementsをブロックの中だけで有効にするのはむり

Ruby本体をいじらない限りできません。

無理やりの結論: Refinementsをusingした環境でブロックの内容をevalすればいい

  1. Refinementsをusingした環境を用意する
  2. ブロックのselfを1で用意した環境に置く
  3. ブロックから見えるローカル変数を1で用意した環境に置く
  4. ブロックからブロックの内容の文字列を取得する
  5. 2で用意したselfのinstance_evalでブロックの内容の文字列をevalする
  6. 5で変更されたローカル変数の内容をブロックのbindingに書き戻す

これでだいたいのコードはブロックスコープ風な感じで動きそうです。
今回6は実装していません。

使い方(短い例)

gemを入れます

$ gem i with_refinements

こんなコードを用意して実行します

sample.rb
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の内容が配列として返ってくるので、そこからソースコードの絶対パスとコード中の位置が取得できます。

ファイルのパスとブロック定義の文字列の位置が正確にわかるので、ファイルを読めば文字列としてブロックのコードを抜き出すことが出来ます :v:

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というキーに変わったようです :muscle: (対応しました

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最高!

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?