YARV Maniacs 【第 8 回】 命令列のシリアライズ

書いた人:ささだ

はじめに

YARV: Yet Another RubyVM を解説するこの連載。ずっと命令列についてばかり解説していたので、今号ではちょっと脱線して、命令列のシリアライズ機能について解説します。

すでに YARV は命令列のシリアライズ機能、デシリアライズ機能を持っています。今回はその使い方の紹介と、実際に応用してみた話をしてみます。

その前に - RubyKaigi 2006

いやー、日本 Ruby カンファレンス 2006、終わりましたねぇ。運営側の人間としてはまだ残務が残っているんですが、無事に終わって本当に一安心といったところです。お世話になった方々、関係者の方々、ご参加頂いた方々、本当にありがとうございました。この場を借りて御礼申し上げます。

さて、RubyKaigi2006 の初日には Ruby 2.0 というパネルを行ったのですが、そこでまつもとさんから「Ruby 1.9.1 を来年のクリスマスに出す」「Ruby 2.0 は 2010 年くらいに出す」「1.9.1 に YARV が入っているといいねぇ」という話がありました。うーん、マージされるといいですねぇ。YARV のバグだしって何年くらいかかるか想像すると、今年中にはマージしないと 1.9.1 に間に合わないと考えるべきですかねぇ、うーん。

そもそも、RubyKaigi 2006 の準備なり残務なりで、YARV の開発が全然進んでいない。Ruby の開発を阻害する RubyKaigi。うーむ。

さて、そのパネルで、Java の VM が利用するようなクラスファイルとか、コード隠蔽みたいなことは出来ないのか、という質問を青木さんから受けたんですが (事前の打ち合わせどおりです。感謝)、「それを行うための機能は実装している」と答えました(詳細はるびま RubyKaigi2006 特別号を見てね)。その機能が今回紹介する命令列のシリアライズ機能です。

ちなみに、RubyKaigi では「コード隠蔽みたいなことはやる気がないので、プライオリティは低い」と言ったんですが、この連載のことを思い出して、ちょうどいい、ということで簡単に実装することにしました。

ところで、この命令列のシリアライズは、順番が逆で、デシリアライズ、というか Ruby スクリプト以外の他の表現から命令列を生成する方法が欲しくて作りました。今年 (2006 年) の 3 月に YAPC::Asia というイベントが開催されましたが、Pugs の作者である Audrey Tang さんに Pugs のバックエンドとして YARV も使ってみたい、という申し出を受けて作り始めました。作ってみて、作ったよー、と報告はしたんですが、実際に使ってるのかどうかは謎です。

命令の名前の変更

えーと、前回までで説明していた命令の名前、色々と変えてしまいました。前回も後悔しているって書いたんですが、まぁ影響範囲が皆無であるうちに、えいや、ということで変えてしまいました。今度は従来の Ruby プログラムなどのキーワード、メソッド名などと被らないようにしてあります。

変更結果は YARV: Instruction Table を見てください。具体的には、前回後悔していた if 命令などが branchif 命令などに変更されています。

YARV - 0.4.1

今回の記事を書くために色々と修正したので、その辺をまとめて 0.4.1 としてリリースしました。記事中のサンプルは YARV 0.4.1 を利用して実行してみてください。

YARV 命令列

そもそも YARV 命令列とは何か、というのは以前にも書いたような気がするのですがもう一度おさらいしておきます。

YARV は VM、仮想マシンですので、命令を順番に実行していきます。その命令を表現しているのが命令列です。簡単ですね。

Ruby プログラムを読み込むと、まず YARV は従来のパーサを使って構文木にします。私はパーサには (とくに Ruby のようなマジカルなパーサには) 手を出さないように幼少のころから教育されていますので、現在の Ruby 処理系そのまんまのものを使っています。で、パーサは Ruby プログラムを抽象構文木にして返します。ちょっとかっこつけて言うと Abstract Syntax Tree といって、頭文字をとって AST といいます。その AST を YARV のコンパイラが YARV 命令列に変換する、というわけです。簡単ですね。

命令列オブジェクト

