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


Ruby Parser開発日誌 (24-15) - parse.yが生成するノードを変える ー 仮引数のmultiple assignment

15日目: 仮引数にもあるmultiple assignment

さて、multiple assignmentでおそらく最も複雑な部分を実装したので今回は仮引数部分のmultiple assignmentに取り組みます1。 以下のコードのように、Rubyではメソッド定義などの仮引数のところにもmultiple assignmentを書くことができます。

def m((a, b), c)
  p a
  p b
  p c
end

m([1, 2], 3)
#=> 1
#=> 2
#=> 3

そこまで詳しい部分でもないので、いつものように文法、ノード、バイトコードを眺めつつ進めていきましょう。

文法とノードを眺める

まずはざっと文法を眺めておきます。 パッとみてわかる特徴は以下の2点でしょうか。

  • def m((a, (b, c)), d); endのように()をネストすることができる
  • abのところにはローカル変数の形のものしか書けない

二つめの点は、代入としてのmultiple assignmentが左辺に様々な変数や一部の形式のメソッド呼び出しをとれたことを考えるとだいぶシンプルです2

// 仮引数の個々の部分
f_arg_item : f_arg_asgn
           | tLPAREN f_margs rparen
           ;

// "()"のなかに複数の要素を書くことができる
// *argも書くことができる
f_margs    : mlhs_items(f_marg)
           | mlhs_items(f_marg) ',' f_rest_marg
           ...
           ;

// "()"がネストできる!
f_marg     : f_norm_arg
           | tLPAREN f_margs rparen
           ;

// 要素としてはローカル変数しか書けない
f_norm_arg : f_bad_arg
           | tIDENTIFIER
           ;

細部を変えながら書き換え前のノードを眺めておきましょう。

$ ruby --parser=parse.y --dump=p -e 'def m((a,b)); end'
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,17))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 11, line: 1, location: (1,0)-(1,17))
#         +- nd_tbl: (internal variable: 0x34359214071),:a,:b
#         +- nd_args:
#         |   @ NODE_ARGS (id: 9, line: 1, location: (1,6)-(1,11))
#         |   +- nd_ainfo.forwarding: 0 (no forwarding)
#         |   +- nd_ainfo.pre_args_num: 1
#         |   +- nd_ainfo.pre_init:
#         |   |   @ NODE_MASGN (id: 6, line: 1, location: (1,7)-(1,10))
#         |   |   +- nd_value:
#         |   |   |   @ NODE_LVAR (id: 7, line: 1, location: (1,7)-(1,7))
#         |   |   |   +- nd_vid: (internal variable: 0x34359214071)
#         |   |   +- nd_head:
#         |   |   |   @ NODE_LIST (id: 3, line: 1, location: (1,7)-(1,10))
#         |   |   |   +- as.nd_alen: 2
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LASGN (id: 2, line: 1, location: (1,7)-(1,8))
#         |   |   |   |   +- nd_vid: :a
#         |   |   |   |   +- nd_value:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LASGN (id: 4, line: 1, location: (1,9)-(1,10))
#         |   |   |   |   +- nd_vid: :b
#         |   |   |   |   +- nd_value:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_next:
#         |   |   |       (null node)
#         |   |   +- nd_args:
#         |   |       (null node)
#         |   +- nd_ainfo.post_args_num: 0
#         |   +- nd_ainfo.post_init:
#         |   |   (null node)
#         |   +- nd_ainfo.first_post_arg: (null)
#         |   +- nd_ainfo.rest_arg: (null)
#         |   +- nd_ainfo.block_arg: (null)
#         |   +- nd_ainfo.opt_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_rest_arg:
#         |       (null node)
#         +- nd_body:
#             (null node)

nd_ainfo.pre_initという部分にNODE_MASGNというノードがセットされます。 NODE_MASGNというノードは代入のときと同じノードです。

NODE_MASGNnd_valueにはinternal variableを参照するノードがセットされています。 例えて言うならa, b = internal_idといったところでしょうか。 また全く同じ数値のidがnd_tblにも設定されています。 これは以下のような書き換えを考えるとよいと思います。

(a,b)という仮引数は、multiple assignmentを表す適当な仮引数iを用意して、iabに分解することと同じようです。

def m((a, b))
end

def m(i)
  a, b = i
end

nd_ainfo.pre_initのほかにnd_ainfo.post_initというフィールドもあります。 仮引数においてpostというのは*r(rest)のあとのことなので、以下のようなスクリプトを考えます。

