以下の内容はhttps://yui-knk.hatenablog.com/entry/2026/02/15/133356より取得しました。


Ruby Parser開発日誌 (24-34) - parse.yが生成するノードを変える ー array assignment with operatorとattr assignment with operator

34日目: ary[1] += foostruct.field += foo

前回は匿名引数とforwardingというメソッド呼び出しに関係する部分をやりました。 今回はary[1] += foostruct.field += fooという形式のメソッド呼び出しについて取り組んでいきたいと思います。

ary[1] += fooの場合

この形式のメソッド呼び出しでは最終的に以下の3つを異なるものとして扱う必要があると思います。

  1. ary[1] += foo
  2. ary[1] ||= foo
  3. ary[1] &&= foo

というのもこれらは以下のような意味になるわけですが、とくに1と2 & 3では生成されるバイトコードが異なるはずだからです。 1はメソッド呼び出しと代入ですが、2と3ではメソッド呼び出しの結果をみて分岐する必要があります。

  1. ary[1] = ary[1] + foo
  2. ary[1] = ary[1] || foo
  3. ary[1] = ary[1] && foo

そのあたりに注意しながら進めていきましょう。

parse.yの変更

ノードの書き換え前はいずれのケースもNODE_OP_ASGN1で表現していて、それぞれの違いはnd_midの部分で区別をしています。

# @ NODE_OP_ASGN1 (id: 4, line: 1, location: (1,0)-(1,13))*
# +- nd_recv:
# |   @ NODE_VCALL (id: 0, line: 1, location: (1,0)-(1,3))
# |   +- nd_mid: :ary
# +- nd_mid: :+
# +- nd_index:
# |   @ NODE_LIST (id: 2, line: 1, location: (1,4)-(1,5))
# |   +- as.nd_alen: 1
# |   +- nd_head:
# |   |   @ NODE_INTEGER (id: 1, line: 1, location: (1,4)-(1,5))
# |   |   +- val: 1
# +- nd_rvalue:
# |   @ NODE_VCALL (id: 3, line: 1, location: (1,10)-(1,13))
# |   +- nd_mid: :foo
ary[1] += foo

# @ NODE_OP_ASGN1 (id: 9, line: 2, location: (2,0)-(2,13))*
# +- nd_recv:
# |   @ NODE_VCALL (id: 5, line: 2, location: (2,0)-(2,3))
# |   +- nd_mid: :ary
# +- nd_mid: :-
# +- nd_index:
# |   @ NODE_LIST (id: 7, line: 2, location: (2,4)-(2,5))
# |   +- as.nd_alen: 1
# |   +- nd_head:
# |   |   @ NODE_INTEGER (id: 6, line: 2, location: (2,4)-(2,5))
# |   |   +- val: 1
# +- nd_rvalue:
# |   @ NODE_VCALL (id: 8, line: 2, location: (2,10)-(2,13))
# |   +- nd_mid: :foo
ary[1] -= foo

# @ NODE_OP_ASGN1 (id: 16, line: 3, location: (3,0)-(3,14))*
# +- nd_recv:
# |   @ NODE_VCALL (id: 12, line: 3, location: (3,0)-(3,3))
# |   +- nd_mid: :ary
# +- nd_mid: :||
# +- nd_index:
# |   @ NODE_LIST (id: 14, line: 3, location: (3,4)-(3,5))
# |   +- as.nd_alen: 1
# |   +- nd_head:
# |   |   @ NODE_INTEGER (id: 13, line: 3, location: (3,4)-(3,5))
# |   |   +- val: 1
# +- nd_rvalue:
# |   @ NODE_VCALL (id: 15, line: 3, location: (3,11)-(3,14))
# |   +- nd_mid: :foo
ary[1] ||= foo

# @ NODE_OP_ASGN1 (id: 22, line: 4, location: (4,0)-(4,14))*
# +- nd_recv:
# |   @ NODE_VCALL (id: 18, line: 4, location: (4,0)-(4,3))
# |   +- nd_mid: :ary
# +- nd_mid: :&&
# +- nd_index:
# |   @ NODE_LIST (id: 20, line: 4, location: (4,4)-(4,5))
# |   +- as.nd_alen: 1
# |   +- nd_head:
# |   |   @ NODE_INTEGER (id: 19, line: 4, location: (4,4)-(4,5))
# |   |   +- val: 1
# +- nd_rvalue:
# |   @ NODE_VCALL (id: 21, line: 4, location: (4,11)-(4,14))
# |   +- nd_mid: :foo
ary[1] &&= foo

