Ebisu.rb#18 で、RubyVM::ISeqの話をしました

Tsukasa OISHI

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