$ ruby --parser=parse.y --dump=p -e 'def m(*r, (a,b)); end'
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,21))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 11, line: 1, location: (1,0)-(1,21))
#         +- nd_tbl: :r,(internal variable: 0x34359214063),:a,:b
#         +- nd_args:
#         |   @ NODE_ARGS (id: 9, line: 1, location: (1,6)-(1,15))
#         |   +- nd_ainfo.forwarding: 0 (no forwarding)
#         |   +- nd_ainfo.pre_args_num: 0
#         |   +- nd_ainfo.pre_init:
#         |   |   (null node)
#         |   +- nd_ainfo.post_args_num: 1
#         |   +- nd_ainfo.post_init:
#         |   |   @ NODE_MASGN (id: 6, line: 1, location: (1,11)-(1,14))
#         |   |   +- nd_value:
#         |   |   |   @ NODE_LVAR (id: 7, line: 1, location: (1,11)-(1,11))
#         |   |   |   +- nd_vid: (internal variable: 0x34359214063)
#         |   |   +- nd_head:
#         |   |   |   @ NODE_LIST (id: 3, line: 1, location: (1,11)-(1,14))
#         |   |   |   +- as.nd_alen: 2
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LASGN (id: 2, line: 1, location: (1,11)-(1,12))
#         |   |   |   |   +- nd_vid: :a
#         |   |   |   |   +- nd_value:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LASGN (id: 4, line: 1, location: (1,13)-(1,14))
#         |   |   |   |   +- nd_vid: :b
#         |   |   |   |   +- nd_value:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_next:
#         |   |   |       (null node)
#         |   |   +- nd_args:
#         |   |       (null node)
#         |   +- nd_ainfo.first_post_arg: (internal variable: 0x34359214063)
#         |   +- nd_ainfo.rest_arg: :r
#         |   +- nd_ainfo.block_arg: (null)
#         |   +- nd_ainfo.opt_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_rest_arg:
#         |       (null node)
#         +- nd_body:
#             (null node)

nd_ainfo.pre_initではなくnd_ainfo.post_initNODE_MASGNがセットされました。

multiple assignmentがネストするケースについてもみておきましょう。 特徴は以下の2点でしょう。

  • NODE_MASGNの下にNODE_MASGNがネストする構造になっている
  • nd_tblのinternal variableは1つのまま

このネスト構造はa, (b, c) = iと同じ構造になっています。

$ ruby --parser=parse.y --dump=p -e 'def m((a,(b, c))); end'
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,22))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 15, line: 1, location: (1,0)-(1,22))
#         +- nd_tbl: (internal variable: 0x34359214063),:a,:b,:c
#         +- nd_args:
#         |   @ NODE_ARGS (id: 13, line: 1, location: (1,6)-(1,16))
#         |   +- nd_ainfo.forwarding: 0 (no forwarding)
#         |   +- nd_ainfo.pre_args_num: 1
#         |   +- nd_ainfo.pre_init:
#         |   |   @ NODE_MASGN (id: 10, line: 1, location: (1,7)-(1,15))
#         |   |   +- nd_value:
#         |   |   |   @ NODE_LVAR (id: 11, line: 1, location: (1,7)-(1,7))
#         |   |   |   +- nd_vid: (internal variable: 0x34359214063)
#         |   |   +- nd_head:
#         |   |   |   @ NODE_LIST (id: 3, line: 1, location: (1,7)-(1,14))
#         |   |   |   +- as.nd_alen: 2
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LASGN (id: 2, line: 1, location: (1,7)-(1,8))
#         |   |   |   |   +- nd_vid: :a
#         |   |   |   |   +- nd_value:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_MASGN (id: 8, line: 1, location: (1,10)-(1,14))
#         |   |   |   |   +- nd_value:
#         |   |   |   |   |   (null node)
#         |   |   |   |   +- nd_head:
#         |   |   |   |   |   @ NODE_LIST (id: 5, line: 1, location: (1,10)-(1,14))
#         |   |   |   |   |   +- as.nd_alen: 2
#         |   |   |   |   |   +- nd_head:
#         |   |   |   |   |   |   @ NODE_LASGN (id: 4, line: 1, location: (1,10)-(1,11))
#         |   |   |   |   |   |   +- nd_vid: :b
#         |   |   |   |   |   |   +- nd_value:
#         |   |   |   |   |   |       (null node)
#         |   |   |   |   |   +- nd_head:
#         |   |   |   |   |   |   @ NODE_LASGN (id: 6, line: 1, location: (1,13)-(1,14))
#         |   |   |   |   |   |   +- nd_vid: :c
#         |   |   |   |   |   |   +- nd_value:
#         |   |   |   |   |   |       (null node)
#         |   |   |   |   |   +- nd_next:
#         |   |   |   |   |       (null node)
#         |   |   |   |   +- nd_args:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_next:
#         |   |   |       (null node)
#         |   |   +- nd_args:
#         |   |       (null node)
#         |   +- nd_ainfo.post_args_num: 0
#         |   +- nd_ainfo.post_init:
#         |   |   (null node)
#         |   +- nd_ainfo.first_post_arg: (null)
#         |   +- nd_ainfo.rest_arg: (null)
#         |   +- nd_ainfo.block_arg: (null)
#         |   +- nd_ainfo.opt_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_rest_arg:
#         |       (null node)
#         +- nd_body:
#             (null node)