ノードの書き換え後は+=-=IndexOperatorWriteNode||=IndexOrWriteNode&&=IndexAndWriteNodeで表すようになります。

parse.yではnew_ary_op_assign関数でNODE_OP_ASGN1を生成しています。

%rule op_asgn(rhs) <node> | primary_value '['[lbracket] opt_call_args rbracket tOP_ASGN lex_ctxt rhs
                              {
                                  $$ = new_ary_op_assign(p, $primary_value, $opt_call_args, $tOP_ASGN, $rhs, &@opt_call_args, &@$, &NULL_LOC, &@lbracket, &@rbracket, &@tOP_ASGN);
                              /*% ripper: opassign!(aref_field!($:1, $:3), $:5, $:7) %*/
                              }

この関数を変更して、$tOP_ASGNに応じて生成するノードを変えればよいでしょう。

static rb_node_t *
new_ary_op_assign(struct parser_params *p, rb_node_t *ary,
                  rb_arguments_node_t *args, ID op, rb_node_t *rhs, const YYLTYPE *args_loc, const YYLTYPE *loc,
                  const YYLTYPE *call_operator_loc, const YYLTYPE *opening_loc, const YYLTYPE *closing_loc, const YYLTYPE *binary_operator_loc)
{
    rb_node_t *asgn;

    aryset_check(p, args);

    switch (op) {
      case idOROP:
        asgn = NEW_RB_INDEX_OR_WRITE(ary, args, rhs, loc, call_operator_loc, opening_loc, closing_loc, binary_operator_loc);
        break;
      case idANDOP:
        asgn = NEW_RB_INDEX_AND_WRITE(ary, args, rhs, loc, call_operator_loc, opening_loc, closing_loc, binary_operator_loc);
        break;
      default :
        asgn = NEW_RB_INDEX_OPERATOR_WRITE(ary, op, args, rhs, loc, call_operator_loc, opening_loc, closing_loc, binary_operator_loc);
        break;
    }
    fixpos(asgn, ary);
    return asgn;
}

バイトコードを眺める

コンパイラに変更を加える前にバイトコードを見ておきましょう。

# == disasm: #<ISeq:<main>@test.rb:1 (1,0)-(4,13)>
# 0000 putnil                                                           (   4)[Li]
# 0001 putself
# 0002 opt_send_without_block                 <calldata!mid:ary, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0004 putobject_INT2FIX_1_
# 0005 dupn                                   2
# 0007 opt_aref                               <calldata!mid:[], argc:1, ARGS_SIMPLE>[CcCr]
# 0009 putself
# 0010 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0012 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
# 0014 setn                                   3
# 0016 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
# 0018 pop
# 0019 leave
ary[1] += foo

それぞれのステップにおけるスタックの状態を考えていきます。 まず初めにnilをスタックに積み、その上に左辺に当たるary1を積みます。 このnilはあとで式全体の値を保持するのに使います。

# `0004 putobject_INT2FIX_1_`まで
1
ary
nil

ary[1] += fooというのはary[1] = ary[1] + fooのことであり、この右辺にあるary[1]を評価します。 後にary[1] =のメソッド呼び出しをすることになるので、ここではdupnをつかってスタックをコピーしておきます。

# `0005 dupn 2`まで
1
ary
1
ary
nil

# `0007 opt_aref <calldata!mid:[], argc:1, ARGS_SIMPLE>[CcCr]`まで
ary[1]
1
ary
nil

ary[1] = ary[1] + foofooary[1] + fooの評価をします。 この時点で式全体の戻り値が確定するので、最初にスタックにおいたnilsetnを用いて置き換えます。

# `0010 opt_send_without_block <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>`まで
foo
ary[1]
1
ary
nil

# `0012 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]`まで
ary[1] + foo
1
ary
nil

# `0014 setn 3`まで
ary[1] + foo
1
ary
ary[1] + foo

最後にary[1] =の部分を評価し、popを用いてスタックの状態を調整して完了です。

# `0016 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]`まで
# ary.[]=(1, ary[1] + foo) と同等
ary.[]=(1, ary[1] + foo)
ary[1] + foo

# `0018 pop`まで
# 右辺が最終的な式の評価値になる
ary[1] + foo

