ひとり勉強会

ひとり楽しく勉強会

YARVソースコード勉強会 (8)

わわわ!オレンジニュースに載ってる!!!
まとめエントリは本当に目次だけで不親切だったので(すみません。。。)、さっき少しだけ説明も追記しときました。

さて、では第8回を開始します。今回から読むバージョンをr584にあげました。といっても、compile.c には変更はなかったみたいです。今回は、コンパイル部分を最後まで読んじゃえ!と勢いつけたけれど最後まで行きませんでした。特に山場はなかったので、ひたすら淡々と並んでます。

だいありーでささださんに突っ込み頂きまくりました!すみません。ありがとうございます!以下の記事は加筆訂正 (12/15 12:00) 版です。

NODE_SUPER

最初は super のコンパイルです。親クラスの同じメソッドに処理を回すやつです。super には二種類あって、

super(100) # 引数つきsuper
super      # 引数なしsuper

引数つきの方は NODE_SUPER、引数なしの方は NODE_ZSUPER と構文解析の段階で分かれています。ZSUPER 版は、現在のメソッドに渡された引数をそのまま親メソッドに引き渡すという意味のRubyの構文です。
YARVアーキテクチャ の頃はYARVのマシン命令レベルでも、super と zsuper に分かれていたようですが、現在のバージョンでは invokesuper という同じ命令にまとまっています。

superとメソッド呼び出しとの違いは、おおまかには、invokesuper命令を使うかsuper命令を使うか、だけです。なので流れは先週と一緒ですね。コンパイル後の命令列は、以下の順番です。

  • レシーバをスタックに積む
  • 引数をスタックに積む
  • ブロックが付属してればその処理
  • 呼び出し (invokesuper)