最後に複数のmultiple assignmentが並んでいるケースをみておきます。 特徴は以下の2点です。

  • nd_tblには2つのinternal variableがセットされている
  • nd_ainfo.pre_initにはNODE_MASGNが2つ同じ階層に並んでいる
$ ruby --parser=parse.y --dump=p -e 'def m((a, b), (c, d)); end'
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,26))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 20, line: 1, location: (1,0)-(1,26))
#         +- nd_tbl: (internal variable: 0x34359214071),(internal variable: 0x34359214047),:a,:b,:c,:d
#         +- nd_args:
#         |   @ NODE_ARGS (id: 18, line: 1, location: (1,6)-(1,20))
#         |   +- nd_ainfo.forwarding: 0 (no forwarding)
#         |   +- nd_ainfo.pre_args_num: 2
#         |   +- nd_ainfo.pre_init:
#         |   |   @ NODE_BLOCK (id: 16, line: 1, location: (1,7)-(1,19))
#         |   |   +- nd_head (1):
#         |   |   |   @ NODE_MASGN (id: 6, line: 1, location: (1,7)-(1,11))
#         |   |   |   +- nd_value:
#         |   |   |   |   @ NODE_LVAR (id: 7, line: 1, location: (1,7)-(1,7))
#         |   |   |   |   +- nd_vid: (internal variable: 0x34359214071)
#         |   |   |   +- nd_head:
#         |   |   |   |   @ NODE_LIST (id: 3, line: 1, location: (1,7)-(1,11))
#         |   |   |   |   +- as.nd_alen: 2
#         |   |   |   |   +- nd_head:
#         |   |   |   |   |   @ NODE_LASGN (id: 2, line: 1, location: (1,7)-(1,8))
#         |   |   |   |   |   +- nd_vid: :a
#         |   |   |   |   |   +- nd_value:
#         |   |   |   |   |       (null node)
#         |   |   |   |   +- nd_head:
#         |   |   |   |   |   @ NODE_LASGN (id: 4, line: 1, location: (1,10)-(1,11))
#         |   |   |   |   |   +- nd_vid: :b
#         |   |   |   |   |   +- nd_value:
#         |   |   |   |   |       (null node)
#         |   |   |   |   +- nd_next:
#         |   |   |   |       (null node)
#         |   |   |   +- nd_args:
#         |   |   |       (null node)
#         |   |   +- nd_head (2):
#         |   |       @ NODE_MASGN (id: 13, line: 1, location: (1,15)-(1,19))
#         |   |       +- nd_value:
#         |   |       |   @ NODE_LVAR (id: 14, line: 1, location: (1,15)-(1,15))
#         |   |       |   +- nd_vid: (internal variable: 0x34359214047)
#         |   |       +- nd_head:
#         |   |       |   @ NODE_LIST (id: 10, line: 1, location: (1,15)-(1,19))
#         |   |       |   +- as.nd_alen: 2
#         |   |       |   +- nd_head:
#         |   |       |   |   @ NODE_LASGN (id: 9, line: 1, location: (1,15)-(1,16))
#         |   |       |   |   +- nd_vid: :c
#         |   |       |   |   +- nd_value:
#         |   |       |   |       (null node)
#         |   |       |   +- nd_head:
#         |   |       |   |   @ NODE_LASGN (id: 11, line: 1, location: (1,18)-(1,19))
#         |   |       |   |   +- nd_vid: :d
#         |   |       |   |   +- nd_value:
#         |   |       |   |       (null node)
#         |   |       |   +- nd_next:
#         |   |       |       (null node)
#         |   |       +- nd_args:
#         |   |           (null node)
#         |   +- nd_ainfo.post_args_num: 0
#         |   +- nd_ainfo.post_init:
#         |   |   (null node)
#         |   +- nd_ainfo.first_post_arg: (null)
#         |   +- nd_ainfo.rest_arg: (null)
#         |   +- nd_ainfo.block_arg: (null)
#         |   +- nd_ainfo.opt_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_args:
#         |   |   (null node)
#         |   +- nd_ainfo.kw_rest_arg:
#         |       (null node)
#         +- nd_body:
#             (null node)

parse.yを書き換える

ノードの書き換え後は他の必須引数と同様にParametersNoderequiredsもしくはpostsMultiTargetNodeが並ぶ構造をとります。

