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


Ruby Parser開発日誌 (24-13) - parse.yが生成するノードを変える ー multiple assignment (コンパイラ編)

13日目: multiple assignmentをコンパイルする

前回はmultiple assignmentのノードを変更したので、今回はコンパイラをやっていこうとおもいます。

バイトコードを観察する

取り扱うノードの種類が微妙に変わったので、multiple assignmentに対応するバイトコードについてすこし理解を深めておきましょう。 まずはmultiple assignmentのバイトコードをいくつか観察します。

基本となる形はa, b = fooでしょう。

# == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
# local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 2] a@0        [ 1] b@1
# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            2, 0
# 0007 setlocal_WC_0                          a@0
# 0009 setlocal_WC_0                          b@1
# 0011 leave
a, b = foo

対応するバイトコードの構造は次のようになっています。

  1. 右辺(foo)を実行する
  2. expandarrayする
  3. abをそれぞれsetlocalする

expandarray命令は今回初登場なので定義をみてみます。

/* if TOS is an array expand, expand it to num objects.
     if the number of the array is less than num, push nils to fill.
     if it is greater than num, exceeding elements are dropped.
     unless TOS is an array, push num - 1 nils.
     if flags is non-zero, push the array of the rest elements.
     flag: 0x01 - rest args array
     flag: 0x02 - for postarg
     flag: 0x04 - reverse?
 */
DEFINE_INSN
expandarray
(rb_num_t num, rb_num_t flag)
(..., VALUE ary)
(...)
// attr bool handles_sp = true;
// attr bool leaf = false; /* has rb_check_array_type() */
// attr rb_snum_t sp_inc = (rb_snum_t)num - 1 + (flag & 1 ? 1 : 0);
{
    vm_expandarray(GET_CFP(), ary, num, (int)flag);
}

第一オペランドは配列を行くつの要素に分解するかを表しています。 第二オペランドはフラグになっています。

  • 0x00: numを超える要素については捨てる
  • 0x01: numを超える要素については配列にいれる。preとrest用のスタックの積み方をする
  • 0x02: numを超える要素については配列にいれる。post用のスタックの積み方をする
  • 0x04: 多分使われていないような気がする

今回のケースでは0x00が指定されているので、fooから2つの要素を取り出してスタックに積み、残りの要素はスタックには積みません。 abという2つの変数に代入すればいいので、3つ目以降の要素については特にスタックにおく必要はないでしょう。

restがあるとき

次にa, b, *r = fooのようにrestな要素があるときを考えます。 先ほどのケースと異なり、expandarrayの第二オペランド0x01になっています。 このときexpandarraynumを超える数の要素を配列に入れてスタックにおきます。 この配列がrに代入されることになります。

# == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,14)>
# local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 3] a@0        [ 2] b@1        [ 1] r@2
# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            2, 1
# 0007 setlocal_WC_0                          a@0
# 0009 setlocal_WC_0                          b@1
# 0011 setlocal_WC_0                          r@2
# 0013 leave
a, b, *r = foo

続いてa, b, *r, c, d = fooのようにrestとpostがあるケースです。 このときはexpandarrayを2回行います。 1回目のexpandarrayabで使う値を設定し、2回目のexpandarray*rcdで使う値をスタックに積みます。

# == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,20)>
# local table (size: 5, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 5] a@0        [ 4] b@1        [ 3] r@2        [ 2] c@3        [ 1] d@4
# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            2, 1
# 0007 setlocal_WC_0                          a@0
# 0009 setlocal_WC_0                          b@1
# 0011 expandarray                            2, 3
# 0014 setlocal_WC_0                          r@2
# 0016 setlocal_WC_0                          c@3
# 0018 setlocal_WC_0                          d@4
# 0020 leave
a, b, *r, c, d = foo

*r, c, d = fooのときも2つのexpandarray命令が生成されます。 preが存在しないので、1つ目のexpandarrayにおける第一オペランド0になっています。

# == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,14)>
# local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 3] r@0        [ 2] c@1        [ 1] d@2
# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            0, 1
# 0007 expandarray                            2, 3
# 0010 setlocal_WC_0                          r@0
# 0012 setlocal_WC_0                          c@1
# 0014 setlocal_WC_0                          d@2
# 0016 leave
*r, c, d = foo

vm_expandarray関数

expandarray命令の主な処理を行うのがvm_expandarray関数です。 vm_expandarray関数はflagの0x02がたっているかどうかでスタックへの値の積み方が変わります。