YARV では、命令列もオブジェクトとして取り扱っています。YARVCore::InstructionSequence という長ーいクラス名のオブジェクトになっています。ところで、YARV は YARV じゃなくなるので、YARVCore という名前もなくなると思います。YARVCore じゃなくて RubyCore になるのかなぁ。それとも、ただの VM クラスになるのかなぁ。ご意見募集中です。あ、ところで YARVCore クラスは YARV を実行すると勝手に定義されています。

InstructionSequence は、そのまんま命令列の意味ですね。違ったら、誰かこっそり教えてください。YARV のソース中では、これをそのまま書くのはめんどくさいので iseq と略しています。YARV のソースコード中には大量に iseq が出没しますので、ソースを読むときは覚えておいてください。

オブジェクトなのでメソッドを持ちます。たとえば、InstructionSequence#disasm メソッドは逆アセンブルした結果を文字列で返します。文字列で返すのはカッコわりーなー、もうちょっとマシな設計無いのかよ、と自分でも思うのですが、誰も使う人がいないのでとりあえずこのまんまです。将来的にはこの辺も整理されることでしょう。されるといいなぁ。

命令列オブジェクトの単位

命令列オブジェクトは、実際にはいくつもの命令列オブジェクトがツリー上になって構成されています。

いったいどういうことかというと、いくつかの種類の命令列オブジェクトが、変数のスコープが独立しているという単位 (など) で、異なる命令列オブジェクトとなり、それらがリンクしています。具体的な種類は次のとおりです。

  • トップレベル
  • クラス・モジュール
  • (特異) メソッド
  • ブロック
  • 例外 (rescue / ensure)
  • eval によって生成された命令列

たとえば、

 class C
   def m
     1.times{
     }
   end
 end
 C.new.m

なんていうやる気のないプログラムは

 toplevel 命令列:
   クラス C の命令列:
     メソッド m の命令列:
       ブロックの命令列

というような木構造になっています。

ちなみに、命令列オブジェクトも Ruby の普通のオブジェクトですので GC されます。もちろん実行中には GC されることはありませんが、たとえばトップレベルの命令列は別のトップレベルへ実行が遷移したとき、GC されるかもしれません*1

ちなみに、命令列同士の親子関係で参照を持っているものがあります。たとえば、ブロックの命令列はその親への参照を持っています。そのため、子どもを実行中に親が GC されるということは多分ありません。

命令列オブジェクトの取り出し

ところで、このオブジェクトはどうやって生成するんでしょうか。前述したように、YARV は命令列にそって実行します。つまり、Ruby プログラムを読み込んで実行している時点で、しっかり存在するわけですね。

でも、YARV を使っていても 命令列オブジェクトなんて見た事ねーよ、という方がほとんどではないかと思います*2。私もほとんどありません。

今回の話題は、この命令列オブジェクトに対してなんかする、ということなので、Ruby レベルのオブジェクトとして取り出したいわけですが、どうしましょう。

実は、実行するために命令列にしてしまうと、それを取り出す手段は (今は) ありません*3。require なども同様です。

そのため、YARV 命令列オブジェクトのファクトリーメソッドを用意してあります。これは、Ruby プログラムである文字列を YARV 命令列オブジェクトに変換して返すという機能を持っています。

ファクトリーメソッドは以下の二つです。

 iseq = YARVCore::InstructionSequence.compile(ruby_program, [file_name, [line_no]])
 iseq = YARVCore::InstructionSequence.compile_file(file_name)

compile メソッドは文字列を、compile_file メソッドはファイルを対象にパースとコンパイルを行います。

compile メソッドの file_name、line_no はそれぞれファイル名と行数で、eval メソッドの引数と同様、スタックトレースの出力などに利用します。

では、実際に試してみましょう。test.rb というファイルを用意します。

# test.rb
iseq = YARVCore::InstructionSequence.compile(<<EOS__)
if 1<2
  puts "hello"
end
EOS__
puts "compile sample:"
puts iseq.disasm
puts
puts "compile file sample:"
puts YARVCore::InstructionSequence.compile_file(__FILE__).disasm

このプログラムでは、まずてきとーな Ruby プログラム (ヒアドキュメントで記述してあるもの) をコンパイルし、そのコンパイル結果を逆アセンブルして出力、というものです。その後、そのファイル自身を compile_file メソッドでコンパイルし、出力しています。

