ひとり勉強会

ひとり楽しく勉強会

NODE_MASGN / 多重代入

ここまでのいろいろな代入より、順番的にはこっちのcaseが先に出てきてました。説明の都合で逆順に読んでいます。

a, b, c = 1, 2, 3

みたいな形の代入です。コンパイル処理はほぼ全て、補助関数に丸投げされています。

case NODE_MASGN:{
  compile_massign(iseq, ret, node->nd_value, /* rhsn  */
                  node->nd_args,             /* splat */
                  node->nd_head,             /* lhsn  */
                  0);
  if (!poped) {
    ADD_INSN1(ret, nd_line(node), putobject, Qtrue);
  }
  break;
}

最後のputobjectは式の値をスタックに積むところです。多重代入式の値については議論の対象(yarv-dev:268)なようですね。現在のYARVでは常にtrueを返してます。
多重代入の形によって、構文解析器が返すノードの形は変化します。

右辺の形 rhsn
a a
a,b,c [a,b,c]
a,b,*c NODE_ARGSCAT([a,b,c],d)
*a NODE_SPLAT(a)

右辺はこんなです。左辺は、

左辺の形 lhsn splat
a,b, [a,b] 0
a,b,c [a,b,c] 0
a,b,*c [a,b,c] c
a,b,* [a,b] -1
* 0 -1
a,b,*c,d,e [a,b,c] NODE_POSTARG(c,[d,e])
a,b,*,d,e [a,b] NODE_POSTARG(-1,[d,e])
*,a,b, 0 NODE_POSTARG(-1,[a,b])

こう。NODE_POSTARGはRuby 1.9 featureで、現在のYARVは未対応みたいでした。(試したら落ちた。)
左辺の1個1個の項は、構文木としては、nullを代入する NODE_*ASGN ノードになっています。例えば

a, b.c, $d = ...

NODE_LASGN( :a, 0 )
NODE_ATTRASGN( b, :c, 0 )
NODE_GASGN( :d, 0 )

の3つのリストノードです。0はLASGNとかではRubynil値扱いになって、ATTRASGNでは、引数の長さが0のリスト扱いになります。これ重要。試験にでます。

compile_massign

おおまかな流れ。

static int
compile_massign(yarv_iseq_t *iseq, LINK_ANCHOR *ret,
		NODE * rhsn, NODE * splatn, NODE * lhsn, int llen)
{
  if (lhsn != 0) {
    compile_massign(iseq, ret, rhsn, splatn, lhsn->nd_next, llen + 1);
    make_masgn_lhs(iseq, ret, lhsn->nd_head);
  }
  else {
    ... (右辺の値をスタックに積みまくる処理) ...

    if (lhs_splat) {
      make_masgn_lhs(iseq, ret, splatn);
	}
  }
  return COMPILE_OK;
}

左辺が空になるまで、再帰でくりかえしてます。ソースコード上での左辺の逆順に、make_masgn_lhsが呼ばれて命令列が作られます。つまり

a1,a2,... = b1,b2,...

の形の多重代入は、

b1,b2...をスタックに積みまくる処理
assign_to aN
...
assign_to a2
assign_to a1

の順番の命令列にコンパイルされます。

make_masgn_lhs

make_masgn_lhsは、「スタックトップにある値を左辺に代入する」ような命令列を作ります。なかなかトリッキーでした。どうぞ。

static int
make_masgn_lhs(yarv_iseq_t *iseq, LINK_ANCHOR *ret, NODE * node)
{
  switch (nd_type(node)) {
  case NODE_ATTRASGN:{
    ...
  }
  case NODE_MASGN:{
    ...
  }
  default:{
      DECL_ANCHOR(anchor);
      COMPILE_POPED(anchor, "masgn lhs", node);
      REMOVE_ELEM(FIRST_ELEMENT(anchor));
      ADD_SEQ(ret, anchor);
    }
  }
}

一番わかりやすい default の場合で説明します。ここでは、LASGNやGASGNなど、ATTRASGN以外の全ての左辺値の場合が扱われています。まずは、単純に左辺ノードをコンパイルします。左辺ノードは NODE_LASGN( :a, 0 ) のような形だったのでした。これをコンパイルすると、必ず

putnil
スタックトップを a に代入するコード

になります。なので、この命令列の先頭要素「putnil」を取り除(REMOVE_ELEM)けば。。。

スタックトップを a に代入するコード

が手に入ります!他の代入の場合も、先頭がputnil 、残りが変数への代入、という形になっているので全く同じように動きます。

case ATTRASGNの場合はもっと複雑です。まず NODE_ATTRASGN(obj, :method=, 0) をCOMPILE_POPEDでコンパイルすると、次のような命令列になります。

  obj
  send 0, :method=
  pop

こんどは、先頭の命令を削っても「スタックトップをobj.methodに入れるコード」にはなりません。レシーバobjを積んだ上に、元スタックトップの値を積み直すように調整する必要があります。具体的には、こう書き換えます。

  obj
  topn 2
  send 1, :method=  # send 0, :method=
  pop               # pop
  pop

topn 2 で、スタックでobjの下にある値を積み直します。send の引数を1個増やして呼び出して、あとは要らない値をpop,pop。以下、該当部分のコードです。