$ ruby --parser=prism --dump=p -e 'def m((a, *b, c), d, *r, e, (f, *g, h)); end'
+-- @ DefNode (location: (1,0)-(1,44))
    +-- name: :m
    +-- receiver: nil
    +-- parameters:
    |   @ ParametersNode (location: (1,6)-(1,38))
    |   +-- requireds: (length: 2)
    |   |   +-- @ MultiTargetNode (location: (1,6)-(1,16))
    |   |   |   +-- lefts: (length: 1)
    |   |   |   |   +-- @ RequiredParameterNode (location: (1,7)-(1,8))
    |   |   |   |       +-- name: :a
    |   |   |   +-- rest:
    |   |   |   |   @ SplatNode (location: (1,10)-(1,12))
    |   |   |   |   +-- expression:
    |   |   |   |       @ RequiredParameterNode (location: (1,11)-(1,12))
    |   |   |   |       +-- name: :b
    |   |   |   +-- rights: (length: 1)
    |   |   |   |   +-- @ RequiredParameterNode (location: (1,14)-(1,15))
    |   |   |   |       +-- name: :c
    |   |   +-- @ RequiredParameterNode (location: (1,18)-(1,19))
    |   |       +-- name: :d
    |   +-- optionals: (length: 0)
    |   +-- rest:
    |   |   @ RestParameterNode (location: (1,21)-(1,23))
    |   |   +-- name: :r
    |   +-- posts: (length: 2)
    |   |   +-- @ RequiredParameterNode (location: (1,25)-(1,26))
    |   |   |   +-- name: :e
    |   |   +-- @ MultiTargetNode (location: (1,28)-(1,38))
    |   |       +-- lefts: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (1,29)-(1,30))
    |   |       |       +-- name: :f
    |   |       +-- rest:
    |   |       |   @ SplatNode (location: (1,32)-(1,34))
    |   |       |   +-- expression:
    |   |       |       @ RequiredParameterNode (location: (1,33)-(1,34))
    |   |       |       +-- name: :g
    |   |       +-- rights: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (1,36)-(1,37))
    |   |       |       +-- name: :h
    |   +-- keywords: (length: 0)
    |   +-- keyword_rest: nil
    |   +-- block: nil
    +-- body: nil
    +-- locals: [:a, :b, :c, :d, :r, :e, :f, :g, :h]

基本的には代入のときと同じ構造をしているのでmlhs_basicmlhsといった生成規則を参考にアクションを書き換えていきます。 例えば()の中の要素を表すf_margsでは生成するノードを変更します。

 f_margs                : mlhs_items(f_marg)
                     {
-                        $$ = NEW_MASGN($1, 0, &@$);
+                        $$ = NEW_RB_MULTI_TARGET($1, 0, 0, &@$);
                     /*% ripper: $:1 %*/
                     }
                 | mlhs_items(f_marg) ',' f_rest_marg
                     {
-                        $$ = NEW_MASGN($1, $3, &@$);
+                        $$ = NEW_RB_MULTI_TARGET($1, $3, 0, &@$);
                     /*% ripper: mlhs_add_star!($:1, $:3) %*/
                     }

ひとつ困った箇所があります。 それは個々の要素を表すf_margのアクションです。

 f_marg         : f_norm_arg
                     {
-                        $$ = assignable(p, $1, 0, &@$);
-                        mark_lvar_used(p, $$);
+                        // $$ = assignable(p, $1, 0, &@$);
+                        // TODO: local_var ?
+                        // local_var(p, $1);
+                        // mark_lvar_used(p, $$);
+                        arg_var(p, $1);
+                        $$ = NEW_RB_REQUIRED_PARAMETER($1, &NULL_LOC);
                     }

これまではidを引数としてassignable関数を呼び出してNODE_LASGNノードを取得し、そのノードをmark_lvar_usedに渡すことで変数が使用されていないという警告を抑制していました。

assignablemark_lvar_usedRequiredParameterNodeには対応していないので、どうしましょう。 というかdef m((a, *b, c), d, *r, e, (f, *g, h)); endabなどはローカル変数ではなく仮引数です。 assignable関数ではなくarg_var関数であれば引数がIDですし、受け取ったidを仮引数として扱ってくれます。 ここでは一旦arg_varを使うようにしてみます。

生成されるノードを確認します。 よさそうですね。

$ ./miniruby --parser=parse.y --dump=p -e "def m((a, *b, c)); end"
+-- @ DefNode (location: (1,0)-(1,22))*
    +-- name: :m
    +-- receiver: nil
    +-- parameters:
    |   @ ParametersNode (location: (0,-1)-(0,-1))
    |   +-- requireds: (length: 1)
    |   |   +-- @ MultiTargetNode (location: (1,7)-(1,15))
    |   |       +-- lefts: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (0,-1)-(0,-1))
    |   |       |       +-- name: :a
    |   |       +-- rest:
    |   |       |   @ SplatNode (location: (0,-1)-(0,-1))
    |   |       |   +-- expression:
    |   |       |       @ RequiredParameterNode (location: (0,-1)-(0,-1))
    |   |       |       +-- name: :b
    |   |       +-- rights: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (0,-1)-(0,-1))
    |   |       |       +-- name: :c
    |   +-- optionals: (length: 0)
    |   +-- rest: nil
    |   +-- posts: (length: 0)
    |   +-- keywords: (length: 0)
    |   +-- keyword_rest: nil
    |   +-- block: nil
    +-- body:
    |   @ StatementsNode (location: (1,17)-(1,17))
    |   +-- body: (length: 0)
    +-- locals: [:a, :b, :c, :(null)]