実行結果は次のようになります。

compile sample:
== disasm: <ISeq:<main>@<compiled>>=====================================
local scope table (size: 1, argc: 0)

0000 putobject        1                                               (   1)
0002 putobject        2
0004 opt_lt
0005 branchunless     17
0007 putself                                                          (   2)
0008 putstring        "hello"
0010 send             :puts, 1, nil, 4, <ic>
0016 leave                                                            (   1)
0017 putnil
0018 leave                                                            (   2)

compile file sample:
== disasm: <ISeq:<main>@../yarv/test.rb>================================
local scope table (size: 2, argc: 0)
[ 2] iseq
0000 getinlinecache   <ic>, 9                                         (   3)
0003 getconstant      :YARVCore
0005 getconstant      :InstructionSequence
0007 setinlinecache   0
0009 putstring        "if 1<2\n  puts \"hello\"\nend\n"
0011 send             :compile, 1, nil, 0, <ic>
0017 setlocal         iseq
0019 putself                                                          (   8)
0020 putstring        "compile sample:"
0022 send             :puts, 1, nil, 4, <ic>
0028 pop
0029 putself                                                          (   9)
0030 getlocal         iseq
0032 send             :disasm, 0, nil, 0, <ic>
0038 send             :puts, 1, nil, 4, <ic>
0044 pop
0045 putself                                                          (  10)
0046 send             :puts, 0, nil, 12, <ic>
0052 pop
0053 putself                                                          (  11)
0054 putstring        "compile file sample:"
0056 send             :puts, 1, nil, 4, <ic>
0062 pop
0063 putself                                                          (  12)
0064 getinlinecache   <ic>, 73
0067 getconstant      :YARVCore
0069 getconstant      :InstructionSequence
0071 setinlinecache   64
0073 putstring        "../yarv/test.rb"
0075 send             :compile_file, 1, nil, 0, <ic>
0081 send             :disasm, 0, nil, 0, <ic>
0087 send             :puts, 1, nil, 4, <ic>
0093 leave

まぁ、長々と出てきましたが、なんとなくコンパイルできた感をつかんでいただけたんではないかと思います。多分。

コンパイルオプション

さて、紹介した 2 つの命令列オブジェクトファクトリーメソッドですが、実はオプショナルにもう 1 つ、コンパイルオプションを指定する引数を受け付けます。コンパイルオプションで渡す値は以下のようになっています。

nil
デフォルトのコンパイルオプションを適用する
true
全てのコンパイルオプションを有効にする
false
全てのコンパイルオプションを無効にする
Hash
特定のコンパイルオプションを有効・無効に設定する。指定の無いコンパイルオプションはデフォルトのままとする

現状でサポートしているコンパイルオプションは以下のとおりです。

peephole_optimization
ピープホール最適化を行う (デフォルトで有効)
inline_const_cache
定数インラインキャッシュを利用する (デフォルトで有効)
specialized_instruction
特化命令を利用する (デフォルトで有効)
operands_unification
オペランド融合を行う
instructions_unification
命令融合を行う
stack_caching
スタックキャッシングを行う (性能上の問題から、現時点では有効にできないようにしてあります)

指定するときは、上記のコンパイルオプションをシンボルにして、

CompileOption = {
 :peephole_optimization    => true,
 :inline_const_cache       => true,
 :specialized_instruction  => false,
}

のように行います。

では、コンパイルオプションを弄って試してみましょう。

def compile_option_test opt
  script = <<-EOS__
  if 1<2
    puts "hello"
  end
  EOS__
  puts YARVCore::InstructionSequence.compile(script, '<test>', 1, opt).disasm
end

# すべてのコンパイル (最適化) オプションを無効にする
compile_option_test false
# デフォルトのコンパイルオプションを適用する
compile_option_test nil
# すべてのコンパイル (最適化) オプションを有効にする
compile_option_test true

実行結果です。

== disasm: <ISeq:<main>@<test>>=========================================
local scope table (size: 1, argc: 0)

0000 putobject        1                                               (   1)
0002 putobject        2
0004 send             :<, 1, nil, 0, <ic>
0010 branchunless     25
0012 jump             14
0014 putself                                                          (   2)
0015 putstring        "hello"
0017 send             :puts, 1, nil, 4, <ic>
0023 jump             26                                              (   1)
0025 putnil
0026 leave                                                            (   2)
== disasm: <ISeq:<main>@<test>>=========================================
local scope table (size: 1, argc: 0)