ではary[1] ||= fooの場合はどうなるでしょうか。

# == disasm: #<ISeq:<main>@test.rb:1 (1,0)-(1,14)>
# 0000 putnil                                                           (   1)[Li]
# 0001 putself
# 0002 opt_send_without_block                 <calldata!mid:ary, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0004 putobject_INT2FIX_1_
# 0005 dupn                                   2
# 0007 opt_aref                               <calldata!mid:[], argc:1, ARGS_SIMPLE>[CcCr]
# 0009 dup
# 0010 branchif                               22
# 0012 pop
# 0013 putself
# 0014 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0016 setn                                   3
# 0018 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
# 0020 pop
# 0021 leave
# 0022 setn                                   3
# 0024 adjuststack                            3
# 0026 leave
ary[1] ||= foo

||なのでary[1]の値がnilfalseのときだけ代入が行われます。 そのためary[1]を評価したあと0010 branchif 22でその値をチェックして分岐しています。 もしary[1]がfalsyならばそのまま0012 popに進み、先ほど同じような命令を実行して0021 leaveで抜けます。

ary[1]がtruthyの場合は0022 setn 3以降の命令が実行されます。 0010 branchif 22の時点でのスタックは以下のようになっています。

ary[1]
1
ary
nil

ary[1] = ary[1] || fooary[1]がtruthyの場合、式全体の値はary[1]になります。 そこでsetnary[1]nilの場所にコピーし、adjuststackでスタックの高さを調整します。

# `0022 setn 3`まで
ary[1]
1
ary
ary[1]

# `0024 adjuststack 3`まで
ary[1]

compile.cの変更

バイトコードはそれなりに複雑ですがcompile.cの変更は局所的です。 今までは3つのパターン全てがNODE_OP_ASGN1というノードで表現されてきました。 なのでそのノードをコンパイルするcompile_op_asgn1関数の中に&&=||=などidに基づいた分岐があります。

ノードの書き換え後は3つのパターンで異なるノードを使いわけるようになるので、compile_op_asgn1関数の外でidの解決を行うようにします。

 static int
