おおいしつかさ


旅行とバイクとドライブと料理と宇宙が好き。
Ubie Discoveryのプログラマ。
Share:  このエントリーをはてなブックマークに追加

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すると、@usernilだとしても例外になりません。

@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というおもしろそうなクラスも用意されているので、さらにいろいろ遊べそうです。
こういうことをやっていても業務にはまったく役に立たないと思いますが、役に立たないことが楽しいじゃないですか。