0000 putobject        1                                               (   1)
0002 putobject        2
0004 opt_lt
0005 branchunless     17
0007 putself                                                          (   2)
0008 putstring        "hello"
0010 send             :puts, 1, nil, 4, <ic>
0016 leave                                                            (   1)
0017 putnil
0018 leave                                                            (   2)
== disasm: <ISeq:<main>@<test>>=========================================
local scope table (size: 1, argc: 0)

0000 putobject_OP_INT2FIX_O_1_C_                                      (   1)
0001 putobject        2
0003 opt_lt
0004 branchunless     13
0006 putself                                                          (   2)
0007 putstring        "hello"
0009 send_OP__WC__1_Qfalse_0x04__WC_ :puts, <ic>
0012 leave                                                            (   1)
0013 putnil
0014 leave                                                            (   2)

たくさん出てきてわかりづらいですが、とりあえず命令列の長さが短くなっているのはわかるので、なんらかの最適化がかかったんだなぁ、ということはわかると思います。

なお、Ruby プログラムを実行するときに適用されるコンパイルオプション (デフォルト値) は YARVCore::InstructionSequence.compile_option= メソッドにここで説明した値を渡すことで指定できます。多分、将来的には Ruby インタプリタのコマンドライン引数で指定できるようにするんじゃないでしょうか。ただ、この辺を弄れても嬉しい人はほとんどいないような気はします。

命令列のシリアライズ

さて今回の本題、YARV 命令列のシリアライズです。

シリアライズ ―― YARVCore::InstructionSequence::to_a

命令列オブジェクトは、定数 (数値・文字列・シンボル)、配列、ハッシュオブジェクトなどのプリミティブなデータのみの、取り扱いデータ型へ変換可能です。ここでは、これをもって命令列のシリアライズと称しています。

で、その変換を行うには YARVCore::InstructionSequence::to_a を利用します。実際に試してみましょう。

require 'pp'

ary = YARVCore::InstructionSequence.compile(<<EOS__).to_a
class C
  def m
    1.times{
    }
  end
end

C.new.m

EOS__

pp ary

こんなふうに使います。このプログラムはやる気の無い Ruby プログラムをコンパイルして、YARV 命令列オブジェクトを生成して、その結果を配列に変換し、pp してデータ構造をどかどか出力しています。

## pp での出力結果
["YARVInstructionSimpledataFormat",
 1,
 1,
 1,
 nil,
 "<main>",
 "<compiled>",
 [],
 :toplevel,
 [],
 [0, 0, [], 0, 0],
 [],
 [[:putnil],
  [:putnil],
  [:defineclass,
   :C,
   ["YARVInstructionSimpledataFormat",
    1,
    1,
    1,
    nil,
    "<class:C>",
    "<compiled>",
    [],
    :class,
    [],
    [0, 0, [], 0, 0],
    [],
    [[:putnil],
     [:definemethod,
      :m,
      ["YARVInstructionSimpledataFormat",
       1,
       1,
       1,
       nil,
       "m",
       "<compiled>",
       [],
       :method,
       [],
       0,
       [[:retry, nil, :label_0, :label_8, :label_0, 0]],
       [:label_0,
        [:putobject, 1],
        [:send,
         :times,
         0,
         ["YARVInstructionSimpledataFormat",
          1,
          1,
          1,
          nil,
          "block in m",
          "<compiled>",
          [],
          :block,
          [],
          0,
          [[:redo, nil, :label_0, :label_1, :label_0, 0],
           [:next, nil, :label_0, :label_1, :label_1, 0]],
          [:label_0, [:putnil], :label_1, [:leave]]],
         0,
         nil],
        :label_8,
        [:leave]]],
      0],
     [:putnil],
     [:leave]]],
   0],
  [:pop],
  :label_7,
  [:getinlinecache, nil, :label_14],
  [:getconstant, :C],
  [:setinlinecache, :label_7],
  :label_14,
  [:send, :new, 0, nil, 0, nil],
  [:send, :m, 0, nil, 0, nil],
  [:leave]]]