たとえばa, b, *r, c, d = [0, 1, 2, 3, 4, 5]のケースを考えます。 一回目のexpandarrayではスタックには上から0, 1, [2, 3, 4, 5]の順に値が積まれます。

0
1
[2, 3, 4, 5]
...

二回目のexpandarrayではスタックには上から[2, 3], 4, 5の順に値が積まれます。

[2, 3]
4
5

expandarrayのあとに続くsetlocal命令が、a, b ... dと左にあるものほど先に実行されるためです。

メソッド呼び出しがあるケース

a[0], s.f = fooのように代入のさいにメソッドが呼ばれるケースを考えていきます。

変数に対する代入であればselocalsetinstancevariableといった命令一つで処理することができますが、メソッド呼び出しの場合にはレシーバーと引数をスタック積んだうえで、opt_send_without_blockなどの命令を実行する必要があります。

# == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,15)>

# 1. `a[0]`のセットアップ
# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 putobject_INT2FIX_0_

# 2. `s`のセットアップ
# 0004 putself
# 0005 opt_send_without_block                 <calldata!mid:s, argc:0, FCALL|VCALL|ARGS_SIMPLE>

# 3. `foo`を評価する
# 0007 putself
# 0008 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>

# 4. 右辺を展開する
# 0010 dup
# 0011 expandarray                            2, 0

# 5. `a[0]= x`用のセットアップをしてメソッドを呼び出す
# 0014 topn                                   5
# 0016 topn                                   5
# 0018 topn                                   2
# 0020 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
# 0022 pop
# 0023 pop

# 6. `s.f = y`用のセットアップをしてメソッドを呼び出す
# 0024 topn                                   2
# 0026 swap
# 0027 opt_send_without_block                 <calldata!mid:f=, argc:1, ARGS_SIMPLE>
# 0029 pop

# 7. 式全体の戻り値の調整
# 0030 setn                                   3
# 0032 pop
# 0033 pop
# 0034 pop

# 0035 leave
a[0], s.f = foo

いままでと比べて命令列が長いです。 いくつかのステップに分けて考えましょう。

最初に左辺のうち評価が必要な部分を評価します。 例でいうと#1と#2です。 この時点でスタックは以下のようになります。

s
0
a