-compile_op_asgn1(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, int popped)
+compile_op_asgn1(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, const NODE *const nd_recv, ID id, const rb_arguments_node_t *cons
t nd_index, const NODE *const nd_rvalue, int popped)
 {
     const int line = nd_line(node);
     VALUE argc;
     unsigned int flag = 0;
     int asgnflag = 0;
-    ID id = RNODE_OP_ASGN1(node)->nd_mid;

     /*
      * a[x] (op)= y

+      case RB_INDEX_OPERATOR_WRITE_NODE: {
+        rb_index_operator_write_node_t *cast = (rb_index_operator_write_node_t *)node;
+        CHECK(compile_op_asgn1(iseq, ret, node, cast->receiver, cast->binary_operator, cast->arguments, cast->value, popped));
+        break;
+      }
+      case RB_INDEX_OR_WRITE_NODE: {
+        rb_index_or_write_node_t *cast = (rb_index_or_write_node_t *)node;
+        CHECK(compile_op_asgn1(iseq, ret, node, cast->receiver, idOROP, cast->arguments, cast->value, popped));
+        break;
+      }
+      case RB_INDEX_AND_WRITE_NODE: {
+        rb_index_and_write_node_t *cast = (rb_index_and_write_node_t *)node;
+        CHECK(compile_op_asgn1(iseq, ret, node, cast->receiver, idANDOP, cast->arguments, cast->value, popped));
+        break;
+      }

minirubyをビルドして実行してみます。

a = [0, 1]
a[0] += 2
p a
#=> [2, 1]

b = [true, false]
b[0] ||= 0
b[1] ||= 1
p b
#=> [true, 1]

c = [true, false]
c[0] &&= 0
c[1] &&= 1
p c
#=> [0, false]

良さそうです。

struct.field += fooの場合

続いてstruct.field += fooのケースをやっていきましょう。 具体的には以下の3つのパターンがあります。

s.f += foo
s.f &&= foo
s.f ||= foo

このケースもary[1] += fooと同様に書き換え前は3つのパターン全てをNODE_OP_ASGN2という1つのノードで表現していました。 また書き換え後はary[1] += fooと同様に、CallOperatorWriteNode, CallAndWriteNode, CallOrWriteNodeの3種類のノードを使い分けることになります。

parseの変更

NODE_OP_ASGN2new_attr_op_assign関数で生成しているので、この関数を修正してidの種類に応じて生成するノードを切り替えます。

static rb_node_t *
new_attr_op_assign(struct parser_params *p, rb_node_t *lhs,
                   ID atype, ID attr, ID op, rb_node_t *rhs, const YYLTYPE *loc,
                   const YYLTYPE *call_operator_loc, const YYLTYPE *message_loc, const YYLTYPE *binary_operator_loc)
{
    rb_node_t *asgn;

    switch (op) {
      case idOROP:
        asgn = NEW_RB_CALL_OR_WRITE(lhs, attr, rhs, loc, call_operator_loc, message_loc, binary_operator_loc);
        break;
      case idANDOP:
        asgn = NEW_RB_CALL_AND_WRITE(lhs, attr, rhs, loc, call_operator_loc, message_loc, binary_operator_loc);
        break;
      default: {
        int flags = CALL_Q_P(atype) ? RB_CALL_NODE_FLAGS_SAFE_NAVIGATION : 0;
        asgn = NEW_RB_CALL_OPERATOR_WRITE(lhs, flags, attr, op, rhs, loc, call_operator_loc, message_loc, binary_operator_loc);
        break;
      }
    }

    fixpos(asgn, lhs);
    return asgn;
}

ここで注意点が2つあります。

1つはwrite_nameというフィールドについてです。 例えばs.f += fooというコードがあるとき、そのバイトコードではs.fの呼び出しとs.f=の呼び出しを行うことになります。 ノードの書き換え前はnd_vid: :fとしてfのシンボルだけをノードに持たせて、:f=コンパイル時に計算していました。 ノードの書き換え後はread_name: :fwrite_name: :f=として両方のシンボルをノードに持たせることになります。

もうひとつはs.f += foos&.f += fooをどのようにして区別するかです。 ノードの書き換え前はbool nd_aidというフィールドがノードにあり、それをみて.なのか&.なのかを判断していました。 ノードの書き換え後はCallOperatorWriteNodeのフラグにsafe_navigationが立っているかどうかで判断するようになります。

コンパイラを修正する

コンパイラの修正はarray assignment with operatorのときと同様にcompile_op_asgn2関数の呼び出し元で必要な情報を渡すように書き換えます。

@@ -11897,6 +11901,21 @@ iseq_compile_each0(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const no
         CHECK(compile_op_asgn1(iseq, ret, node, cast->receiver, idANDOP, cast->arguments, cast->value, popped));
         break;
       }
+      case RB_CALL_OPERATOR_WRITE_NODE: {
+        rb_call_operator_write_node_t *cast = (rb_call_operator_write_node_t *)node;
+        CHECK(compile_op_asgn2(iseq, ret, node, cast->receiver, cast->read_name, cast->write_name, cast->binary_operator, cast->value, popped));
+        break;
+      }
+      case RB_CALL_OR_WRITE_NODE: {
+        rb_call_or_write_node_t *cast = (rb_call_or_write_node_t *)node;
+        CHECK(compile_op_asgn2(iseq, ret, node, cast->receiver, cast->read_name, cast->write_name, idOROP, cast->value, popped));
+        break;
+      }
+      case RB_CALL_AND_WRITE_NODE: {
+        rb_call_and_write_node_t *cast = (rb_call_and_write_node_t *)node;
+        CHECK(compile_op_asgn2(iseq, ret, node, cast->receiver, cast->read_name, cast->write_name, idANDOP, cast->value, popped));
+        break;
+      }

いくつかコードを実行して確認してみましょう。

class S
  attr_accessor :f
end

s = nil
s&.f += 1
p s
#=> nil

s = S.new
p s.f
#=> nil

s.f = 0

s.f += 1
p s.f
#=> 1

s&.f += 2
p s.f
#=> 3

&&=||=も確認します。

class S
  attr_accessor :f
end

s = S.new
p s.f
#=> nil

s.f &&= 1
p s.f
#=> nil

s.f ||= 1
p s.f
#=> 1

良さそうです。

まとめ

今日の成果です。

  • array assignment with operator (ary[1] += foo)に対応した
  • attr assignment with operator (s.f += foo)に対応した



以上の内容はhttps://yui-knk.hatenablog.com/entry/2026/02/15/133356より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14