compile.cを変更する

一度現時点で生成されるISeqを確認しておきましょう。

# Before
== disasm: #<ISeq:m@-e:1 (1,0)-(1,22)>
local table (size: 4, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 4] ?@0<Arg>   [ 3] a@1        [ 2] b@2        [ 1] c@3
0000 getlocal_WC_0                          ?@0                       (   1)
0002 expandarray                            1, 1
0005 setlocal_WC_0                          a@1
0007 expandarray                            1, 3
0010 setlocal_WC_0                          b@2
0012 setlocal_WC_0                          c@3
0014 putnil                                 [Ca]
0015 leave                                  [Re]

# After
== disasm: #<ISeq:m@-e:1 (1,0)-(1,22)>
local table (size: 4, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 4] a@0<Arg>   [ 3] b@1        [ 2] c@2        [ 1] ?@3
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

まだバイトコードを生成するロジックを直していないのでバイトコードに差があるのはよいとして、問題はlocal tableの変数の順番が変わっていることです。 local tableは仮引数、ローカル変数の順番に変数が並びます。 parse.yでassignablemark_lvar_usedを使っていた箇所をarg_varにしたため、a, b, cが仮引数になって前に移動したということです。

これではいけませんね。

あまり既存のロジックに手を入れないようにしたいので、assignable_target関数を呼び出したうえでノードの変換をアクションで行うようにします。

f_marg     : f_norm_arg
                    {
                        $$ = assignable_target(p, $1, &@$);
                        mark_lvar_used(p, $$);
                        if (!RB_NODE_TYPE_P($$, RB_LOCAL_VARIABLE_TARGET_NODE))
                            rb_bug("unexpected node: %s", ruby_node_name(nd_type($$)));;
                        $$ = local_variable_target2required_parameter(p, $$);
                    }

再度minirubyをbuildして確認します。 今度はよさそうですね。

$ ./miniruby --parser=parse.y --dump=p,i -e "def m((a, *b, c)); end"
+-- @ DefNode (location: (1,0)-(1,22))*
    +-- name: :m
    +-- receiver: nil
    +-- parameters:
    |   @ ParametersNode (location: (0,-1)-(0,-1))
    |   +-- requireds: (length: 1)
    |   |   +-- @ MultiTargetNode (location: (1,7)-(1,15))
    |   |       +-- lefts: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (1,7)-(1,8))
    |   |       |       +-- name: :a
    |   |       +-- rest:
    |   |       |   @ SplatNode (location: (0,-1)-(0,-1))
    |   |       |   +-- expression:
    |   |       |       @ RequiredParameterNode (location: (1,10)-(1,12))
    |   |       |       +-- name: :b
    |   |       +-- rights: (length: 1)
    |   |       |   +-- @ RequiredParameterNode (location: (1,14)-(1,15))
    |   |       |       +-- name: :c
    |   +-- optionals: (length: 0)
    |   +-- rest: nil
    |   +-- posts: (length: 0)
    |   +-- keywords: (length: 0)
    |   +-- keyword_rest: nil
    |   +-- block: nil
    +-- body:
    |   @ StatementsNode (location: (1,17)-(1,17))
    |   +-- body: (length: 0)
    +-- locals: [:(null), :a, :b, :c]

== disasm: #<ISeq:m@-e:1 (1,0)-(1,22)>
local table (size: 4, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 4] ?@0<Arg>   [ 3] a@1        [ 2] b@2        [ 1] c@3
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

これはRequiredなのだろうか?

ここまでやって1つ気がついたのですが、MultiTargetNodeの下にいるRequiredParameterNodeは別に必須引数ではないんですよね。 必須でもないし、内部的には引数とも言い難い。

例えば以下のメソッドは1つの必須引数を持ちます。 ですがその引数を展開したときに要素が足らなくてもエラーにはなりません。

def m((a, *b, c))
  p a
  p b
  p c
end

m()
#=> wrong number of arguments (given 0, expected 1) (ArgumentError)
m(1)
#=> 1
#=> []
#=> nil
m([])
#=> nil
#=> []
#=> nil

ノードでいえばMultiTargetNodeは確かに必須ですが、その下にあるノードは特段必須というわけではありません。