case NODE_SUPER:
case NODE_ZSUPER:{
  ... 略 ...

  /* dummy reciever */
  if (nd_type(node) == NODE_ZSUPER) {
    ADD_INSN1(ret, nd_line(node), putobject, Qfalse);
  }
  else {
    ADD_INSN1(ret, nd_line(node), putobject, Qtrue);
  }

あ、でも、superの場合レシーバはselfに決まってるので、レシーバは重要ではありません。現在の実装では、superとzsuperを区別するフラグを受け渡す用に使っていました。

DECL_ANCHOR(args);

if (nd_type(node) == NODE_SUPER) {
  argc = setup_arg(iseq, args, node, &flag);
}
else {
  /* NODE_ZSUPER */
  ... 省略 ...
  for (i = 0; i < liseq->argc; i++) {
     int idx = liseq->local_size - i;
     ADD_INSN1(args, nd_line(node), getlocal, INT2FIX(idx));
  }
}

ADD_SEQ(ret, args);

続いて引数を積む命令列です。引数ありの場合は、メソッドの時と同じ補助関数setup_argが使えます。引数なしの場合は、現在のメソッドに渡された引数をコピーしてスタックに積みます。引数はローカル変数テーブルの端の方に詰まっているのでそれをgetlocalでスタックに積んでるようです。
ローカル変数は書き換わるかもしれないですが、その場合は書き換わった値が渡る...

class A
  def f(x); p x; end
end
class B<A
  def f(x); x="me"; super; end
end
B.new.f "you"      # "me" を表示

...んですね。ちょっぴり意外でした。

/* block */
if (parent_block) {
  if (parent_block & 1) {
    flag |= VM_CALL_ARGS_BLOCKARG_BIT;
    ADD_SEQ(ret, (LINK_ANCHOR *)(parent_block & (~1)));
  }
  else {
    block = parent_block;
  }
}

続いてブロックの準備。これはメソッド呼び出しの時と全く同じですね。
前回の宿題として、「この parent_block & 1 ってなんだろう?」が残ってました。じつは未だによくわからないんですけど、これは ブロックと Proc オブジェクトの分離 関係、かな。
Block と Proc を分離しない場合は、ブロックは常に「Procオブジェクトをスタックに積む」コードにコンパイルされてて、parent_block に最下位ビットONで入っていた。けど、それをやらなくなったのでこのコードは現時点では使われていない、ような気がする、みたいな。(あやふやです。)

この辺りはもう使われていないコードなので気にしなくて良いそうです。

ADD_INSN3(ret, nd_line(node), invokesuper, argc, block, INT2FIX(flag));

ともかく、最後はinvokesuper命令で終了です。

NODE_YIELD

yield は、呼び出すものがブロックに変わるだけで、本質的にはメソッド呼び出しです。ただし、ブロックにブロックを渡したりはしません。あとレシーバを考える必要もないです。その分 call や super より簡単です。

  • 引数をスタックに積んで
  • 呼び出し(invokeblock)

呼び出し命令は invokeblock です。

case NODE_YIELD:{
  DECL_ANCHOR(args);
  //
  // おきまりの、args に引数積み積み命令を格納する処理
  // (省略)
  //
  ADD_SEQ(ret, args);
  ADD_INSN2(ret, nd_line(node), invokeblock, INT2FIX(argc),
    INT2FIX(flag));

NODE_RETURN

return は

  • メソッド定義の直下からreturnする場合
  • メソッドの中のブロックの中からreturnする場合

コンパイル結果の命令列が変わってきます。それ以外の場所(クラス定義の直下や、トップレベル)でreturnしようとするとエラーです。
どちらも、breakのコンパイル と、考え方はまったくおんなじ。前者は、周りのensure節をその場に展開してから、ジャンプ命令で脱出します。

// 抜粋
case NODE_RETURN:{
  ADD_INSN(ret, nd_line(node), emptstack);
  COMPILE(ret, "return nd_stts (return val)",
    node->nd_stts);
  add_ensure_iseq(ret, iseq);
  ADD_INSN(ret, nd_line(node), leave);

emptstack はその名の通り、現在のメソッドで使ったスタックを空にクリアする命令です。
ブロックの中から一気にreturnするには、breakと同様、例外を使います。

// 抜粋
  COMPILE(ret, "return nd_stts (return val)",
    node->nd_stts);
  ADD_INSN1(ret, nd_line(node), throw,
    INT2FIX(0x01) /* TAG_RETURN */ );

変数/定数読み取り

Rubyには変数にも色々種類があります...というのは、代入文のところでやりました。

種類 NODE 命令
メソッドローカル変数 NODE_LVAR getlocal
ブロックローカル変数 NODE_DVAR getdynamic
グローバル変数 NODE_GVAR getglobal
インスタンス変数 NODE_IVAR getinstancevariable
クラス変数 NODE_CVAR getclassvariable

変数の値を読む式は、変数の種類に応じて、対応するYARVの1命令にコンパイルされます。例えばローカル変数ならこんな感じ。

case NODE_LVAR:{
  if (!poped) {
    int idx = iseq->local_iseq->local_size + 2 - node->nd_cnt;
    debugs("idx: %d\n", idx);
    ADD_INSN1(ret, nd_line(node), getlocal, INT2FIX(idx));
  }
  break;
}
種類 NODE 命令
定数 NODE_CONST getconstant
obj::定数 NODE_COLON2 getconstant
::定数 NODE_COLON3 getconstant
ありの場合は少しややこしいですが、定数も基本は getconstant 1命令へコンパイルされます定数を取ってくる対象のクラスオブジェクトをスタックに積んで、getconstant する、という命令列にコンパイルされます(nilが積んであればカレントスコープ、falseが積んであればトップレベルスコープから検索。)。。ただし、「定数インラインキャッシュ」の最適化が有効になっていると、こんな感じにキャッシュチェック命令が入ります。
lstart:
  getinlinecache lend
  getconstant    :定数名
  setinlinecache
lend:

Rubyの定数はあとから幾らでも書き換えられるので、あまり「定数」っぽくはありません。それでも「実際には一度代入したらあとは変更しない」という使われ方が多いようです。ということで、前回値を読んだときから定数書き換えがおこっていなければ、定数の検索をせずにキャッシュから直接値を返す、という流れ。そのための特別な命令が、getinlinecache と setinlinecache です。詳しい動作はVMの方のコードを読むときにでも。参考:るびま

種類 NODE 命令
$1,$2,... NODE_NTH_REF getspecial
$&,$+,$',$` NODE_BACK_REF getspecial

組み込み変数(プログラミング言語 Ruby リファレンスマニュアル)のうち、ほとんどはgetglobal命令で値のとれる、グローバル変数です。ただし、正規表現マッチで使われる$1,$2,...だけは他のグローバル変数とは区別されて、getspecialというYARV命令に落ちるようです。

種類 NODE 命令
rescue C=>v NODE_ERRINFO -

変数の項でまとめる話じゃないかもしれませんが、resuce節で変数と例外のクラスを宣言した場合、NODE_ERRINFOというノードがくっついてきます。詳細略ですが、getglobal $! して、クラスチェックをした後変数vにsetdynamicするコードにコンパイルされています。ぜんぜん違いました!rescue C=>v; は、getdynamic $! -> setlocal v という流れになります。

NODE_ARRAY

配列式のコンパイルです。たとえば [a,b,c] という式は

eval a
eval b
eval c
newarray 3

こういう命令列になります。newarray がスタックから値を拾ってきて配列オブジェクトを作る命令です。コンパイル処理は compile_array という補助関数に丸投げです。

case NODE_ARRAY:{
  compile_array(iseq, ret, node, Qtrue);
  if (poped) {
    ADD_INSN(ret, nd_line(node), pop);
  }
  break;
}
case NODE_ZARRAY:{
  if (!poped) {
    ADD_INSN1(ret, nd_line(node), newarray, INT2FIX(0));
  }
  break;
}

ついでに ZARRAY は [] の時で、newarray 0 にコンパイルされています。
あと、るびま で解説されているように、中身が全部リテラルの配列では、わざわざスタックの積みおろしをしなくても作るべき配列オブジェクトがわかります。この最適化処理も compile_array の中に入っています。

NODE_VALUES

return 1,2,3

の 1,2,3 などなどの時に出現するノードなようです。要するに配列なので、そのまま順番に評価してnewarray、というコードにコンパイルされます。全部リテラルな時の最適化はこっちにはありませんでした。

NODE_HASH

ハッシュリテラルです。

{"aaa",3,"bbb",4}
{"ccc"=>5,"ddd"=>6}

newarrayのハッシュ版命令「newhash」があるので、実はこれも、左から順に要素をスタックに積んで、newhashするだけなのです。重要なとこだけコンパイラのコードを抜粋すると、こうです。

case NODE_HASH:{
  ... 略 ...
  compile_array(iseq, list, node->nd_head, Qfalse);
  size = OPERAND_AT(POP_ELEMENT(list), 0);
  ADD_SEQ(ret, list);
  ADD_INSN1(ret, nd_line(node), newhash, size);

compile_array して(最後のQfalseはリテラル最適化をしないという意味です)、最後の命令、つまり newarray を取り除いて、かわりに newhash を付け加えています。
奇数個しか要素のないハッシュリテラル {1,2,3} などは、構文解析の段階ではじかれていて、ここでは気にしていません。

NODE_DOT系

"a".."z"
0...10

のようなRangeオブジェクトを表す式です。ドット2個の NODE_DOT2 とドット3個の NODE_DOT3 があって、どちらもnewrange命令にコンパイルされて引数のフラグで区別されます。

case NODE_DOT2:
case NODE_DOT3:{
  int flag = type == NODE_DOT2 ? INT2FIX(0) : INT2FIX(1);
  COMPILE(ret, "min", (NODE *) node->nd_beg);
  COMPILE(ret, "max", (NODE *) node->nd_end);
  if (poped) {
    ADD_INSN(ret, nd_line(node), pop);
    ADD_INSN(ret, nd_line(node), pop);
  }
  else {
    ADD_INSN1(ret, nd_line(node), newrange, flag);
  }
  break;
}

NODE_FLIP系

NODE_DOTの範囲式が条件式として使われると、特別な意味になります。そういう位置にある範囲式はNODE_DOTではなくNODE_FLIPとしてコンパイルされます。

1 .. 式2

は「式1が真になるまでは偽を返し、その後は式2が真を返すまでは真を返します。式2が真になれば状態は偽に戻ります」となっているそうです。つまり、この範囲式を評価するには、今「式1が真になるまで」の状態なのか、「その後は式2が真を返すまでは」の状態なのかをどこかに記憶しておく必要があります。
現在のYARVの実装では、範囲式の状態を保存する領域へのアクセスは、getspecial で行います。

ADD_INSN2(ret, nd_line(node), getspecial, INT2FIX(node->nd_cnt),
          INT2FIX(0));

構文解析の段階で node->nd_cnt に$1,$2などとは重ならない番号が入っているので、それを利用しています。状態を記憶するために、式1待ちのときはfalse、式2待ちのときはtrueを入れます。というわけで、生成されるコードはこちらの通り。

  getspecial
  branchif lend
    # 状態がtrueならlend:へジャンプ
  eval 式1
  dup
  branchunless lfin
    # 式1 が falseなら式全体がfalse
    # 式1 が trueなら、それを状態に記憶
  setspecial
lend:
  eval 式2
  branchunless ltrue
    # 式2がfalseなら式全体がtrue
    # 式2がtrueなら、falseを状態に記憶
  putobject false
  setspecial
ltrue:
  putobject true
lfin:

NODE_MATCH 系

ノードは3種類ありますが、要するにどれも =~ です。基本は、単純に =~ メソッドを呼ぶだけです。

case NODE_MATCH:
case NODE_MATCH2:
case NODE_MATCH3:{
  ... 思いっきり略 ...

  COMPILE(recv, "reciever", node->nd_recv);
  COMPILE(val, "value", node->nd_value);

  ... 思いっきり略 ...

  ADD_SEQ(ret, recv);
  ADD_SEQ(ret, val);
  ADD_SEND(ret, nd_line(node), ID2SYM(idEqTilde), INT2FIX(1));

ただし、るびま で解説されている最適化が入ることがあります。idEqTilde を send する代わりに、「=~が再定義されていなければ、正規表現マッチ処理をする。再定義されていれば諦めてsendする」という意味の opt_regexpmatch 命令を使うという最適化です。Ruby では正規表現のマッチは非常によく使われるので、メソッド呼び出しのコストを回避する価値があるというわけです。
こちらがその最適化版を生成するコード

if (iseq->compile_data->option->specialized_instruction) {
  /* TODO: detect by node */
  if (recv->last == recv->anchor.next &&
      INSN_OF(recv->last) == BIN(putobject) &&
      nd_type(node) == NODE_MATCH2) {
        ADD_SEQ(ret, val);
        ADD_INSN1(ret, nd_line(node), opt_regexpmatch1,
          OPERAND_AT(recv->last, 0));
  }
  else {
    ADD_SEQ(ret, recv);
    ADD_SEQ(ret, val);
    ADD_INSN(ret, nd_line(node), opt_regexpmatch2);
  }
}

使われてる正規表現リテラルなら、opt_regexpmatch1。そうでなければ opt_regexpmatch1 と、さらに細かく分かれています。

BEGIN と END

大文字の BEGIN

BEGIN { ... }

は、そのファイルの他の部分より先に必ず評価されるブロックで、構文解析の段階で、NODE_PRELUDE というノードになります。構文解析器がソースコードの中のBEGINブロックを全部取り出して

node
  nd_head : ソース中のBEGINブロックの中身
  nd_body : BEGIN以外の部分の中身

というノードを作ってくれるので、その順番にコンパイルするだけです。

case NODE_PRELUDE:{
  COMPILE_POPED(ret, "prelude", node->nd_head);
  COMPILE_(ret, "body", node->nd_body, poped);
  break;
}

大文字の END

END { ... }

は、この文が実行されたときに、終了処理としてブロックを登録します。これは単純にpostexeというYARVの1命令になります。

case NODE_POSTEXE:{
  VALUE block = NEW_CHILD_ISEQVAL(node, make_name_for_block(iseq), ISEQ_TYPE_BLOCK);
  ADD_INSN1(ret, nd_line(node), postexe, block);

NODE_OPTBLOCK

YARVの最適化の一つとして、「ブロックのインライン化」があります。例えば

3.times { ブロックの中身 }

をブロックを作ったりyieldで呼んだりすると、スタック操作が無駄に発生してしまって効率がよくありません。あと、ブロックを使うとredoやnextが例外になってしまうのも避けたいところです。というわけで、こういう簡単なループがブロックで書かれているときは、while文に変換されます。

e = 0
while e < 3 do
 lredo:
    ブロックの中身
 lnext:
  e = e + 1
end

「実はされません。とても微妙なバグがあるため、この機能はオフになっています。」だそうです。むむむー残念。
この最適化変換でwhileの中に移動されたブロックを表すノードが、OPTBLOCKです。

case NODE_OPTBLOCK:{
  /* for optimize */
  LABEL *redo_label = NEW_LABEL(0);
  LABEL *next_label = NEW_LABEL(0);

  iseq->compile_data->start_label = next_label;
  iseq->compile_data->redo_label = redo_label;

  ADD_LABEL(ret, redo_label);
  COMPILE_(ret, "optblock body", node->nd_head, 1 /* pop */ );
  ADD_LABEL(ret, next_label);
  ADD_INSN(ret, 0, opt_checkenv);
  break;
}

コンパイルのしかたは、redoとnext用のラベルではさみながら、ブロックの本体をその場にコンパイルするだけでした。

リテラル系

リテラル式は、そのリテラルをスタックに積む命令1個にコンパイルされます。対応表はこうです。

NODE 命令
NODE_LIT putobject
NODE_SELF putself
NODE_NIL putnil
NODE_TRUE putobject true
NODE_FALE putobject false
NODE_STR putstring

動的文字列&正規表現

文字列リテラルにも色々ありまして

NODE
NODE_DSTR "a = #{a}"
NODE_EVSTR "#{a}"

文字列がputstring命令になるのは、式展開が含まれていない、単純な文字列リテラルのときだけです。

"値は #{a}#{b} です"

のように式展開が含まれる場合は

NODE_DSTR
  NODE_STR "値は "
  NODE_EVSTR a
  NODE_STR " と "
  NODE_EVSTR b
  NODE_STR "です"

というノードが構文解析で作られて

putobject "値は "
[aを評価する命令列]
tostring
putobject " と "
[bを評価する命令列]
tostring
putobject " です"
concatstrings 5

みたいに、左から順に文字列をスタックに積んでいって、concatstrings命令でひとつにまとめる、というコードにコンパイルされます。

NODE
NODE_XSTR `echo`
NODE_DXSTR `ec#{104.chr}o`
NODE_DSYM :$gvar
NODE_DREGX /#{gets}/
NODE_DREGX_ONCE /#{gets}/o

式展開が入りうるリテラルは他にも、シンボルと正規表現があります。それぞれ、DSTRと同じ方法で一旦文字列を作ってから、.internメソッドを呼ぶか、YARVのtoregexp命令に変換されます。"once"正規表現は、onceを実現するために定数のキャッシュと似た onceinlinecache という仕組みを使うようコンパイルされてました。

まとめ

以上、そろそろ構文木から命令列リストへの変換を読むのに飽きてきたので、一気に駆け抜けて終わらせようとした軌跡でした。あと残っているのはクラス/メソッド定義のような、定義関係のノードです。次回はそこを読んで、一旦ここまで9回分をスライドか何かでまとめたいと思います。毎週のログは長くて自分でも読み返す気がしないので、読み返す気になるくらいのサイズにぴしっとまとめ、られたら、いいな。