次に右辺を評価します(#3)。 ここでは[1, 2, 3, 4]になったとします。 スタックの一番上に配列が積まれます。

[1, 2, 3, 4]
s
0
a

スタックトップをdupしてからexpandarrayで展開します(#4)。

# dup
[1, 2, 3, 4]
[1, 2, 3, 4]
s
0
a

# expandarray 2, 0
1
2
[1, 2, 3, 4]
s
0
a

準備ができたのでa[0]= 1を実行したいのですが、そのためにはスタックトップにレシーバーと引数が積まれていないといけません。 そこでtopn命令を使ってスタックの下の方にある値をスタックトップにコビーします(#5)。

# `0014 topn 5`でレシーバーをスタックトップにコビーする
a
1
2
[1, 2, 3, 4]
s
0
a

# `0016 topn 5`で引数をスタックトップにコビーする
0
a
1
2
[1, 2, 3, 4]
s
0
a

# `0018 topn 2`で引数をスタックトップにコビーする
1
0
a
1
2
[1, 2, 3, 4]
s
0
a

topnを3回行うことでレシーバーと引数がスタックトップに並びました。

# `0020 opt_aset`でメソッドを呼び出した
a[0] = 1 # の戻り値
1
2
[1, 2, 3, 4]
s
0
a

#[]=メソッドを呼び出した直後のスタックをみると、a[0] = 1 # の戻り値1が不要であることがわかります。 なおこの1expandarrayによってスタックに積まれたオブジェクトです。 popを2回実行することで、次のs.f = 2の処理に移れるようにします。

# `0022 pop`と`0023 pop`を実行した
2
[1, 2, 3, 4]
s
0
a

a[0] = 1と同じようにs.f = 2のためのセットアップをします(#6)。 今回は引数が1つしかないのでswapを使うことができます。

# `0024 topn 2`でレシーバーをスタックトップにコビーする
s
2
[1, 2, 3, 4]
s
0
a

# `0026 swap`でスタックトップの2つの要素を入れ替える
# こうすることで上から引数、レシーバーの順にオブジェクトが並ぶ
2
s
[1, 2, 3, 4]
s
0
a

# `0027 opt_send_without_block`を実行したあと
s.f= 2 # の戻り値
[1, 2, 3, 4]
s
0
a

# `0029 pop`を実行したあと
[1, 2, 3, 4]
s
0
a

ここまでで左辺の評価、右辺の評価、そしてメソッド呼び出しによる代入までが終わりました。 最後にスタックを調整して、式全体を評価した値だけがスタックが残るようにします(#7)。

# `0030 setn 3`を実行したあと
[1, 2, 3, 4]
s
0
[1, 2, 3, 4]

# popを3回行った
[1, 2, 3, 4]

命令の数が多いですが、大きく分ければ以下の4つのステップから成ることがわかりました。

  1. 左辺の評価
  2. 右辺の評価と展開
  3. メソッドの呼び出し
  4. スタックの調整をして式全体の値になるようにする

multiple assignmentがネストするケース

multiple assignmentの複雑なケースとして、multiple assignmentがネストするケースを考えてみましょう。 手始めに(a, b), c, d = fooバイトコードを眺めてみます。

# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            3, 0
# 0007 expandarray                            2, 0
# 0010 setlocal_WC_0                          a@0
# 0012 setlocal_WC_0                          b@1
# 0014 setlocal_WC_0                          c@2
# 0016 setlocal_WC_0                          d@3
# 0018 leave
(a, b), c, d = foo

0004 expandarray 3, 0(a, b)を1つの要素と捉えてx, c, d = fooを展開するということでしょう。 そのうえで(a, b) = yを処理するのが0007 expandarray 2, 0から0012 setlocal_WC_0 b@1までの部分です。

ということはネストする位置が変わればexpandarrayをする位置も変わるはずです。 a, (b, c), d = fooを試してみましょう。

# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            3, 0
# 0007 setlocal_WC_0                          a@0
# 0009 expandarray                            2, 0
# 0012 setlocal_WC_0                          b@1
# 0014 setlocal_WC_0                          c@2
# 0016 setlocal_WC_0                          d@3
# 0018 leave
a, (b, c), d = foo

0004 expandarray 3, 0は変わりませんが2回目のexpandarrayの前に0007 setlocal_WC_0 a@0をしています。 ネストしたケースでは()を1つの要素として見立てて外側を分割し、代入をするときに()に当たったら、そこを再び分割をするという構造になるようです。

restやメソッド呼び出しやネストを組み合わせる

multiple assignmentの応用的な機能としてrest、メソッド呼び出し、ネストと3つの機能をみてきました。 これらを組み合わせたときのバイトコードの順番について考えてみましょう。

  1. 左辺のうち評価が必要なものを評価する
  2. 右辺の評価と展開
  3. 代入を処理する
    1. preとrestを分割するためにexpandarrayをする
    2. preの要素を1つずつ代入していく
      • 途中でネストしたmultiple assignmentがあった場合は、ネストした部分について3-1から処理をする
      • メソッドの呼び出しが必要な場合
        1. topnなどを使ってレシーバーと引数をセットアップする
        2. メソッドを呼び出す
        3. popでスタックを調整する
    3. restとpostを分割するためにexpandarrayをする
    4. restの要素を代入する
    5. postの要素を1つずつ代入していく。個別の要素についてはpreの場合と同じ
  4. スタックの調整をして式全体の値になるようにする

例えばrestとメソッド呼び出しが組み合わさったa[0], *r, s.f = fooであれば、最初にa, 0, sを評価し、expandarrayのあとに適宜メソッド呼び出しが挟まります。

# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 putobject_INT2FIX_0_
# 0004 putself
# 0005 opt_send_without_block                 <calldata!mid:s, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0007 putself
# 0008 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0010 dup
# 0011 expandarray                            1, 1
# 0014 topn                                   5
# 0016 topn                                   5
# 0018 topn                                   2
# 0020 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
# 0022 pop
# 0023 pop
# 0024 expandarray                            1, 3
# 0027 setlocal_WC_0                          r@0
# 0029 topn                                   2
# 0031 swap
# 0032 opt_send_without_block                 <calldata!mid:f=, argc:1, ARGS_SIMPLE>
# 0034 pop
# 0035 setn                                   3
# 0037 pop
# 0038 pop
# 0039 pop
# 0040 leave
a[0], *r, s.f = foo

restとネストが組み合わさった(a, *r0, b), *r1, (c, *r2, d) = fooであれば、外側の代入に対応するexpandarray、preに対応するexpandarray、postに対応するexpandarrayが生成されます。

# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 dup
# 0004 expandarray                            1, 1
# 0007 expandarray                            1, 1
# 0010 setlocal_WC_0                          a@0
# 0012 expandarray                            1, 3
# 0015 setlocal_WC_0                          r0@1
# 0017 setlocal_WC_0                          b@2
# 0019 expandarray                            1, 3
# 0022 setlocal_WC_0                          r1@3
# 0024 expandarray                            1, 1
# 0027 setlocal_WC_0                          c@4
# 0029 expandarray                            1, 3
# 0032 setlocal_WC_0                          r2@5
# 0034 setlocal_WC_0                          d@6
# 0036 leave
(a, *r0, b), *r1, (c, *r2, d) = foo

メソッド呼び出しとネストが組み合わさった(a, ary[0]), b, (c, s.f) = fooであれば、最初にa, 0, sを評価し、ネストした部分の代入を実行するときに適宜メソッド呼び出しが挟まることになります。

# 0000 putself                                                          (   1)[Li]
# 0001 opt_send_without_block                 <calldata!mid:ary, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0003 putobject_INT2FIX_0_
# 0004 putself
# 0005 opt_send_without_block                 <calldata!mid:s, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0007 putself
# 0008 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0010 dup
# 0011 expandarray                            3, 0
# 0014 expandarray                            2, 0
# 0017 setlocal_WC_0                          a@0
# 0019 topn                                   6
# 0021 topn                                   6
# 0023 topn                                   2
# 0025 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
# 0027 pop
# 0028 pop
# 0029 setlocal_WC_0                          b@1
# 0031 expandarray                            2, 0
# 0034 setlocal_WC_0                          c@2
# 0036 topn                                   2
# 0038 swap
# 0039 opt_send_without_block                 <calldata!mid:f=, argc:1, ARGS_SIMPLE>
# 0041 pop
# 0042 setn                                   3
# 0044 pop
# 0045 pop
# 0046 pop
# 0047 leave
(a, ary[0]), b, (c, s.f) = foo

compile.c

生成されるバイトコードについてある程度の理解ができたので、コンパイラの修正をしましょう。

メインの処理は全てcompile_massignという関数に書かれているので、それを踏襲します。

@@ -11358,6 +11358,13 @@ iseq_compile_each0(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const no
         break;
       }

+      case RB_MULTI_WRITE_NODE: {
+        bool prev_in_masgn = ISEQ_COMPILE_DATA(iseq)->in_masgn;
+        ISEQ_COMPILE_DATA(iseq)->in_masgn = true;
+        compile_massign(iseq, ret, RB_NODE_MULTI_WRITE(node), popped);
+        ISEQ_COMPILE_DATA(iseq)->in_masgn = prev_in_masgn;
+        break;
+      }

compile_massign関数

構造体の変更にともなう修正をしながらコードを眺めていきます。 一点補足しておくとRNODE_MASGN(node)->nd_argsが存在する場合というのは、*rがある場合ということです。 なのでnode->restをチェックするように書き換えます。

@@ -6271,9 +6271,9 @@ compile_massign0(rb_iseq_t *iseq, LINK_ANCHOR *const pre, LINK_ANCHOR *const rhs
 }

 static int
-compile_massign(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, int popped)
+compile_massign(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const rb_multi_write_node_t *const node, int popped)
 {
-    if (!popped || RNODE_MASGN(node)->nd_args || !compile_massign_opt(iseq, ret, RNODE_MASGN(node)->nd_value, RNODE_MASGN(node)->nd_head)) {
+    if (!popped || node->rest || !compile_massign_opt(iseq, ret, node->value, node->lefts)) {
         struct masgn_state state;
         state.lhs_level = popped ? 0 : 1;
         state.nested = 0;

さていきなり分岐です。 RNODE_MASGN(node)->nd_args、つまりrestがない場合にはcompile_massign_optによる最適化を試みます。 最適化は後回しにして、メインの処理をみていきます。

struct masgn_stateをセットアップしてcompile_massign0を呼び出しています。 compile.cの処理でノードとISeq以外の構造体が必要になることは珍しいのでstruct masgn_stateについては嫌な予感がします。 nestednum_argsといったフィールド名からmultiple assignmentがネストしているケースにおけるtopnオペランドの計算に必要な情報などを管理するための構造体なのではないかと思い当たりますが、一旦深く考えずにコードを読んでいきます。

static int
compile_massign(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const rb_multi_write_node_t *const node, int popped)
{
    if (!popped || node->rest || !compile_massign_opt(iseq, ret, node->value, &node->lefts)) {
        struct masgn_state state;
        state.lhs_level = popped ? 0 : 1;
        state.nested = 0;
        state.num_args = 0;
        state.first_memo = NULL;
        state.last_memo = NULL;

        DECL_ANCHOR(pre);
        INIT_ANCHOR(pre);
        DECL_ANCHOR(rhs);
        INIT_ANCHOR(rhs);
        DECL_ANCHOR(lhs);
        INIT_ANCHOR(lhs);
        DECL_ANCHOR(post);
        INIT_ANCHOR(post);
        int ok = compile_massign0(iseq, pre, rhs, lhs, post, node, &state, popped);

        struct masgn_lhs_node *memo = state.first_memo, *tmp_memo;
        while (memo) {
            VALUE topn_arg = INT2FIX((state.num_args - memo->argn) + memo->lhs_pos);
            for (int i = 0; i < memo->num_args; i++) {
                INSERT_BEFORE_INSN1(memo->before_insn, nd_line(memo->line_node), nd_node_id(memo->line_node), topn, topn_arg);
            }
            tmp_memo = memo->next;
            free(memo);
            memo = tmp_memo;
        }
        CHECK(ok);

        ADD_SEQ(ret, pre);
        ADD_SEQ(ret, rhs);
        ADD_SEQ(ret, lhs);
        if (!popped && state.num_args >= 1) {
            /* make sure rhs array is returned before popping */
            ADD_INSN1(ret, node, setn, INT2FIX(state.num_args));
        }
        ADD_SEQ(ret, post);
    }
    return COMPILE_OK;
}

ということでcompile_massign0関数をみます。

compile_massign0関数

static int
compile_massign0(rb_iseq_t *iseq, LINK_ANCHOR *const pre, LINK_ANCHOR *const rhs, LINK_ANCHOR *const lhs, LINK_ANCHOR *const post, const NODE *const node, struct masgn_state *state, int popped)
{
    const NODE *rhsn = RNODE_MASGN(node)->nd_value;
    const NODE *splatn = RNODE_MASGN(node)->nd_args;
    const NODE *lhsn = RNODE_MASGN(node)->nd_head;
    const NODE *lhsn_count = lhsn;
    int lhs_splat = (splatn && NODE_NAMED_REST_P(splatn)) ? 1 : 0;

    int llen = 0;
    int lpos = 0;

    // preの処理
    while (lhsn_count) {
        llen++;
        lhsn_count = RNODE_LIST(lhsn_count)->nd_next;
    }
    while (lhsn) {
        CHECK(compile_massign_lhs(iseq, pre, rhs, lhs, post, RNODE_LIST(lhsn)->nd_head, state, (llen - lpos) + lhs_splat + state->lhs_level));
        lpos++;
        lhsn = RNODE_LIST(lhsn)->nd_next;
    }

    // restとpostの処理
    if (lhs_splat) {
        if (nd_type_p(splatn, NODE_POSTARG)) {
            /*a, b, *r, p1, p2 */
            const NODE *postn = RNODE_POSTARG(splatn)->nd_2nd;
            const NODE *restn = RNODE_POSTARG(splatn)->nd_1st;
            int plen = (int)RNODE_LIST(postn)->as.nd_alen;
            int ppos = 0;
            int flag = 0x02 | (NODE_NAMED_REST_P(restn) ? 0x01 : 0x00);

            ADD_INSN2(lhs, splatn, expandarray, INT2FIX(plen), INT2FIX(flag));

            if (NODE_NAMED_REST_P(restn)) {
                CHECK(compile_massign_lhs(iseq, pre, rhs, lhs, post, restn, state, 1 + plen + state->lhs_level));
            }
            while (postn) {
                CHECK(compile_massign_lhs(iseq, pre, rhs, lhs, post, RNODE_LIST(postn)->nd_head, state, (plen - ppos) + state->lhs_level));
                ppos++;
                postn = RNODE_LIST(postn)->nd_next;
            }
        }
        else {
            /* a, b, *r */
            CHECK(compile_massign_lhs(iseq, pre, rhs, lhs, post, splatn, state, 1 + state->lhs_level));
        }
    }

    // rhsの処理
    if (!state->nested) {
        NO_CHECK(COMPILE(rhs, "normal masgn rhs", rhsn));
    }

    if (!popped) {
        ADD_INSN(rhs, node, dup);
    }
    ADD_INSN2(rhs, node, expandarray, INT2FIX(llen), INT2FIX(lhs_splat));
    return COMPILE_OK;
}

compile_massign0関数の内容は大きく4つからなります。 代入をpre, *rest, post = fooとしたときに、それぞれpreの処理をする部分(while (lhsn) {})、restの処理をする部分(if (lhs_splat) {})、postの処理をする部分(while (postn) {})、そして右辺を処理する部分(NO_CHECK(COMPILE(rhs, "normal masgn rhs", rhsn));)です。

restの有無とrestが匿名かどうかに応じて後半の分岐が変化します。

LHS nd_args restのコンパイル postのコンパイル
a, b NULL 不要 不要
a, *r NODE_LASGN 必要 不要
a, * NODE_SPECIAL_NO_NAME_REST 不要 不要
a, *r, b NODE_POSTARG 必要 必要
a, *, b NODE_POSTARG 不要 必要
a, NULL 不要 不要

ここでふと思ったのですがa,のケースでImplicitRestNodeを作っていなかった気がします。 parse.yを修正しておきましょう。

 mlhs_basic     : mlhs_head
                     {
-                        $$ = NEW_RB_MULTI_WRITE($1, 0, 0, &@$);
+                        $$ = NEW_RB_MULTI_WRITE($1, NEW_RB_IMPLICIT_REST(&NULL_LOC), 0, &@$);
                     /*% ripper: $:1 %*/
                     }

やはりちゃんと書き換え前後のノードのまとめを書いておかないとダメですね。

LHS rest
a, b NULL
a, *r SplatNode
a, * SplatNode (expression: nil)
a, *r, b SplatNode
a, *, b SplatNode (expression: nil)
a, ImplicitRestNode

compile_massign0関数については構造体を変えたことによるマクロやフィールド名の変更が主なので変更内容は割愛します。

preにしろ、restにしろ、postにしろ、それらの要素はcompile_massign_lhsという関数がコンパイルをしています。 次はcompile_massign_lhs関数をみていきましょう。

compile_massign_lhs関数

multiple assignmentの左辺の要素のコンパイルを行うのがcompile_massign_lhs関数です。 この関数は引数nodeの種類によって分岐する、いつものパターンをしています。

static int
compile_massign_lhs(rb_iseq_t *iseq, LINK_ANCHOR *const pre, LINK_ANCHOR *const rhs, LINK_ANCHOR *const lhs, LINK_ANCHOR *const post, const NODE *const node, struct masgn_state *state, int lhs_pos)
{
    switch (nd_type(node)) {
      case NODE_ATTRASGN: {
        ...
        break;
      }
      case NODE_MASGN: {
        ...
        break;
      }
      case NODE_CDECL:
        if (!RNODE_CDECL(node)->nd_vid) {
            ...
            break;
        }
      default: {
        DECL_ANCHOR(anchor);
        INIT_ANCHOR(anchor);
        CHECK(COMPILE_POPPED(anchor, "masgn lhs", node));
        ELEM_REMOVE(FIRST_ELEMENT(anchor));
        ADD_SEQ(lhs, anchor);
      }
    }

    return COMPILE_OK;
}

一部のノードのコンパイル処理は長いので省略して掲載しています。 さてNODE_LASGNなどに関する分岐がありません。 ということはそれらのノードはdefault:で処理されているということになります。

実際この時点でminirubyをコンパイルしてa, b, *rest = fooバイトコードを生成するとエラーが発生します。

$ ./miniruby --parser=parse.y --dump=i -e  "a, b, *rest = foo"
-e:1: iseq_compile_each: unknown node (RB_LOCAL_VARIABLE_TARGET_NODE)
-e: compile error (SyntaxError)

どういう仕組みになっているかというと、CHECK(COMPILE_POPPED(anchor, "masgn lhs", node));で通常通りコンパイルしたあとにELEM_REMOVE(FIRST_ELEMENT(anchor));で一番最初の命令を削除することで目的のバイトコードを取得しています。

普通にローカル変数に代入するコードをコンパイルすると以下のようなバイトコードが生成されます1

# 0000 putobject_INT2FIX_1_                                             (   1)[Li]
# 0001 setlocal_WC_0                          a@0
# 0003 putnil
# 0004 leave
a = 1; nil

multiple assignmentでローカル変数に代入するコードのバイトコードは以下のとおりです。

# 0000 putnil                                                           (   1)[Li]
# 0001 dup
# 0002 expandarray                            2, 0
# 0005 setlocal_WC_0                          a@0
# 0007 setlocal_WC_0                          b@1
# 0009 leave
a, b = nil

今回欲しいのはsetlocal_WC_0の1命令です。 これはちょうどa = 1コンパイルして1つ目の命令を消したものと一致します。 またiseq_compile_each関数はnode引数がNULLのときはputnilを生成します。

static int
iseq_compile_each(rb_iseq_t *iseq, LINK_ANCHOR *ret, const NODE *node, int popped)
{
    if (node == 0) {
        if (!popped) {
            int lineno = ISEQ_COMPILE_DATA(iseq)->last_line;
            if (lineno == 0) lineno = FIX2INT(rb_iseq_first_lineno(iseq));
            debugs("node: NODE_NIL(implicit)\n");
            ADD_SYNTHETIC_INSN(ret, lineno, -1, putnil);
        }
        return COMPILE_OK;
    }
    return iseq_compile_each0(iseq, ret, node, popped);
}

書き換え前の世界ではlhsのNODE_LASGNノードはnd_valueがNULLになっているのでした。 そのため通常のローカル変数代入と同様にコンパイルをして、1つ目の命令であるputnilを消すことで目的のバイトコードを入手しています。

さて今回通常の代入で用いるLOCAL_VARIABLE_WRITE_NODEとは別にLOCAL_VARIABLE_TARGET_NODEというノードを用意したので、この新しいノードをどこかでどうにかコンパイルしないといけません。 実装の選択肢は大きく2つ、細かく考えれば3つ思いつきます。

  1. compile_massign_lhsのなかでLOCAL_VARIABLE_TARGET_NODEコンパイルする
  2. 1と同様だがLOCAL_VARIABLE_WRITE_NODEと共通する処理を別の関数に切り出す
  3. LOCAL_VARIABLE_WRITE_NODEと同様にiseq_compile_each0コンパイルの処理を書く

既存の処理を大きく変えたくないのでとりあえず3で実装を進めます。

      case RB_LOCAL_VARIABLE_WRITE_NODE: // LASGN and DASGN
      case RB_LOCAL_VARIABLE_TARGET_NODE: {
        int idx, lv, ls;
        ID id;
        const NODE *valn = NULL;
        if (nd_type_p(node, RB_LOCAL_VARIABLE_WRITE_NODE)) {
            id = RB_NODE_LOCAL_VARIABLE_WRITE(node)->name;
            valn = RB_NODE_LOCAL_VARIABLE_WRITE(node)->value;
        }
        else {
            id = RB_NODE_LOCAL_VARIABLE_TARGET(node)->name;
        }
        CHECK(COMPILE(ret, "dvalue", valn));
        debugi("dassn id", rb_id2str(id) ? id : '*');

        if (!popped) {
            ADD_INSN(ret, node, dup);
        }

        idx = get_dyna_var_idx(iseq, id, &lv, &ls);

        if (idx < 0) {
            COMPILE_ERROR(ERROR_ARGS "RB_LOCAL_VARIABLE_WRITE_NODE: unknown id (%"PRIsVALUE")",
                          rb_id2str(id));
            goto ng;
        }
        ADD_SETLOCAL(ret, node, ls - idx, lv);
        break;
      }

ローカル変数以外にも、インスタンス変数やクラス変数、グローバル変数は同じような仕組みで実装できます。

minirubyをビルドして試してみます。 よさそうです。

$ ./miniruby --parser=parse.y --dump=i -e "a, @b, @@c, $d = foo"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,18)>
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0
0000 putself                                                          (   1)[Li]
0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 dup
0004 expandarray                            3, 0
0007 setlocal_WC_0                          a@0
0009 setinstancevariable                    :@b, <is:0>
0012 setclassvariable                       :@@c, <is:1>
0015 leave

IndexTargetNodeCallTargetNodeが左辺にあるケース

a[0], s.f = fooを考えたとき、それら2つはノードの書き換え前後で異なるノードに区分されるのでした。

書き換え前 書き換え後
a[0] NODE_ATTRASGN IndexTargetNode
s.f NODE_ATTRASGN CallTargetNode

一方でcompile_massign_lhs関数では両方をcase NODE_ATTRASGN:と1つのロジックで処理しています。 compile_massign_lhs関数の対応する部分では対象のノードをコンパイルし、末尾のsend命令の削除などを行っています。 そのため実はノード固有のフィールドにアクセスしていません。

      case RB_CALL_TARGET_NODE:
      case RB_INDEX_TARGET_NODE: {
        INSN *iobj;
        const NODE *line_node = node;
        ...

そこで今回はIndexTargetNodeCallTargetNodeを既存のロジックで処理させ、iseq_compile_each0関数を拡張するようにします。

さて、iseq_compile_each0関数にはすでにCallNodeコンパイルするロジックが実装されています。 CallNodeコンパイル時にはa[0] = 1s.f = 1といった形式のメソッド呼び出しについてはcompile_attrasgnという関数で処理するようにしているのでした。 IndexTargetNodeCallTargetNodeについてもcompile_attrasgn関数に任せてしまいましょう。

      case RB_CALL_NODE: {
        if (rb_node_get_fl(node) & RB_CALL_NODE_FLAGS_ATTRIBUTE_WRITE) {
            CHECK(compile_attrasgn(iseq, ret, node, popped));
        }
        else if (compile_iter(iseq, ret, node, type, popped) == COMPILE_NG) {
            goto ng;
        }
        break;
      }
      case RB_CALL_TARGET_NODE:
      case RB_INDEX_TARGET_NODE: {
        CHECK(compile_attrasgn(iseq, ret, node, popped));
        break;
      }

minirubyをbuildして実行してみます。 よさそうです。

$ ./miniruby --parser=parse.y --dump=i -e "a[nil], s.f = foo"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,17)>
0000 putself                                                          (   1)[Li]
0001 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putnil
0004 putself
0005 opt_send_without_block                 <calldata!mid:s, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0007 putself
0008 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 dup
0011 expandarray                            2, 0
0014 topn                                   5
0016 topn                                   5
0018 topn                                   2
0020 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0022 pop
0023 pop
0024 topn                                   2
0026 swap
0027 opt_send_without_block                 <calldata!mid:f=, argc:1, ARGS_SIMPLE>
0029 pop
0030 setn                                   3
0032 pop
0033 pop
0034 pop
0035 leave

MultiTargetNodeが左辺にあるケース

compile_massign_lhs関数のNODE_MASGNの箇所をみてみると、ノードのコンパイルに関してはcompile_massign0関数に任せていることがわかります。

      case NODE_MASGN: {
        DECL_ANCHOR(nest_rhs);
        INIT_ANCHOR(nest_rhs);
        DECL_ANCHOR(nest_lhs);
        INIT_ANCHOR(nest_lhs);

        int prev_level = state->lhs_level;
        bool prev_nested = state->nested;
        state->nested = 1;
        state->lhs_level = lhs_pos - 1;
        CHECK(compile_massign0(iseq, pre, nest_rhs, nest_lhs, post, node, state, 1));
        state->lhs_level = prev_level;
        state->nested = prev_nested;

        ADD_SEQ(lhs, nest_rhs);
        ADD_SEQ(lhs, nest_lhs);
        break;
      }

ということはcase ...の部分を書き換えれば動くのではないでしょうか。

         }
         break;
       }
-      case NODE_MASGN: {
+      case RB_MULTI_TARGET_NODE: {
         DECL_ANCHOR(nest_rhs);
         INIT_ANCHOR(nest_rhs);
         DECL_ANCHOR(nest_lhs);

思った通りに動きました。

$ ./miniruby --parser=parse.y --dump=i -e "(a, b), c = foo"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,15)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a@0        [ 2] b@1        [ 1] c@2
0000 putself                                                          (   1)[Li]
0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 dup
0004 expandarray                            2, 0
0007 expandarray                            2, 0
0010 setlocal_WC_0                          a@0
0012 setlocal_WC_0                          b@1
0014 setlocal_WC_0                          c@2
0016 leave

まとめ

今日の成果です。

  • multiple assignmentに対応した(定数への代入を除く)

ここまできたら同じような構文をもつforやメソッドなどの仮引数を実装するのがよいでしょうか。 もしくは今回スキップした定数に対応するために、通常の定数代入を実装するのがよいでしょうか。 次回はそのあたりに取り組むと思います。


  1. compile_massign_lhs関数ではCOMPILE_POPPEDを使ってコンパイルしているので、ローカル変数への代入が同様にpoppedでコンパイルされるように; nilを追加しています。



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

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