gsubのラッパーは書けるか

Posted by Shugo Maeda on November 08, 2018 · 4 mins read

NaClの前田です。

社内のコードレビュー会で、Railsで h(s).gsub(/.../) { ... } のようなコードを書いた時にブロック内で $1 が参照できなくてハマったという話が出ました。

hメソッドが返すのは実はStringオブジェクトではなくてActiveSupport::SafeBufferオブジェクトなのですが、 $1 のような特殊変数はローカル変数同様にメソッドを抜けると参照できなくなってしまうので、SafeBufferのように単純なgsubのラッパーを書いても参照できません。

実践的にはStringに変換してからgsubを呼べばよいのですが、「$1 などが参照できるgsubのラッパーは本当に書けないのか?」というのが今回のテーマです。

ダメな例

まず単純なラッパーを書いてみます。

def gsub_wrapper(str, re, &block)
  str.gsub(re, &block)
end

実行してみると、 $1 が参照できずに以下のようにエラーになります。

p gsub_wrapper("foo123", /([a-z]+)(\d+)/) {
  $2 + $1  #=> undefined method `+' for nil:NilClass (NoMethodError)
}

これは $1$2 がgsub_wrapperメソッドの中でしか参照できないためです。

つまり、何とかしてgsub_wrapperの呼び出し元のコンテクストで $1 などの値を設定できれば解決できそうです。

呼び出し元のコンテクストを得る

呼び出し元のコンテクストは普通簡単には手に入らないのですが、今回はブロック付きのメソッドなので、ブロックをProc化したオブジェクトからProc#bindingで呼び出し元のコンテクストを得ることができます。

def gsub_wrapper(str, re, &block)
  str.gsub(re) { |s|
    block.binding.eval(<<-EOF)
      # ここで何とかして$1を設定したい
    EOF
    block.call(s)
  }  
end

$1は代入できない

しかし、 $1 に代入すると以下のようなエラーになります。

$1 = "foo" #=> Can't set variable $1

困りましたね。

rb_backref_set()

おもむろにRubyのソースを読むとrb_backref_set()という関数にMatchDataを渡すと $~$1 などが設定されることがわかりました。
これで何とかなりそうです。

fiddle

fiddleという標準ライブラリを使うと、任意のC関数を呼び出すことができます。

require "fiddle"

LIBRUBY = Fiddle::Handle.new(nil)
RB_BACKREF_SET = Fiddle::Function.new(LIBRUBY["rb_backref_set"],
                                      [Fiddle::TYPE_VOIDP],
                                      Fiddle::TYPE_VOID)

def foo
  RB_BACKREF_SET.call(Fiddle.dlwrap($backref))
  p $~ #=> #<MatchData "foo" 1:"foo">
  p $1 #=> "foo"
end

$backref = "foo".match(/(foo)/)
foo

Fiddle::Handle.newはライブラリをオープンしてハンドルを返します。
今回はRuby自体の関数を呼びたいのでnilを指定します。

Fiddle::Function.newでrb_backref_set()を取得します。
第1引数がFiddle::Handleから取り出した関数のアドレス、第2引数が引数の型(void *の引数を1つ)、第3引数が戻り値の型(void)です。

Rubyオブジェクトの参照をそのまま渡す場合は、Fiddle.dlwrapを使用します。

gsubのラッパー

あとは組み合わせるだけです。

require "fiddle"

LIBRUBY = Fiddle::Handle.new(nil)
RB_BACKREF_SET = Fiddle::Function.new(LIBRUBY["rb_backref_set"],
                                      [Fiddle::TYPE_VOIDP],
                                      Fiddle::TYPE_VOID)

def gsub_wrapper(str, re, &block)
  str.gsub(re) { |s|
    Thread.current[:backref] = Regexp.last_match
    block.binding.eval(<<-EOF)
      RB_BACKREF_SET.call(Fiddle.dlwrap(Thread.current[:backref]))
    EOF
    block.call(s)
  }
end

p gsub_wrapper("foo123", /([a-z]+)(\d+)/) {
  p $~    #=> #<MatchData "foo123" 1:"foo" 2:"123">
  $2 + $1
} #=> "123foo"

うまく行きましたね!

Thread.current[:backref] を使って、一応スレッドローカルにしています。

ただ、これだとJRubyでは動作しません……。

実は$~は設定できる

もう少し調べると、実は $~ には代入ができることがわかりました。
$~ に代入するだけで $1 などでも適切な値を参照できるようになります。

つまり、以下のようにfiddleなしでも実装できました。

def gsub_wrapper(str, re, &block)
  str.gsub(re) { |s|
    Thread.current[:backref] = Regexp.last_match
    block.binding.eval(<<-EOF)
      $~ = Thread.current[:backref]
    EOF
    block.call(s)
  }
end

p gsub_wrapper("foo123", /([a-z]+)(\d+)/) {
  p $~
  $2 + $1 
}

これでJRubyでも動きました。

まとめ

無事にgsubのラッパーを書くことができました。

JRubyでもちゃんと動作しますので、ActiveSupport::SafeBufferのパッチを提案したらひょっとしたら取り込まれるかもしれません。