case NODE_ATTRASGN:{
  INSN *iobj;
  VALUE dupidx;

  COMPILE_POPED(ret, "masgn lhs (NODE_ATTRASGN)", node);
  POP_ELEMENT(ret);        /* pop pop insn */
  iobj = (INSN *)POP_ELEMENT(ret); /* pop send insn */

  dupidx = iobj->operands[1];
  dupidx = INT2FIX(FIX2INT(dupidx) + 1);
  iobj->operands[1] = dupidx;

  ADD_INSN1(ret, nd_line(node), topn, dupidx);
  ADD_ELEM(ret, (LINK_ELEMENT *)iobj);
  ADD_INSN(ret, nd_line(node), pop);	/* result */
  ADD_INSN(ret, nd_line(node), pop);	/* rhs    */
  break;
}

いっぺんコンパイルして作った命令列を書き直して再利用してしまうとは。豪快でした。

compile_massign ふたたび

そんなこんなで、スタックに積んだ右辺の値を、左辺で取り込む方のコンパイルは終わりました。今度は右辺の値をスタックに積む側を読んでいきます。方針として

  • 左辺より右辺の方が長い場合、pushしすぎない
  • 左辺より右辺の方が短い場合、足りない文nilで補う
  • あとは基本的にひたすら順番にpushするだけ。

です。右辺積み部隊は、lhsnをたどる再帰の一番底で呼ばれます。

static int
compile_massign(yarv_iseq_t *iseq, LINK_ANCHOR *ret,
		NODE * rhsn, NODE * splatn, NODE * lhsn, int llen)
{
  if (lhsn != 0) {
    ...
  }
  else {
    ...
    switch (nd_type(rhsn)) {
    case NODE_ARRAY:   ...
    case NODE_TO_ARY:  ...
    case NODE_SPLAT:   ...
    case NODE_ARGSCAT: ...
    default:          ...
    }
  }
}

この時点で、

  • llen : 左辺の、*より前の項の個数
  • splatn : *つき項がもしあれば、それ
  • rhsn : 右辺全体

となっています。llen個ぴったりスタックに積むのが目標になります。簡単なケースから行くと

default:
  COMPILE(ret, "rhs to ary (splat/default)", rhsn);
  ADD_INSN2(ret, nd_line(rhsn), expandarray, INT2FIX(llen),
            INT2FIX(lhs_splat));
  break;

これは、a,b,c = d みたいなパターンですね。「dを配列化して、先頭llen個をスタックに積め」というまさにそのまんまな命令がYARVにあります。expandarray命令です。NODE_TO_ARY, NODE_SPLATも構文が違うだけで、やる処理はまったくおんなじです。次〜。

case NODE_ARRAY:

これは、a,b,c = d,e,f,g みたいに、右辺が単純に複数個並んでる場合。やるべきことは

  • llen個目までは、COMPILE で値をスタックに残すようにコンパイル
  • llen個を過ぎたら
    • splatn があれば、引き続き COMPILE で値をスタックに残すようにコンパイル
      • 最後に、llen個目以降を配列にまとめる newarray 命令を追加
    • splatn がなければ、COMPILE_POPED で、値をスタックに残さないようコンパイル
  • 右辺全部スタックに入れてもllen個に足りなかったら、残りをnilで埋める

こうです。ここで「llen個目より後ろはコンパイルしない」とかにしちゃうと、後ろの方でオブジェクトの状態が変わったり何か副作用があることをやってたときに、意味が違ってしまうのでダメです。
えーと、該当部分のコード(日本語コメントはhzkrによる注釈入り)。ちょび長いです。

int rlen = rhsn->nd_alen;
int max = rlen > llen ? rlen : llen;
int i, si = 0;

for (i = 0; i < max; i++) {
  if (i < rlen && i < llen) {
     // llen個目までは、COMPILEで、
     // スタックに値を残すようコンパイル
     /* a, b = c, d */
     COMPILE(ret, "masgn val1", rhsn->nd_head);
          rhsn = rhsn->nd_next;
  }
  else if (i < rlen) {
    // llen個を過ぎたら
    if (lhs_splat) {
      // splatn があれば
      while (rhsn) {
        // 引き続き COMPILE で
        /* a, *b = x, y, z */
        si++;
        COMPILE(ret, "masgn rhs for lhs splat",
                rhsn->nd_head);
        rhsn = rhsn->nd_next;
      }
      break;
    }
    else {
      // splatn がなければ
      // COMPILE_POPED で
      /* a, b = c, d, e */
      COMPILE_POPED(ret, "masgn rhs (popped)",
                    rhsn->nd_head);
      rhsn = rhsn->nd_next;
    }
  }
  else if (i < llen) {
    // llen個に足りてなかったらnilで埋める
    /* a, b, c = c, d */
    ADD_INSN(ret, 0, putnil);
  }
}

// 最後に、llen個目以降を配列にまとめる newarray 命令
if (lhs_splat) {
  ADD_INSN1(ret, 0, newarray, INT2FIX(si));
}

というわけでした。最後に残った

case NODE_ARGSCAT:

は a,b,c = d,e,*f のパターンで、これはここまでの2つの合わせ技なので省略します。

よだん:評価順序

YARVでは、「右辺を順番にスタックに積んで、左辺で逆順にpopする」となっていることがわかりました。ということは、こんなコード書かないよ!というのは置いておくとすると、

dummy = {}
dummy[p 1], dummy[p 2] = dummy[p 3], dummy[p 4]

これの実行結果は必然的に

3
4
2
1

こうなりますね。一方ふつうのRuby (1.9.0)だと

3
4
1
2

こうなりました。「左から右」がRubyの評価順の原則と聞いたことがあります。それで1,2なのだと思いますが、スタックマシンだとこれに合わせるのは大変そうですね。
無理矢理やると、左辺への代入を毎回topnで掘り起こす形にコンパイル...効率悪そうかな。。。