眺めただけで、どんなプログラムなのかすぐにわかりますね!

シリアライズしたデータ構造

さて、出力したデータ構造は面倒なんで説明はかなり省略しますが、命令列の説明 + いろんな付加データとなっています (省略しすぎ)。

「いろんな付加情報」というのは以下のような配列になっています。

[magic, major_version, minor_version, format_type, misc,
 name, filename, line,
 type, args, vars, exception_table, body]

それぞれがどんなデータをあらわしてどのように表現するか、という説明は面倒くさいので紙面の都合上行いませんが、名前を見るとぼんやりとわかるんじゃないかな、と思います。ソースを見るのも手です (compile.c、iseq.c)。

この付加情報のうち body 要素が命令列を表しています。body 要素の実体は配列で、その配列の要素は [:"命令名のシンボル", operand...] という配列もしくは :label_name というシンボルです。つまり、body の値は次のような感じになります。

   [[:insnA, op1, op2],
    [:insnB, op3],
    :labelX,
    ...
   ]

例えば、p true while true という Ruby プログラムは

[:label_0,
 [:putself],
 [:putobject, true],
 [:send, :p, 1, nil, 4, nil],
 [:pop],
 [:jump, :label_0],
 [:putnil],
 [:leave]]]

こんなふうに表現されます。

命令名などは、命令名に対応する数字を決めてしまえば、このデータ構造はかなり圧縮できたりするのですが、シンボルにしておいたほうが可搬性があるのでこのようなフォーマットにしています。というか、まだ命令セットが fix していないので、ガチガチに固めたくないということもあります。まぁ、シンボルだと (人間にとって) 読み書きがとてもやりやすいというのが一番の理由です。

デシリアライズ ―― YARVCore::InstructionSequence.load

さて、上記のようにシリアライズしたデータ構造は、もちろんそのまま読み込むことができます。

 iseq = YARVCore::InstructionSequence.load(serialized_iseq)

こんなふうに使います。

また、YARVCore::InstructionSequence#eval というメソッドは (トップレベルタイプの命令列なら) その命令列を実行します。

応用例:rb/compile.rb

では YARV のシリアライズ機構の応用例を示しましょう。昔から Ruby には難読化器がないとかなんとか、いろいろ言われてきたので、シリアライズ機構を活用して作ってみました。以下にその難読化器のコードを示します。なお、これとまったく同じものが YARV のソースツリーの中の rb/compile.rb にも同梱してあるので、実際に使いたい場合はそちらを利用してください。

require 'optparse'
require 'pp'

OutputCompileOption = {
  # enable
  :peephole_optimization    =>true,
  :inline_const_cache       =>true,

  # disable
  :specialized_instruction  =>false,
  :operands_unification     =>false,
  :instructions_unification =>false,
  :stack_caching            =>false,
}

def compile_to_rb infile, outfile
  iseq = YARVCore::InstructionSequence.compile_file(infile, OutputCompileOption)

  open(outfile, 'w'){|f|
    f.puts "YARVCore::InstructionSequence.load(" +
           "Marshal.load(<<EOS____.unpack('m*')[0])).eval"
    f.puts [Marshal.dump(iseq.to_a)].pack('m*')
    f.puts "EOS____"
  }
end

def compile_to_rbc infile, outfile, type
  iseq = YARVCore::InstructionSequence.compile_file(infile, OutputCompileOption)

  case type
  when 'm'
    open(outfile, 'wb'){|f|
      f.print "RBCM"
      f.puts Marshal.dump(iseq.to_a, f)
    }
  else
    raise "Unsupported compile type: #{type}"
  end
end

## main

outfile = 'a.rb'
type    = 'm'
opt = OptionParser.new{|opt|
  opt.on('-o file'){|o|
    outfile = o
  }
  opt.on('-t type', '--type type'){|o|
    type = o
  }
  opt.version = '0.0.1'
}

opt.parse!(ARGV)

ARGV.each{|file|
  case outfile
  when /\.rb\Z/
    compile_to_rb file, outfile
  when /\.rbc\Z/
    compile_to_rbc file, outfile, type
  else
    raise
  end
}

このプログラムは、指定したファイルを a.rb (もしくは、-o オプションで指定した名前) というファイルにコンパイルします。