またここまでみてきたようにa, b, cはISeqのレベルでみれば仮引数ではなくローカル変数として扱われます。

RequiredParameterNodeになっているところはLocalVariableTargetNodeのほうが適切な気がします。

compile.cを変更する (2回目)

といっても仕方ないので、compile.cに手を加えていきましょう。

どこに手を入れるかというと3日目にコメントアウトした部分です。

yui-knk.hatenablog.com

このパターンはさきほどのオプショナル引数にNODE_LASGNがセットされているパターンによく似ています。 ということでmultiple assignmentのノードを対応するまではコメントアウトしておきましょう。

ノードの書き換え前はpre_initpost_initにノードが入っていましたが、書き換えによりrequiredspostsに他の仮引数のノードと一緒に保存されるようになりました。 そのためrequiredspostsに入っているノードを辿りながら、MultiTargetNodeのときはコンパイルをするというロジックに書き換えます。

diff --git a/compile.c b/compile.c
index 72e860600d..a0e8a8e684 100644
--- a/compile.c
+++ b/compile.c
@@ -2342,7 +2342,19 @@ iseq_set_arguments(rb_iseq_t *iseq, LINK_ANCHOR *const optargs, const NODE *cons
             iseq_calc_param_size(iseq);
             body->param.size = arg_size;

-            // TODO: def m4((a, b), c, (d, e), f = false, (g, h)); end
+            // Generate byte codes for multiple assignment like below.
+            //
+            // `def m4((a, b), c, (d, e), f = false, (g, h)); end`
+            for (size_t i = 0; i < RB_NODE_LIST_LEN(&args->requireds); i++) {
+                const NODE *n = args->requireds.nodes[i];
+                if (!nd_type_p(n, RB_MULTI_TARGET_NODE)) continue;
+                NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", n));
+            }
+            for (size_t i = 0; i < RB_NODE_LIST_LEN(&args->posts); i++) {
+                const NODE *n = args->posts.nodes[i];
+                if (!nd_type_p(n, RB_MULTI_TARGET_NODE)) continue;
+                NO_CHECK(COMPILE_POPPED(optargs, "init arguments (p)", n));
+            }
             // if (args->pre_init) { /* m_init */
             //     NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", args->pre_init));
             // }

バイトコードを生成してみましょう。

$ ./miniruby --parser=parse.y --dump=i -e "def m((a, *b, c)); end"
-e: -e:1: iseq_compile_each: unknown node (RB_MULTI_TARGET_NODE) (SyntaxError)

だめですね...

複数代入のときはiseq_compile_each0関数でMultiWriteNodeを扱うものの、その中身(とくにMultiTargetNode)はcompile_massign_lhs関数とcompile_massign0関数で処理するようになっているのでした。 そのためiseq_compile_each0関数はMultiTargetNodeの扱い方を知らないのです。

面倒だな...

MultiTargetNodeコンパイルする

ノードを書き換える前の世界では複数代入のNODE_MASGNにしろ、仮引数のNODE_MASGNにしろcompile_massign関数で扱っていました。 ノードの書き換え後もこれを踏襲して一番外側のMultiTargetNodecompile_massign関数で扱うようにしてみましょう。 MultiWriteNodeもしくはMultiTargetNodeを受け取ってleftsrestを返す補助的な関数をいくつか用意しましたが、基本的な変更は以下の1点だけです。

-      case RB_MULTI_WRITE_NODE: {
+      case RB_MULTI_WRITE_NODE:
+      case RB_MULTI_TARGET_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);
+        compile_massign(iseq, ret, node, popped);
         ISEQ_COMPILE_DATA(iseq)->in_masgn = prev_in_masgn;
         break;
       }

今度こそどうでしょうか。

$ ./miniruby --parser=parse.y --dump=i -e "def m((a, *b, c)); end"
-e: -e:1: iseq_compile_each: unknown node (RB_REQUIRED_PARAMETER_NODE) (SyntaxError)

ああーーー。 LocalVariableTargetNodeにしていればこんなことには...

RequiredParameterNodeコンパイルする

RequiredParameterNodeコンパイルをどこで行うか、二つの選択肢があります。

  1. 複数代入の左辺のノードをコンパイルするcompile_massign_lhs関数
  2. 一般にノードをコンパイルするiseq_compile_each0関数

現時点でRequiredParameterNodeが出てくるのはMultiTargetNodeの以下だけです。 なのでcompile_massign_lhs関数で処理するのはあり得る選択肢でしょう。

一方で、いままでそれはNODE_LASGNとして表現されていて、iseq_compile_each0関数で処理されていたことを考えるとiseq_compile_each0関数で扱うというのもあり得る選択肢でしょう。 コンパイル処理のほとんどがLocalVariableTargetNodeなどと同じになりそうなので、iseq_compile_each0関数で扱うようにしてみます。

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

