ひとり勉強会

ひとり楽しく勉強会

[YARV] 自己代入の注意点

a += 100

自己代入とは、上のように、自分と他の値の演算結果を、また自分に代入し直すような式のことをいいます。これは基本的には、

a = a + 100

と書き直したのと同じ意味です。実際、ほんとうに同じ意味になる場合はRuby構文解析器が、YARVに渡すより前に同じ意味の代入式に書き換えてくれてます。ただし、いくつかの場合ではこの書き換えではダメで、今日はそのダメな部分を扱います。

属性参照

expr.attr += 100
expr.attr = expr.attr + 100

この二つの文は、ちょっと意味が違います。前者ではオブジェクトを表す式 expr は1回だけ評価されますが、後者だと2回評価されます。変数への自己代入なら1回も2回もありませんが、属性参照の場合は expr に任意の式が来る可能性があるので、1回と2回で意味が変わってきてしまいます。

and, or

プログラミング言語 Ruby リファレンスマニュアル にあるように、演算子が && か || の場合、

1 op= 式2

# 式1 = 式1 op 式2 ではなく1 op (式1 = 式2)

みたいになります。どういうことかというと、式1の値によっては、代入が行われません。属性参照の場合は attr= メソッドが呼ばれないということになるので、意味が変わってきます。普通の変数への代入でも、無駄な操作が減るので、たぶん若干効率があがります。あとそもそも、+ や - のような演算子Rubyのメソッドですが、|| や && は特別な構文なので扱いが分かれざるを得ません。
この辺ふまえた上で、自己代入関連のノードは4つに分かれています。ではどうぞ。

NODE_OP_ASGN1

a[x] op= y

のパターンは、NODE_OP_ASGN1 という名前になっています。どういうバイトコードコンパイルされるのか、コメントがついていました。

/*
 * eval a    # a
 * eval x    # a x
 * dupn 2    # a x a x
 * send :[]  # a x a[x]
 * eval y    # a x a[x] y
 * send op   # a x a[x]+y
 * send []=  # ret
 */

わかりやすいですね。a や x の評価は1回だけで、あとは dupn 2 命令で複製して使っているのがわかります。先ほど説明したように、op が || や && の場合は生成するコードも変わってきます。こちらは