実際にやってみましょう。

# t.rb
class C
  def m
    1.times{
    }
  end
end

C.new.m

これを ruby compile.rb t.rb として実行すると、a.rb というファイルが生成されます。

# a.rb
YARVCore::InstructionSequence.load(Marshal.load(<<EOS____.unpack('m*')[0])).eval
BAhbEiIkWUFSVkluc3RydWN0aW9uU2ltcGxlZGF0YUZvcm1hdGkGaQZpBjAi
CzxtYWluPiIJdC5yYlsAOg10b3BsZXZlbFsAWwppAGkAWwBpAGkAWwBbEVsG
OgtwdXRuaWxbBjsGWwk6EGRlZmluZWNsYXNzOgZDWxIiJFlBUlZJbnN0cnVj
dGlvblNpbXBsZWRhdGFGb3JtYXRpBmkGaQYwIg48Y2xhc3M6Qz5ACFsAOgpj
bGFzc1sAWwppAGkAWwBpAGkAWwBbCVsGOwZbCToRZGVmaW5lbWV0aG9kOgZt
WxIiJFlBUlZJbnN0cnVjdGlvblNpbXBsZWRhdGFGb3JtYXRpBmkGaQYwIgZt
QAhbADoLbWV0aG9kWwBpAFsGWws6CnJldHJ5MDoMbGFiZWxfMDoMbGFiZWxf
ODsOaQBbCjsOWwc6DnB1dG9iamVjdGkGWws6CXNlbmQ6CnRpbWVzaQBbEiIk
WUFSVkluc3RydWN0aW9uU2ltcGxlZGF0YUZvcm1hdGkGaQZpBjAiD2Jsb2Nr
IGluIG1ACFsAOgpibG9ja1sAaQBbB1sLOglyZWRvMDsOOgxsYWJlbF8xOw5p
AFsLOgluZXh0MDsOOxU7FWkAWwk7DlsGOwY7FVsGOgpsZWF2ZWkAMDsPWwY7
F2kAWwY7BlsGOxdpAFsGOghwb3A6DGxhYmVsXzdbCDoTZ2V0aW5saW5lY2Fj
aGUwOg1sYWJlbF8xNFsHOhBnZXRjb25zdGFudDsIWwc6E3NldGlubGluZWNh
Y2hlOxk7G1sLOxE6CG5ld2kAMGkAMFsLOxE7C2kAMGkAMFsGOxc=
EOS____

実際に何をやっているかというと、

  • 文字列を YARV 命令列に変換して、
  • それをシリアライズして、
  • それを Marshal.dump して、
  • それを pack('m*') で Base64 エンコーディングして、
  • それをデシリアライズして実行するためのコードを付加して

いる、というものです。

なんというか、Ruby プログラムを pack('m*') するだけでもいいような気がしないでもないですが、まぁそれだけだと簡単に中身が見えてしまうので、一度 YARV 命令列にコンパイルしてから Base64 エンコーディングしているということです。

さて、ロードしたいときにはデシリアライズするプログラムになっているのでこれをそのまま実行すればよいのですが、具体的に何をするかというと、

  • 文字列を unpack('m*') で Base64 デコードして、
  • それを Marshal.load して、
  • それをデシリアライズして、
  • それを実行

という流れになります。

ちなみに、これだけのオーバヘッドがあるのでロード時間短縮のためにこのコンパイラで先にコンパイルしておく、というのは意味がありません (測りました)。というか、遅くなります。unpack のコスト、および Marshal.load のコストが大部分で、これに時間がかかります。後者のコストはシンボルを多用している現在の表現によります。この辺、バイナリでガチガチに固めてしまう、よくある (Java の VM 用クラスファイルのような) 構造を定義すれば可能になるでしょう。そのためには命令セット決めないとなぁ。うーん*4

ところで、結局 YARV 命令列 -> Ruby プログラム変換器 (逆コンパイラ) を作れば中で何をしているのかわかってしまうのですが、とりあえずそんなに簡単ではないとたかをくくっています。誰か作りませんか?

ライブラリ yasm

