Ebisu.rb#18 で、RubyVM::ISeqの話をしました
Ebisu.rb#18 で、RubyVM::InstructionSequenceのどうでもいい話をしました。
ワンワンじゃないほうがぼくです。
RubyVM::InstructionSequence.load_iseq
RubyVM::InstructionSequence (長いのでISeqと略します)に、load_iseq
というクラスメソッドを定義すると、requireが呼ばれてからRubyVMにバイトコードを渡すまでの処理を独自に定義できるようになります。
load_iseq
がISeqオブジェクトを返せばそれがRubyVMに渡される。nilを返した場合は通常の処理となります。
Railsを速くする bootsnapでも使われているので、ご存知の方も多いかと思います。
以下のような基礎的なクラスを用意します。
class HookLoadIseq
def attach
RubyVM::InstructionSequence.singleton_class.prepend build_module
end
private
def build_module
inst = self
Module.new do
define_method("load_iseq") do |path|
source_code = File.read(path)
ruby_code = inst.syntax(source_code)
RubyVM::InstructionSequence.compile(ruby_code)
end
end
end
end
このクラスを継承して、syntax
メソッドを定義すれば、requireで読んだコードを書き換えることができるようになります。
以下のようなクラスを用意しておいて
class GuardNil < HookLoadIseq
def syntax(code)
code.gsub(".", "&.")
end
end
GuardNil.new.attach
を実行した後に require を呼ぶと、このコード書き換えが有効になります。
たとえば以下のようなコードが存在するファイルをrequireすると、@user
がnil
だとしても例外になりません。
@user.address.prefecture.name || "非公開"
require時のコード書き換えによって、.
が&.
になってしまうためです。
やりたいのはこれじゃない
上の例は結局のところ、ただの文字列操作をしているに過ぎません。requireするコードに、3.14
のような実数リテラルが登場しただけで破綻します。
そもそも遊びたかったのはこれじゃなかったと気づきます(おもしろくなかったので)。ここを追求していくと、bootsnapのようにパフォーマンスへの道を進むか、パーサーや構文木解析を作る道に進むことになりそう。それもおもしろいかもだけど、どちらかというと今回はRubyVMのバイトコードをもうちょっと触りたい気分でした。
RubyVMのバイトコード
@user.address
というコードのバイトコードは以下のようになります。
0000 getinstancevariable :@user, <is:0> ( 1)[Li]
0003 opt_send_without_block <callinfo!mid:address, argc:0, ARGS_SIMPLE>, <callcache>
0006 leave
@user&.address
というコードのバイトコードは以下です。
0000 getinstancevariable :@user, <is:0> ( 1)[Li]
0003 dup
0004 branchnil 9
0006 opt_send_without_block <callinfo!mid:address, argc:0, ARGS_SIMPLE>, <callcache>
0009 leave
RubyVMのバイトコードはrubyっぽいですね。だいたい見たままのとおりです。branchnil
という命令は、スタックから取り出した値がnilの場合、指定された場所へジャンプします。このために、その1つ前で dup
を実行しているわけです。
バイトコードのArray表現
バイトコードをrubyから触るために、なんらかの形のrubyオブジェクトにしたいところです。ISeqにはto_a
というメソッドがあり、これを呼ぶとArrayの形でバイトコードに触ることができるようになります。
それぞれの要素の意味はドキュメントを見ればわかります。バイトコードは14番目にArrayの形で入っています。
@user.address
のバイトコードのArray表現は以下のようになります。
["YARVInstructionSequence/SimpleDataFormat",
2,
5,
1,
{:arg_size=>0, :local_size=>0, :stack_max=>1, :code_range=>[1, 0, 1, 13]},
"<compiled>",
"<compiled>",
nil,
1,
:top,
[],
{},
[],
[1,
:RUBY_EVENT_LINE,
[:getinstancevariable, :@user, 0],
[:opt_send_without_block, {:mid=>:address, :flag=>32, :orig_argc=>0}, false],
[:leave]]]
バイトコードでぼっちオペレータに
このArray表現のバイトコードから、メソッド呼び出しの部分を乱暴にぼっちオペレータの挙動に書き換えてしまいます。
ary = iseq.to_a
new_bytecode = []
bytecode = ary[13]
pc = 0
bytecode.each do |b|
unless b.is_a?(Array)
new_bytecode << b
next
end
unless [ :send, :opt_send_without_block].include?(b[0])
pc += b.size
new_bytecode << b
next
end
buffer = []
pc += 1
buffer << [ :dup ]
pc += 2
buffer << [ :branchnil, :temp ]
pc += b.size
buffer << b
pc += 2
label = "label_#{pc}".to_sym
buffer << [ :jump, label ]
buffer[1][1] = label
buffer << label
new_bytecode += buffer
end
ary[13] = new_bytecode
Array表現のバイトコードをISeqオブジェクトに
rubyはこのインターフェースを用意してくれていません。なので、ruby内部のC関数 rb_iseq_load
を呼ぶことにします。
require 'fiddle'
module GuardNil2
def translate(iseq)
# 上記のバイトコード書き換え処理
load_from_array(ary)
end
def load_from_array(ary)
rb_iseq_load.call(Fiddle.dlwrap(ary), nil, nil).to_value
end
private
def rb_iseq_load
return @rb_iseq_load if @rb_iseq_load
address = Fiddle::Handle::DEFAULT['rb_iseq_load']
@rb_iseq_load = Fiddle::Function.new(address, [Fiddle::TYPE_VOIDP] * 3, Fiddle::TYPE_VOIDP)
end
end
このモジュールを ISeqのクラス自身に対して prependします。
RubyVM::InstructionSequence.singleton_class.prepend GuardNil2
ISeqに translate
というクラスメソッドを定義すると、RubyVMにバイトコードを渡す前に処理を入れることができるようになります。
上の例では、引数で渡ってきたISeqオブジェクトをArray表現にしてぼっちオペレータ用に書き換えた後、ISeqオブジェクトに変換しています。
まとめ
ruby2.6には RubyVM::AST
というおもしろそうなクラスも用意されているので、さらにいろいろ遊べそうです。
こういうことをやっていても業務にはまったく役に立たないと思いますが、役に立たないことが楽しいじゃないですか。