if (id == 0 || id == 1) {
  /* 0: or, 1: and
    a[x] ||= y

    unless/if a[x]
      a[x]= y
    else
      nil
    end
   */

こういうコメントがありました。実際のコードはコメント通りにコード生成しているだけなので、省略と言うことで。

NODE_OP_ASGN2

r.a op= v

NODE_OP_ASGN2 は、属性への自己代入のノードです。

  eval r    # r
  dup       # r r
  eval r.a  # r o
  eval v    # r o v
  send op   # r w
  send a=   # w

r.a = r.a op v を、rを2度評価しないようにdupで複製しながら実行するコードですね。こちらも、op が && と || の場合は別枠になっていますが省略。

NODE_OP_ASGN_OR 等

続いて、属性や添字式以外への、要するに普通の変数への &&= か ||= のノードです。繰り返しになりますが、普通の変数への += 等は = と + の組み合わせに展開して問題ないので、特別には扱われていません。というわけで、これが自己代入系最後のノードです。

この場合も生成されるコードは、ほぼここまでで説明したとおりで、そんなに特殊なことはやってません。でも ||= のバイトコードで一カ所ちょっと気になりました。&&= の方は特にひっかからなかったのでこれまた省略しちゃいます。

#x ||= v
  eval defined?(x)      #
  branchunless lassign  # ← ?
  eval x
  dup
  branchif lfin
  pop
lassign:
  eval x=v
lfin:

左辺の変数がdefinedかどうかチェックして、そうでなければevalを飛ばしていきなり右辺値の代入までジャンプしています。該当部のコードはこちら。

if (nd_type(node) == NODE_OP_ASGN_OR) {
  defined_expr(iseq, ret, node->nd_head, lassign, Qfalse);
  ADD_INSNL(ret, nd_line(node), branchunless, lassign);
}

defined_expr の詳細はあとで NODE_DEFINED の時に読むとして、要するに、Ruby の defined? に当たる命令列を生成してくれる関数です。
この分岐は、「まだ定義されてなかったら、そのときだけ、変数/定数に値を入れる」という目的で使われる ||= をサポートするため、かな?

Constant ||= 123
print Constant

こんなの。yarv で実行すると 123 が表示されます。これを上記の4行をコメントアウトしてビルドしたyarvで動かすと

C:\Desktop> yarv -v test.rb
ruby 1.9.0 (Base: Ruby 1.9.0 2006-11-06) [i386-mswin32]
YARVCore 0.4.1 Rev: 581 (2006-11-10) [opts: [inline method cache] ]
test.rb:1:in `<main>': uninitialized constant Constant (NameError)

怒られました。ただ現行のRubyでも

C:\Desktop> ruby -v test.rb
ruby 1.9.0 (2006-04-15) [i386-mswin32]
test.rb:1: uninitialized constant Constant (NameError)

どうなんだろう。保留。

NODE_CALL 等

続いて、メソッド呼び出しです。3種類のノードがありますが、それぞれの内訳はこの通り。

case NODE_CALL:
case NODE_FCALL:
case NODE_VCALL:{ /* VCALL: variable or call */
  /*
    call:  obj.method(...)
    fcall: func(...)
    vcall: func
   */

vcallは、機能としては単純に引数0個のfcallだと思います。たぶん、引数も括弧もないからローカル変数かもしんないね、っていうエラーメッセージを出すため「だけ」にフラグのためなどに分かれているのかな。

メソッド呼び出しは、4ステップに分かれます。

  • receiver (objかself) をスタックに積む
  • 引数をスタックに積む
  • ブロックが付属してればその処理
  • メソッド呼び出し(send)

順番にコードを追って行きましょう。

#if SUPPORT_JOKE
  ...
#endif

ここはスルー力が試されています。

/* reciever */
if (type == NODE_CALL) {
  COMPILE(recv, "recv", node->nd_recv);
}
else if (type == NODE_FCALL || type == NODE_VCALL) {
  ADD_INSN(recv, nd_line(node), putself);
}

まずはreceiver。そのまんまです。

/* args */
if (nd_type(node) != NODE_VCALL) {
  argc = setup_arg(iseq, args, node, &flag);
}
else {
   argc = INT2FIX(0);
}

続いて引数。代入の話のときに後回しにした補助関数、setup_arg です。その名の通りメソッド呼び出しの引数をセットアップする命令列を作ってくれそうな関数です。かるーく見てみましょう。

static VALUE
setup_arg(yarv_iseq_t *iseq, LINK_ANCHOR *args, NODE *node, VALUE *flag)
{
  ... 略 ...

  if (argn && nd_type(argn) == NODE_BLOCK_PASS) {
    COMPILE(arg_block, "block", argn->nd_body);
    *flag |= VM_CALL_ARGS_BLOCKARG_BIT;
    argn = argn->nd_head;
  }

ブロック引数があった場合…つまり、引数の最後に "&" つきで手続きオブジェクトを渡したばあいです。arg_blockは、普通の引数をスタックの後ろに積んだその後に積まれます。普通の引数を評価する命令列はこちら

setup_argn:
  if (argn) {
    switch (nd_type(argn)) {
      case NODE_SPLAT: { ... 略 ...
      case NODE_ARGSCAT: { ... 略 ...
      case NODE_ARGSPUSH: { ... 略 ...
      default: { ... 略 ...

カンマ区切りで並んでたり *arg で展開してたりするパターンで場合分けされてて大変なのですが、要は左から右にひたすら引数を積んでいます。疲れてきたので詳細略です。*arg で呼ばれた場合を表す VM_CALL_ARGS_SPLAT_BIT というフラグも用意されていました。

  ADD_SEQ(ret, recv);
  ADD_SEQ(ret, args);

で、setup_arg の話は終わりにして NODE_CALL に戻ります。とりあえずレシーバと引数を評価する命令列まで作り終えました。つぎは、ブロックです。

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

obj.call { ブロック } のブロックの中身は NODE_ITERコンパイル済みです。NODE_CALL のこの箇所には、そのコンパイル済み命令列が parent_blockval に入った状態で到達します。
えーとそれで、最下位ビットを見て分岐しているようなんですけど、これなんでしたっけ。ビットが立っている場合は、「手続きオブジェクトをスタックに積む」命令が parent_blockval に入っていることを前提としたコードになっています。そうでない場合は、ブロックの中身をコンパイルした命令列なはずで、それを一旦ローカルのblock変数に覚えています。

  switch (nd_type(node)) {
    case NODE_VCALL:
      flag |= VM_CALL_VCALL_BIT;
      /* VCALL is funcall, so fall through */
    case NODE_FCALL:
      flag |= VM_CALL_FCALL_BIT;
  }

fcallなのかvcallなのかのフラグを立てて

  ADD_SEND_R(ret, nd_line(node), ID2SYM(mid), argc,
	     block, INT2FIX(flag));

send命令を生成。send命令は、メソッド名のシンボル(mid)と、呼び出し側が渡した引数の個数(mid)、もしあればブロック(block)、そして様々なフラグ(flag)の4引数を取っています。

  if (poped) {
      ADD_INSN(ret, nd_line(node), pop);
  }
  break;

あとはおきまりのpopチェック。完了です。