さて、このデシリアライズインターフェースを使うと簡単に YARV 上で実行できる命令列を作成することができます。たとえば、他の言語で YAML を利用して上記フォーマットを記述しておけば、簡単に YARV に読み込ませることが出来ます*5

が、配列をごちゃごちゃ弄るのはめんどくさいです。そもそも、構造の解説がソース読め、という冷たい仕打ち。そこで、yasm というライブラリを用意しました。要するに YARV 用アセンブラで、Ruby の文法で記述します、というか Ruby のプログラムです。アセンブラで色々記述すると、上記のシリアライズした表現、つまり Ruby の配列を作るので、あとはそれをデシリアライズ (ロード) して実行すればいいわけです。

yasm 利用例

習うより慣れろ、ということで利用例を示します。

require 'yasm'

iseq = YASM.toplevel([:a, :b]){
  #
  # a = 10
  # b = 20
  # p(a+b)
  # 3.times{|i| p i}
  #
  putobject 10
  setlocal :a
  putobject 20
  setlocal :b
  putself
  getlocal :a
  getlocal :b
  send :+, 1
  call :p, 1 # call means send with flags 4
  pop
  putobject 3
  send :times, 0, block([:i]){
    putself
    getdynamic :i
    call :p, 1
    leave
  }
  leave
}
p iseq
iseq.eval #=> 30, 0, 1, 2

YASM.toplevel メソッドが toplevel の YARV 命令列のファクトリーメソッドになります。そして、ブロック中でどんな挙動をするのか、命令を Ruby のメソッドとして作成します*6

ブロックなどは block メソッドで記述します。

余談

さて、YASM の書き方、Ruby の文法としてちょっと不思議に思いませんか。Ruby プログラムとしては完全に valid ですが、putobject などのメソッド、どのクラスに属しているんでしょう。トップレベルで定義されたメソッドでもありません (実際に putobject と読んでみると、NoMethodError になります)。ブロックの中でしか有効ではありません。

この仕組み、instance_eval を利用して作られています。つまり、ブロックの中では self が違うんですね。そのため、うっかりすると間違ってしまう可能性があります。

で、self の置き換えを防ぐために、明示的にビルダーオブジェクトに対してエミットメッセージを送るという書き方にするには次のように書きます。

 YASM.toplevel{|ib| # ib: iseq builder
   ib.putobject 10
   ...
 }

ブロックの arity を見て、もし arity が 1 ならば instance_eval する代わりにビルダーオブジェクトを渡す、という設計です。この設計、ちょっとどうかなと思わなくもないのですが...。まぁ、とりあえずこのようにしておきましょう。

ちなみに、これは Microsoft の .NET などで用意されている ILGenerator みたいなイメージですね。

おわりに

今回は YARV 命令列のシリアライズとデシリアライズの機能について紹介しました。今回紹介した仕組み、とくに yasm を利用すると YARV 上で動作するプログラミング言語を気楽に作ることが出来ます (なんといっても、アセンブリ言語が Ruby の表現力を持っているわけですから、その使いやすさは推して知るべし)。

本当は、今回何か簡単な言語のコンパイラを作ろうと思っていたのですが、間に合いませんでした。誰か Scheme あたりで挑戦してみませんか。かなり簡単だと思いますよ。

では、また。

著者について

ささだ こういち。非学生。

非学生だが、RubyKaigi 2006 にかまけて仕事が全然できていない。ヤバイ。まぁ、いいか (いや、大変よくない。マジで)。

*1 ちょっとわかりづらいかもしれませんが、たとえばこの命令列が require などで実行された場合を考えてみてください。

*2 YARV を使ったことがある人がほとんどいねーという噂。

*3 加えるのは簡単なんですが、インターフェース、API の設計が難しいのです。

*4 別の選択肢として、「現在の VM が dump したファイルしか読み込めないようにする」という割り切りをしてしまうという方法があります。ビルドバージョンが違ったり、別の VM が dump した、たとえばネットワーク越しのやり取りは一切サポートしない、という方法です。個人的には、現在の冗長表現、それから VM 限定のタイトな表現、どちらもサポートするのがいいのかな、と思っています。

*5 というか、そういう目的で作ったわけです。

*6 命令を Ruby のメソッドとして記述可能にするために命令セットの名前を考え直したんですよね。たとえば、end 命令は leave 命令になりました。