今度こそどうだ。

# After
$ ./miniruby --parser=parse.y --dump=i -e "def m((a, *b, c)); end"
== disasm: #<ISeq:m@-e:1 (1,0)-(1,22)>
local table (size: 4, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 4] ?@0<Arg>   [ 3] a@1        [ 2] b@2        [ 1] c@3
0000 putnil                                                           (   1)
0001 expandarray                            1, 1
0004 setlocal_WC_0                          a@1
0006 expandarray                            1, 3
0009 setlocal_WC_0                          b@2                       (   1)
0011 setlocal_WC_0                          c@3
0013 putnil                                 [Ca]
0014 leave                                  [Re]

# Before
$ ruby --parser=parse.y --dump=i -e "def m((a, *b, c)); end"
== disasm: #<ISeq:m@-e:1 (1,0)-(1,22)>
local table (size: 4, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 4] ?@0<Arg>   [ 3] a@1        [ 2] b@2        [ 1] c@3
0000 getlocal_WC_0                          ?@0                       (   1)
0002 expandarray                            1, 1
0005 setlocal_WC_0                          a@1
0007 expandarray                            1, 3
0010 setlocal_WC_0                          b@2
0012 setlocal_WC_0                          c@3
0014 putnil                                 [Ca]
0015 leave                                  [Re]

バイトコードは生成されましたが、一番最初の命令が書き換え前と変わってしまいました。

書き換え前は右辺を表すNODE_MASGNnd_valueにinternal idへのローカル変数アクセスを表すノードが付与されていました。

@ NODE_MASGN (id: 8, line: 1, location: (1,7)-(1,15))
+- nd_value:
|   @ NODE_LVAR (id: 9, line: 1, location: (1,7)-(1,7))
|   +- nd_vid: (internal variable: 0x34359214063)
+- nd_head:
|   @ NODE_LIST (id: 3, line: 1, location: (1,7)-(1,8))
|   +- as.nd_alen: 1
|   +- nd_head:
|   |   @ NODE_LASGN (id: 2, line: 1, location: (1,7)-(1,8))
|   |   +- nd_vid: :a
|   |   +- nd_value:
|   |       (null node)
|   +- nd_next:
|       (null node)
+- nd_args:

しかし書き換え後のMultiTargetNodeには右辺を表すフィールドが存在しません。 そのためNULLCOMPILEに渡されて、putnil命令が生成されるようになりました。

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 = get_node_multi_value(node);
    ...
    if (!state->nested) {
        // rhsn = NULLがコンパイルされてputnil命令が作られる
        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;
}

getlocal命令を生成する

さて正しいバイトコードを生成するためには2つの点で修正が必要です。

  1. MultiTargetNodeの右辺3に対応するputnil命令の生成を止める
  2. 代わりに仮引数を対象とするgetlocal命令を生成する

1についてはcompile_massign0関数の該当箇所でノードの種別をチェックするようにすればよいでしょう。

@@ -6262,7 +6346,7 @@ compile_massign0(rb_iseq_t *iseq, LINK_ANCHOR *const pre, LINK_ANCHOR *const rhs
         }
     }

-    if (!state->nested) {
+    if (!state->nested && nd_type_p(node, RB_MULTI_WRITE_NODE)) {
         NO_CHECK(COMPILE(rhs, "normal masgn rhs", rhsn));
     }

余談ですが、おそらくこのstate->nestedによるチェックは削除することができるはずです。 このチェックはもともと複数代入の一番外側とそれ以外で共通したNODE_MASGNを使っていたときに、内側のNODE_MASGNnd_valueフィールド(値がNULLになっている)に対するコンパイルをスキップするための仕組みのはずです。 書き換え後は一番外側はMultiWriteNode、それ以外はMultiTargetNodeになっているのでノードの種別で判断できるはずです。 まあどこかのタイミングで整理すればよいでしょう。

2については一工夫必要です。

いままではNODE_MASGNnd_valueがinternal idを直接保持していました。 そのためlocal_tableからそのidを探し、local_table内での位置をgetlocalオペランドにすることができました。

ノードの書き換え後に使用しているMultiTargetNodeはidを保持していません。 ここで使うことができる情報を整理してみます。

  • idがinternalなidかどうかはis_internal_idという関数で判別が可能
  • local_tableにinternal idが入るケースは 1. multiple assignmentがあるとき。2. キーワード引数があるとき。のいずれか
  • multiple assignmentのときはmultiple assignmentとinternal idの順番は一致する
  • キーワード引数はpost引数より必ずあとに出現する

ということはlocal_tableからinternal idだけを取り出した場合、それは各multiple assignmentに対応する順番に並んでおり、たとえmultiple assignment以外のinternal idがあったとしても、それはmultiple assignmentのidよりも後ろにあることが保証されているはずです4

ということで左からn番目のinternl idの位置を返す関数を用意し、getlocal命令のオペランドの計算にはその関数の戻り値を使うようにします。

static int
local_internal_id_idx(rb_iseq_t *iseq, int n)
{
    ID *ids = ISEQ_BODY(iseq)->local_table;

    for (int i = 0; i < ISEQ_BODY(iseq)->local_table_size; i++) {
        if (is_internal_id(ids[i])) {
            if (n <= 0) {
                return ISEQ_BODY(iseq)->local_table_size - i;
            }
            else {
                n--;
            }
        }
    }
    rb_bug("unknown index: %d", n);
}

static int
iseq_set_arguments(rb_iseq_t *iseq, LINK_ANCHOR *const optargs, const NODE *const node_args)
{
            ...
            for (size_t i = 0; i < RB_NODE_LIST_LEN(&args->requireds); i++) {
                const NODE *n = args->requireds.nodes[i];
                if (!nd_type_p(n, RB_MULTI_TARGET_NODE)) continue;
                ADD_GETLOCAL(optargs, n, local_internal_id_idx(iseq, internal_id_idx), 0);
                NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", n));
                internal_id_idx++;
            }
            ...

minirubyをbuildして生成されるバイトコードを確認します。 うまく動いていそうです。

$ ./miniruby --parser=parse.y --dump=i -e "def m((a, *b, c), *r, (d, e), k1:); end"
== disasm: #<ISeq:m@-e:1 (1,0)-(1,39)>
local table (size: 10, argc: 1 [opts: 0, rest: 1, post: 1, block: -1, kw: 1@1, kwrest: -1])
[10] ?@0<Arg>   [ 9] r@1<Rest>  [ 8] ?@2<Post>  [ 7] k1@3       [ 6] ?@4        [ 5] a@5        [ 4] b@6        [ 3] c@7        [ 2] d@8        [ 1] e@9
0000 getlocal_WC_0                          ?@0                       (   1) # (a, *b, c)用の引数
0002 expandarray                            1, 1
0005 setlocal_WC_0                          a@5
0007 expandarray                            1, 3
0010 setlocal_WC_0                          b@6                       (   1)
0012 setlocal_WC_0                          c@7
0014 getlocal_WC_0                          ?@2                              # (d, e)用の引数
0016 expandarray                            2, 0
0019 setlocal_WC_0                          d@8
0021 setlocal_WC_0                          e@9
0023 putnil                                 [Ca]
0024 leave                                  [Re]

他の方法としてinternal idかどうかに関わらず直接local tableにおけるindexを計算するという方法もあります。 postのindexを計算するときにrestの有無で1ずらすかどうかが変わる以外は考慮することはないはずなので、そちらの方法のほうがわかりやすいかもしれません。

おまけ: 引数にデフォルト値があるときの実行順序

ふと思ってバイトコードを見たのですが、multiple assignmentの展開って、以下のようにオプショナル引数のデフォルト値の評価が終わったあとに行われるんですね。

  1. opt = foo
  2. k1: bar
  3. (a, *b, c)
  4. (f, g)
def m((a, *b, c), opt = foo, *r, (f, g), k1: bar)
end

# opt = foo
0000 putself                                                          (   1)
0001 opt_send_without_block                 <calldata!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 setlocal_WC_0                          opt@1

# k1: bar
0005 checkkeyword                           8, 0
0008 branchif                               15
0010 putself
0011 opt_send_without_block                 <calldata!mid:bar, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 setlocal_WC_0                          k1@4

# (a, *b, c)
0015 getlocal_WC_0                          ?@0
0017 expandarray                            1, 1
0020 setlocal_WC_0                          a@6
0022 expandarray                            1, 3
0025 setlocal_WC_0                          b@7
0027 setlocal_WC_0                          c@8

# (f, g)
0029 getlocal_WC_0                          ?@3
0031 expandarray                            2, 0
0034 setlocal_WC_0                          f@9
0036 setlocal_WC_0                          g@10
0038 putnil                                 [Ca]
0039 leave                                  [Re]

まとめ

今日の成果です。

  • 仮引数のmultiple assignmentを対応した

ようやく全てのパターンの仮引数に対応したようです。めでたい。


  1. 正式名称がわからないので、仮引数のmultiple assignmentのような言い回しを使っています。
  2. まあdef m(@a); endとかdef m(a[0]); endとかがそもそも書けないので、それはそうという話ではありますが、先日まで取り組んでいたmultiple assignmentの複雑さを考えるとシンプルだなと思いました。
  3. MultiTargetNodeには代入の右辺に対応するvalueフィールドはありませんが、いい表現が思いつかなかったのでここでは右辺という言い方をしています。
  4. 見落としがなければ...



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

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