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


Ruby Parser開発日誌 (24-3) - parse.yが生成するノードを変える ー 仮引数は一日にして成らず

3日目: メソッド定義と仮引数

メソッド定義の構文

2日目は(一部の)変数参照と代入を対応しました。 ノードの書き換えにも慣れてきたのでここらでメソッド定義に取り組んでいきたいと思います。

まずはメソッド定義に関連する構文を確認しましょう。 通常のメソッド定義もしくはシングルトンメソッド定義、endで終わるメソッド定義もしくはendlessメソッド定義の組み合わせで4つのパターンがあります。

# 通常のメソッド定義
def m
end

# シングルトンメソッド定義
def self.m
end

# endlessメソッド定義
def m = 1

# endlessシングルトンメソッド定義
def self.m = 1

parse.yの変更

書き換え前の状態では通常のメソッド定義はNODE_DEFN、シングルトンメソッド定義はNODE_DEFSというノードになります。 endの有無によってノードの種類は変わりません。

書き換え後はいずれのメソッド定義もDefNodeという1種類のノードで扱うようになります。 シングルトンメソッド定義かどうかの判定はreceiverフィールドをみて行います。

$ ruby --parser=prism --dump=p test.rb
...
        +-- @ DefNode (location: (4,0)-(5,3))
            +-- name: :m
            +-- name_loc: (4,9)-(4,10) = "m"
            +-- receiver:
            |   @ SelfNode (location: (4,4)-(4,8))
            +-- parameters: nil
            +-- body: nil
            +-- locals: []
            +-- def_keyword_loc: (4,0)-(4,3) = "def"
            +-- operator_loc: (4,8)-(4,9) = "."
            +-- lparen_loc: nil
            +-- rparen_loc: nil
            +-- equal_loc: nil
            +-- end_keyword_loc: (5,0)-(5,3) = "end"

DefNodeを生成するマクロ(NEW_RB_DEF)を定義して、通常のメソッド定義およびシングルトンメソッド定義の両方でそのマクロを使うようにします。 通常のメソッド定義の場合にはreceiverはないので、0(NULL)を渡しておきます。

@@ -3600,7 +3674,8 @@ defn_head : k_def def_name
                     {
                         $$ = def_head_save(p, $k_def);
                         $$->nd_mid = $def_name;
-                        $$->nd_def = NEW_DEFN($def_name, 0, &@$);
+                        // $$->nd_def = NEW_DEFN($def_name, 0, &@$);
+                        $$->nd_def = NEW_RB_DEF(0, $def_name, &@$);
                     /*% ripper: $:def_name %*/
                     }
                 ;
@@ -3614,7 +3689,8 @@ defs_head : k_def singleton dot_or_colon
                         SET_LEX_STATE(EXPR_ENDFN|EXPR_LABEL); /* force for args */
                         $$ = def_head_save(p, $k_def);
                         $$->nd_mid = $def_name;
-                        $$->nd_def = NEW_DEFS($singleton, $def_name, 0, &@$);
+                        // $$->nd_def = NEW_DEFS($singleton, $def_name, 0, &@$);
+                        $$->nd_def = NEW_RB_DEF($singleton, $def_name, &@$);
                     /*% ripper: [$:singleton, $:dot_or_colon, $:def_name] %*/
                     }
                 ;

parse.yではメソッド定義の解析を3つのステップに分けて行っています。

  1. defからメソッド名まで (defn_head)
  2. 仮引数 (f_arglist)
  3. メソッドの本体 (bodystmt)
primary | defn_head[head]
          f_arglist[args]
          bodystmt
          k_end
            {
                ($$ = $head->nd_def)->location = @$;
                $bodystmt = new_scope_body(p, $args, $bodystmt, $$, &@$);
                RNODE_DEFN($$)->nd_defn = $bodystmt;
                local_pop(p);
            }

これまではNODE_DEFNの下にNODE_SCOPEというノードをおいて、そこでスコープに関する情報を管理していました。

$ ruby --parser=parse.y --dump=p -e 'def m(a); b = 1; end'
...
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,20))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 9, line: 1, location: (1,0)-(1,20))
#         +- nd_tbl: :a,:b
#         +- nd_args:
#         |   @ NODE_ARGS (id: 3, line: 1, location: (1,6)-(1,7))
#         |   +- nd_ainfo.forwarding: 0 (no forwarding)
#         |   +- nd_ainfo.pre_args_num: 1
#         |   +- nd_ainfo.pre_init:
#         |   |   (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:
#             @ NODE_BLOCK (id: 7, line: 1, location: (1,8)-(1,15))
#             +- nd_head (1):
#             |   @ NODE_BEGIN (id: 4, line: 1, location: (1,8)-(1,8))
#             |   +- nd_body:
#             |       (null node)
#             +- nd_head (2):
#                 @ NODE_LASGN (id: 5, line: 1, location: (1,10)-(1,15))*
#                 +- nd_vid: :b
#                 +- nd_value:
#                     @ NODE_INTEGER (id: 6, line: 1, location: (1,14)-(1,15))
#                     +- val: 1

スコープというのはメソッド定義に限らずクラス定義やlambda式などにも存在するので、それらと共通した仕組みとしてNODE_SCOPEを用いていました。

$ ruby --parser=parse.y --dump=p -e 'class A; a = 1; end'
...
#     @ NODE_CLASS (id: 7, line: 1, location: (1,0)-(1,19))*
#     +- nd_cpath:
#     |   @ NODE_COLON2 (id: 0, line: 1, location: (1,6)-(1,7))
#     |   +- nd_mid: :A
#     |   +- nd_head:
#     |   |   (null node)
#     |   +- delimiter_loc: (0,-1)-(0,-1)
#     |   +- name_loc: (1,6)-(1,7)
#     +- nd_super:
#     |   (null node)
#     +- nd_body:
#     |   @ NODE_SCOPE (id: 6, line: 1, location: (1,0)-(1,19))
#     |   +- nd_tbl: :a
#     |   +- nd_args:
#     |   |   (null node)
#     |   +- nd_body:
#     |       @ NODE_BLOCK (id: 4, line: 1, location: (1,7)-(1,14))
#     |       +- nd_head (1):
#     |       |   @ NODE_BEGIN (id: 1, line: 1, location: (1,7)-(1,7))
#     |       |   +- nd_body:
#     |       |       (null node)
#     |       +- nd_head (2):
#     |           @ NODE_LASGN (id: 2, line: 1, location: (1,9)-(1,14))*
#     |           +- nd_vid: :a
#     |           +- nd_value:
#     |               @ NODE_INTEGER (id: 3, line: 1, location: (1,13)-(1,14))
#     |               +- val: 1
#     +- class_keyword_loc: (1,0)-(1,5)
#     +- inheritance_operator_loc: (0,-1)-(0,-1)
#     +- end_keyword_loc: (1,16)-(1,19)

書き換え後はDefNodeがスコープに関する情報を管理するようになります。

$ ruby --parser=prism --dump=p -e 'def m(a); b = 1; end'
...
        +-- @ DefNode (location: (1,0)-(1,20))
            +-- name: :m
            +-- name_loc: (1,4)-(1,5) = "m"
            +-- receiver: nil
            +-- parameters:
            |   @ ParametersNode (location: (1,6)-(1,7))
            |   +-- requireds: (length: 1)
            |   |   +-- @ RequiredParameterNode (location: (1,6)-(1,7))
            |   |       +-- ParameterFlags: nil
            |   |       +-- name: :a
            |   +-- optionals: (length: 0)
            |   +-- rest: nil
            |   +-- posts: (length: 0)
            |   +-- keywords: (length: 0)
            |   +-- keyword_rest: nil
            |   +-- block: nil
            +-- body:
            |   @ StatementsNode (location: (1,10)-(1,15))
            |   +-- body: (length: 1)
            |       +-- @ LocalVariableWriteNode (location: (1,10)-(1,15))
            |           +-- name: :b
            |           +-- depth: 0
            |           +-- name_loc: (1,10)-(1,11) = "b"
            |           +-- value:
            |           |   @ IntegerNode (location: (1,14)-(1,15))
            |           |   +-- IntegerBaseFlags: decimal
            |           |   +-- value: 1
            |           +-- operator_loc: (1,12)-(1,13) = "="
            +-- locals: [:a, :b]
            +-- def_keyword_loc: (1,0)-(1,3) = "def"
            +-- operator_loc: nil
            +-- lparen_loc: (1,5)-(1,6) = "("
            +-- rparen_loc: (1,7)-(1,8) = ")"
            +-- equal_loc: nil
            +-- end_keyword_loc: (1,17)-(1,20) = "end"

DefNodeに統一されたこともふまえて、DefNodeと仮引数とbodyを受け取ってDefNodeを設定する関数を用意し、アクションではその関数を使うようにします。

static void
node_def_setup(struct parser_params *p, rb_def_node_t *nd_def, rb_node_args_t *nd_parameters, rb_def_node_t *nd_body)
{
    nd_def->parameters = nd_parameters;
    nd_def->body = nd_body;
    nd_def->locals = local_tbl(p);
}

primary | defn_head[head]
          f_arglist[args]
          bodystmt
          k_end
            {
                ($$ = $head->nd_def)->location = @$;
                node_def_setup(p, RB_NODE_DEF($$), $args, $bodystmt);
                // $bodystmt = new_scope_body(p, $args, $bodystmt, $$, &@$);
                // RNODE_DEFN($$)->nd_defn = $bodystmt;
                local_pop(p);
            }

compile.cの変更

メソッド定義はiseqを新しくつくるのでrb_iseq_compile_nodeに分岐を追加します。

@@ -912,7 +913,8 @@ rb_iseq_compile_node(rb_iseq_t *iseq, const NODE *node)
         iseq_set_local_table(iseq, 0, 0);
     }
     /* assume node is T_NODE */
-    else if (nd_type_p(node, RB_PROGRAM_NODE)) {
+    else if (nd_type_p(node, RB_PROGRAM_NODE) ||
+             nd_type_p(node, RB_DEF_NODE)) {
         const rb_ast_id_table_t *locals = NULL;
         const NODE *args = NULL;
         const NODE *body = NULL;

@@ -944,6 +946,10 @@ rb_iseq_compile_node(rb_iseq_t *iseq, const NODE *node)
             }
           case RB_DEF_NODE:
             {
+                const rb_def_node_t *cast = (const rb_def_node_t *) node;
+                locals = cast->locals;
+                args = cast->parameters;
+                body = (NODE *) cast->body;
                 break;
             }

iseq_compile_each0も少し調整が必要です。 もともとはシングルトンメソッド定義か否かでノードが分かれてました。 そのためiseq_compile_each0でも処理が分かれています。 それらの処理の違いは2つあります。

  1. シングルトンメソッド定義の場合はreceiverコンパイルする必要がある
  2. シングルトンメソッド定義ではdefinesmethod命令を、通常のメソッド定義ではdefinemethod(sがつかない)命令を使う必要がある
// Before
case NODE_DEFN:{
  ID mid = RNODE_DEFN(node)->nd_mid;
  const rb_iseq_t *method_iseq = NEW_ISEQ(RNODE_DEFN(node)->nd_defn,
                                          rb_id2str(mid),
                                          ISEQ_TYPE_METHOD, line);

  debugp_param("defn/iseq", rb_iseqw_new(method_iseq));
  ADD_INSN2(ret, node, definemethod, ID2SYM(mid), method_iseq);
  RB_OBJ_WRITTEN(iseq, Qundef, (VALUE)method_iseq);

  if (!popped) {
      ADD_INSN1(ret, node, putobject, ID2SYM(mid));
  }

  break;
}
case NODE_DEFS:{
  ID mid = RNODE_DEFS(node)->nd_mid;
  const rb_iseq_t * singleton_method_iseq = NEW_ISEQ(RNODE_DEFS(node)->nd_defn,
                                                     rb_id2str(mid),
                                                     ISEQ_TYPE_METHOD, line);

  debugp_param("defs/iseq", rb_iseqw_new(singleton_method_iseq));
  CHECK(COMPILE(ret, "defs: recv", RNODE_DEFS(node)->nd_recv));
  ADD_INSN2(ret, node, definesmethod, ID2SYM(mid), singleton_method_iseq);
  RB_OBJ_WRITTEN(iseq, Qundef, (VALUE)singleton_method_iseq);

  if (!popped) {
      ADD_INSN1(ret, node, putobject, ID2SYM(mid));
  }
  break;
}

この差分に気をつけながら実装すると以下のようになります。

case RB_DEF_NODE: {
  ID mid = RB_NODE_DEF(node)->name;
  const rb_iseq_t *method_iseq = NEW_ISEQ(node,
                                          rb_id2str(mid),
                                          ISEQ_TYPE_METHOD, line);

  debugp_param("defn/iseq", rb_iseqw_new(method_iseq));
  if (RB_NODE_DEF(node)->receiver) {
      // singleton method definition
      CHECK(COMPILE(ret, "defs: recv", RB_NODE_DEF(node)->receiver));
      ADD_INSN2(ret, node, definesmethod, ID2SYM(mid), method_iseq);
  } else {
      ADD_INSN2(ret, node, definemethod, ID2SYM(mid), method_iseq);
  }
  RB_OBJ_WRITTEN(iseq, Qundef, (VALUE)method_iseq);

  if (!popped) {
      ADD_INSN1(ret, node, putobject, ID2SYM(mid));
  }

  break;
}

仮引数と向き合う

さて。ここで終わりならいいのですが、そんなことはありません。 仮引数と向き合わなくてはいけません。

はたしてRubyにはどのような仮引数があるのでしょう。

まず思いつくのは必須引数とオプショナル引数です。

def m(a, b = 1)
  p [a, b]
end

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

ほかにも可変長引数(rest引数)があります。

def m(a, *rest)
  p rest
end

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

仮引数名に&をつけるとブロックをprocとして受け取ることもできます。

def m(&b)
  p b
  b.call(1)
end

m do |i|
  p i
end
# => #<Proc:0x0000000120b2fd88 (irb):19>
# => 1

キーワード引数も書くことができます。

def m(k1:, k2: 2, **kw)
  p [k1, k2, kw]
end

m(k1: :a, k2: :b, k3: :c, k4: :d) # => [:a, :b, {k3: :c, k4: :d}]
m(k1: :a, k3: :c, k4: :d) # => [:a, 2, {k3: :c, k4: :d}]

Rubyの仮引数はいろいろかけて便利だなーという気持ちをもったところで、parse.yを眺めていきます。

parse.yを眺める

3日目にして難しい部分に着手してしまったような気がします。 こういうときはparse.yに変更を加えるまえに、少し観察をしておくとよいでしょう。

仮引数の中心はf_argsという生成規則です1new_argsへの引数が全て埋まっている2つめの生成規則およびnew_args関数の引数名や型を眺めます。

f_args          : f_arg ',' f_opt_arg(arg_value) ',' f_rest_arg opt_args_tail(args_tail)
                    {
                        $$ = new_args(p, $1, $3, $5, 0, $6, &@$);
                    /*% ripper: params!($:1, $:3, $:5, Qnil, *$:6[0..2]) %*/
                    }
                | f_arg ',' f_opt_arg(arg_value) ',' f_rest_arg ',' f_arg opt_args_tail(args_tail)
                    {
                        $$ = new_args(p, $1, $3, $5, $7, $8, &@$);
                    /*% ripper: params!($:1, $:3, $:5, $:7, *$:8[0..2]) %*/
                    }
                | f_arg ',' f_opt_arg(arg_value) opt_args_tail(args_tail)
                    {
                        $$ = new_args(p, $1, $3, 0, 0, $4, &@$);
                    /*% ripper: params!($:1, $:3, Qnil, Qnil, *$:4[0..2]) %*/
                    }
                ...
             ;

static rb_node_args_t *
new_args(struct parser_params *p, rb_node_args_aux_t *pre_args, rb_node_opt_arg_t *opt_args, ID rest_arg, rb_node_args_aux_t *post_args, rb_node_args_t *tail, const YYLTYPE *loc)
{
    struct rb_args_info *args = &tail->nd_ainfo;

    if (args->forwarding) {
        if (rest_arg) {
            yyerror1(&RNODE(tail)->nd_loc, "... after rest argument");
            return tail;
        }
        rest_arg = idFWD_REST;
    }

    args->pre_args_num   = pre_args ? pre_args->nd_plen : 0;
    args->pre_init       = pre_args ? pre_args->nd_next : 0;

    args->post_args_num  = post_args ? post_args->nd_plen : 0;
    args->post_init      = post_args ? post_args->nd_next : 0;
    args->first_post_arg = post_args ? post_args->nd_pid : 0;

    args->rest_arg       = rest_arg;
    args->opt_args       = opt_args;

    nd_set_loc(RNODE(tail), loc);

    return tail;
}

そうすると以下のような構造になっていることがわかります。

# まず必須引数がある。これは複数書くとができるし、省略もできる
def m(a, b); end

# 次にオプショナル引数がある。これも複数書くとができるし、省略もできる
def m(a, b, op1 = 1, op2 = 2); end

# rest引数がくる。これは最大1つしか書くことができない。省略は可能
def m(a, b, op1 = 1, op2 = 2, *rest); end

# もう一度必須引数を書くことができる。省略してもよい
def m(a, b, op1 = 1, op2 = 2, *rest, c, d, e); end

# tail、生成規則でいうと opt_args_tail(args_tail) は別途観察が必要
def m(a, b, op1 = 1, op2 = 2, *rest, c, d, e, ???); end

opt_args_tail(args_tail)args_tail_basicに注目すればよいでしょう。

%rule args_tail_basic(value) <node_args>
                : f_kwarg(value) ',' f_kwrest opt_f_block_arg
                    {
                        $$ = new_args_tail(p, $1, $3, $4, &@3);
                    }
                | f_kwarg(value) opt_f_block_arg
                    {
                        $$ = new_args_tail(p, $1, 0, $2, &@1);
                    }
                | f_any_kwrest opt_f_block_arg
                    {
                        $$ = new_args_tail(p, 0, $1, $2, &@1);
                    }
                | f_block_arg
                    {
                        $$ = new_args_tail(p, 0, 0, $1, &@1);
                    }
                ;

キーワード引数(f_kwarg)、**から始まるrestキーワード引数(f_kwrest)、&から始まる引数(opt_f_block_arg)が並んでいることがわかります。 おおよその構造がわかったので、一つずつ書き換えていきましょう2

最初の必須引数

生成規則でいうとf_arg、書き換え前の型でいうとrb_node_args_aux_t *です。 任意の個数の必須引数が書ける以上、リスト構造になっていることでしょう。

書き換え後はどうなるかというとParametersNodeノードのrequiredsというフィールドがあって、そこにRequiredParameterNodeが並ぶことになります。

$ ruby --parser=prism --dump=p -e 'def m(a, b); end'
...
        +-- @ DefNode (location: (1,0)-(1,16))
            +-- name: :m
            +-- name_loc: (1,4)-(1,5) = "m"
            +-- receiver: nil
            +-- parameters:
            |   @ ParametersNode (location: (1,6)-(1,10))
            |   +-- requireds: (length: 2)
            |   |   +-- @ RequiredParameterNode (location: (1,6)-(1,7))
            |   |   |   +-- ParameterFlags: nil
            |   |   |   +-- name: :a
            |   |   +-- @ RequiredParameterNode (location: (1,9)-(1,10))
            |   |       +-- ParameterFlags: nil
            |   |       +-- name: :b
...

ノードのリストをどうやって管理するか

必須引数列に関する一連の生成規則は以下のようになっています。

// 仮引数全体を表す
f_args: f_arg ',' f_opt_arg(arg_value) ',' f_rest_arg ',' f_arg opt_args_tail(args_tail)

// 最初の必須引数列を表す
f_arg: f_arg_item
     | f_arg ',' f_arg_item
         {
             $$ = $1;
             $$->nd_plen++;
             $$->nd_next = block_append(p, $$->nd_next, $3->nd_next);
             rb_discard_node(p, (NODE *)$3);
         }

// 必須引数1つ1つを表す
f_arg_item: f_arg_asgn
              {
                  $$ = NEW_ARGS_AUX($1, 1, &NULL_LOC);
              }
          | tLPAREN f_margs rparen
              {
                  ...
              }
          ;

必須引数の各要素を認識した時点(f_arg_item: f_arg_asgn)でそれぞれをリスト構造(NEW_ARGS_AUX)でラップして、必須引数列を作るとき(f_arg ',' f_arg_item)にリスト同士を繋げるという処理を行っています。

ここで一つ問題に直面します。 abといった個別の要素はf_arg_item: f_arg_asgnのときにRequiredParameterNodeで包めばいいとして、必須引数列を作るときにノードの列をどうやって管理すればいいでしょうか。 書き換え後の世界においてノードの配列を管理するデータ構造としてstruct rb_node_list2という構造体3があるのですが、この構造体を直接アクションでアロケーションするのはできれば避けたいです。 実行中のparserのスタックに乗っているけど他から参照されないmallocした領域というのは、syntax errorなどがおきたさいにリークしやすいからです4。 今回の用途に合わせてparse.yのなかだけで使うノード5を定義してもいいですが、ノードの配列を管理するというのは必須引数のときに以外にも行う気がします。 そこでArrayNodeを使って一時的なノードの配列を管理するようにしましょう6

ところでArrayNodeって実装していましたっけ?

いや、まだしてないんですが...

ということで、急遽ArrayNodeを最低限実装することになりました。 といってもArrayNodeを生成するNEW_RB_ARRAYマクロと、ノードを配列に追加するnode_array_append関数を用意すればOKです7。 用意したマクロと関数を用いてアクションを書き直せばよいでしょう。

f_arg  : f_arg_item
            {
                $$ = NEW_RB_ARRAY($1, &NULL_LOC);
            }
        | f_arg ',' f_arg_item
            {
                $$ = node_array_append(p, $1, $3, &NULL_LOC);
                // $$->nd_plen++;
                // $$->nd_next = block_append(p, $$->nd_next, $3->nd_next);
                // rb_discard_node(p, (NODE *)$3);
            /*% ripper: rb_ary_push($:1, $:3) %*/
            }
        ;

f_arg_item  : f_arg_asgn
                {
                    // $$ = NEW_ARGS_AUX($1, 1, &NULL_LOC);
                    $$ = NEW_RB_REQUIRED_PARAMETER($1, &NULL_LOC);
                /*% ripper: $:1 %*/
                }

ノードのリストを受け渡す

f_argでつくったノードのリストは最終的にf_argsParametersNodeへ渡すことになります。 そのための関数(rb_node_list_move)を用意しておきましょう。

static void
rb_node_list_move(rb_node_list2_t *dest, rb_node_list2_t *src)
{
    RUBY_ASSERT((dest->size == 0) && (dest->capacity == 0) && (dest->nodes == NULL));
    dest->size = src->size;
    dest->capacity = src->capacity;
    dest->nodes = src->nodes;
    rb_node_list_init(src);
}

アクションを書き換えれば必須引数の出来上がりです。 post_args、つまりdef m(a, *rest, b, c); endb, cf_argという同じ非終端記号を使っているので、今回の変更でArrayNodeを生成するようになっています。 まとめてParametersNodeへ設定しておきましょう8

static rb_parameters_node_t *
new_args2(struct parser_params *p, rb_array_node_t *pre_args, rb_array_node_t *opt_args, rb_node_t *rest_arg, rb_array_node_t *post_args, rb_parameters_node_t *tail, const YYLTYPE *loc)
{
    if (pre_args) rb_node_list_move(&tail->requireds, &pre_args->elements);
    if (post_args) rb_node_list_move(&tail->posts, &post_args->elements);

    return tail;
}

f_args : f_arg ',' f_opt_arg(arg_value) ',' f_rest_arg opt_args_tail(args_tail)
           {
               $$ = new_args2(p, $1, $3, $5, 0, $6, &@$);
           /*% ripper: params!($:1, $:3, $:5, Qnil, *$:6[0..2]) %*/
           }
       | f_arg ',' f_opt_arg(arg_value) ',' f_rest_arg ',' f_arg opt_args_tail(args_tail)
           {
               $$ = new_args2(p, $1, $3, $5, $7, $8, &@$);
           /*% ripper: params!($:1, $:3, $:5, $:7, *$:8[0..2]) %*/
           }
       | f_arg ',' f_opt_arg(arg_value) opt_args_tail(args_tail)
           {
               $$ = new_args2(p, $1, $3, 0, 0, $4, &@$);
           /*% ripper: params!($:1, $:3, Qnil, Qnil, *$:4[0..2]) %*/
           }

オプショナル引数

オプショナル引数も任意の個数定義できるのでした。 個別のオプショナル引数に対応する生成規則がf_opt、オプショナル引数列に対応するのがf_opt_argなので、それぞれArrayNodeOptionalParameterNodeを生成するように書き換えます。 またnew_args2opt_argsのもつノードの配列をParametersNodeoptionalsフィールドに移動する処理を追加しておきましょう。

 static rb_parameters_node_t *
 new_args2(struct parser_params *p, rb_array_node_t *pre_args, rb_array_node_t *opt_args, rb_node_t *rest_arg,  rb_array_node_t *post_args, rb_parameters_node_t *tail, const YYLTYPE *loc)
 {
     if (pre_args) rb_node_list_move(&tail->requireds, &pre_args->elements);
     if (post_args) rb_node_list_move(&tail->posts, &post_args->elements);
+    if (opt_args) rb_node_list_move(&tail->optionals, &opt_args->elements);

     return tail;
 }

rest引数

rest引数は0個もしくは1個なので、配列で管理する必要はありません。 RestParameterNodeでIDをラップしてParametersNodeに引き渡せばよいでしょう。

と思ってf_rest_argを見に行くと、なんか生成規則が2つありますね。

f_rest_arg  : restarg_mark tIDENTIFIER
                {
                    arg_var(p, shadowing_lvar(p, $2));
                    $$ = $2;
                }
            | restarg_mark
                {
                    arg_var(p, idFWD_REST);
                    $$ = idFWD_REST;
                }
            ;

あー、これは... 無名引数ってやつがいましたね。 def m(*); endこういうやつです。 それで思い出したんですが、def m(**); enddef m(&); endというのもありました。

RestParameterNodeでは*restの場合はname: :restに、*の場合はname: nilになるようです。 一方で書き換え前のf_rest_argでは*のときにidFWD_RESTというIDを返すようになっています。 こういうときはidFWD_RESTが不要だと判断できるまでは既存の実装を残しておいたほうがいいでしょう。 よくわかっていないうちに色々変更して、思いもよらないところを壊しても面倒です。 思慮深さが大事です。

f_rest_arg  : restarg_mark tIDENTIFIER
                {
                    arg_var(p, shadowing_lvar(p, $2));
                    $$ = NEW_RB_REST_PARAMETER($2, &@$, &@1, &@2);
                }
            | restarg_mark
                {
                    arg_var(p, idFWD_REST);
                    $$ = NEW_RB_REST_PARAMETER(idFWD_REST, &@$, &@1, &NULL_LOC);
                }
            ;

というのも、たしかdef m(*); n(...) endのような書き方をしたときにidFWD_RESTをチェックしていたような記憶がうっすらとあるからです。 というのを書いていて気がついたんですが、無名引数には...というのもありましたね...9

いったん必須引数、オプショナル引数、rest引数はできたということにしましょう。

$ ./miniruby --parser=parse.y --dump=p -e 'def m(a, b, *rest, c); end'
@ ProgramNode (location: (1,0)-(1,26))
+-- locals: []
+-- statements:
    @ StatementsNode (location: (1,0)-(1,26))
    +-- body: (length: 1)
        +-- @ DefNode (location: (1,0)-(1,26))*
            +-- name: :m
            +-- name_loc: (0,4294967295)-(0,4294967295) = ""
            +-- receiver: nil
            +-- parameters:
            |   @ ParametersNode (location: (0,4294967295)-(0,4294967295))
            |   +-- requireds: (length: 2)
            |   |   +-- @ RequiredParameterNode (location: (0,4294967295)-(0,4294967295))
            |   |   |   +-- ParameterFlags: nil
            |   |   |   +-- name: :a
            |   |   +-- @ RequiredParameterNode (location: (0,4294967295)-(0,4294967295))
            |   |       +-- ParameterFlags: nil
            |   |       +-- name: :b
            |   +-- optionals: (length: 0)
            |   +-- rest:
            |   |   @ RestParameterNode (location: (1,12)-(1,17))
            |   |   +-- ParameterFlags: nil
            |   |   +-- name: :rest
            |   |   +-- name_loc: (1,13)-(1,17) = ""
            |   |   +-- operator_loc: (1,12)-(1,13) = ""
            |   +-- posts: (length: 1)
            |   |   +-- @ RequiredParameterNode (location: (0,4294967295)-(0,4294967295))
            |   |       +-- ParameterFlags: nil
            |   |       +-- name: :c
            |   +-- keywords: (length: 0)
            |   +-- keyword_rest: nil
            |   +-- block: nil
            +-- body:
            |   @ StatementsNode (location: (1,21)-(1,21))
            |   +-- body: (length: 0)
            +-- locals: [:a, :b, :rest, :c]
            +-- def_keyword_loc: (0,4294967295)-(0,4294967295) = ""
            +-- operator_loc: nil
            +-- lparen_loc: nil
            +-- rparen_loc: nil
            +-- equal_loc: nil
            +-- end_keyword_loc: nil

コンパイル

ここまでの変更に対応するためにcompile.cを編集しましょう。 parametersをきちんと埋めるようになったのでiseq_set_arguments関数の中を調整していくことになります。

VALUE
rb_iseq_compile_node(rb_iseq_t *iseq, const NODE *node)
{
    DECL_ANCHOR(ret);
    INIT_ANCHOR(ret);

    if (node == 0) {
        NO_CHECK(COMPILE(ret, "nil", node));
        iseq_set_local_table(iseq, 0, 0);
    }
    /* assume node is T_NODE */
    else if (nd_type_p(node, RB_PROGRAM_NODE) ||
             nd_type_p(node, RB_DEF_NODE)) {
        const rb_ast_id_table_t *locals = NULL;
        const NODE *args = NULL;
        const NODE *body = NULL;

        /* iseq type of top, method, class, block */
        switch (nd_type(node)) {
          case RB_PROGRAM_NODE:
            {
                const rb_program_node_t *cast = (const rb_program_node_t *) node;
                locals = cast->locals;
                body = (NODE *) cast->statements;
                break;
            }
          case RB_DEF_NODE:
            {
                const rb_def_node_t *cast = (const rb_def_node_t *) node;
                locals = cast->locals;
                args = cast->parameters;
                body = (NODE *) cast->body;
                break;
            }

          default:
            rb_bug("unexpected node: %s", rb_node_type_to_str(nd_type(node)));
            break;
        }
        iseq_set_local_table(iseq, locals, args);
        iseq_set_arguments(iseq, ret, args);
        iseq_set_parameters_lvar_state(iseq);
        ...
}

iseq_set_arguments関数はメソッド定義のように引数を扱うiseqに対して、その引数に関する情報を設定する関数です。 たとえばrest引数がある場合はhas_restというフラグをtrueにしたり、そのメソッドが何個の引数を取るのかを計算してsizeというフィールドを設定したりします。

if (rest_id) {
    body->param.rest_start = arg_size++;
    body->param.flags.has_rest = TRUE;
    if (rest_id == '*') body->param.flags.anon_rest = TRUE;
    RUBY_ASSERT(body->param.rest_start != -1);
}
...
body->param.size = arg_size;

必須引数の数と渡している引数の数が合わない場合などにwrong number of arguments (given 2, expected 1) (ArgumentError)といった例外がでますが、そのためには呼び出されるメソッドの仮引数に関する情報が必要です。

今回仮引数を管理する構造体をstruct rb_args_infoからstruct rb_parameters_nodeに変えたので、それに合わせて用いるマクロやフィールド名を変更します。 たとえば必須引数の個数を取得するところではリストの長さを取得するようにします。

-        body->param.lead_num = arg_size = (int)args->pre_args_num;
+        body->param.lead_num = arg_size = (int)RB_NODE_LIST_LEN(&args->requireds);

rest引数であればIDからノードへのポインタへ変わったので変数名や型を調整します。 また無名引数(*)の表現方法も変わったので、あわせて調整します。

-        ID rest_id = 0;
+        rb_node_t *rest = 0;

-        if (rest_id) {
+        if (rest) {
+            EXPECT_NODE("iseq_set_arguments/rest", rest, RB_REST_PARAMETER_NODE, COMPILE_NG);
             body->param.rest_start = arg_size++;
             body->param.flags.has_rest = TRUE;
-            if (rest_id == '*') body->param.flags.anon_rest = TRUE;
+            if (RB_NODE_REST_PARAMETER(rest)->name == '*') body->param.flags.anon_rest = TRUE;
             RUBY_ASSERT(body->param.rest_start != -1);
         }

書き換えていて困るであろう箇所が3箇所あります。

1つはexcessed_commaです。

        if (rest_id == NODE_SPECIAL_EXCESSIVE_COMMA) {
            last_comma = 1;
            rest_id = 0;
        }

NODE_SPECIAL_EXCESSIVE_COMMAというIDが突然出てくるのですが、これはなんでしょうか。 parse.yをみるとblock_paramのときだけ使われる何かのようです。

excessed_comma  : ','
                    {
                        /* magic number for rest_id in iseq_set_arguments() */
                        $$ = NODE_SPECIAL_EXCESSIVE_COMMA;
                    }
                ;


block_param | f_arg excessed_comma
                {
                    $$ = new_args_tail(p, 0, 0, 0, &@2);
                    $$ = new_args(p, $1, 0, $2, 0, $$, &@$);
                }

丁寧にiseq_set_argumentsで使われるmagic numberだというコメントが書いてありました。 末尾のカンマの有無で、ブロックの仮引数で配列を受け取ったときの挙動が変わります。

def m
  yield [1, 2, 3]
end

m do |a|
  p a # => [1, 2, 3]
end

m do |a,|
  p a # => 1
end

ImplicitRestNodeというノードが対応するようなので、アクションを書き換えてImplicitRestNodeを生成するようにしておきます。 今後メソッド呼び出しに対応したときが楽しみです。

次に困りそうなのがfirst_post_argによる分岐です。

if (args->first_post_arg) {
    body->param.post_start = arg_size;
    body->param.post_num = args->post_args_num;
    body->param.flags.has_post = TRUE;
    arg_size += args->post_args_num;

    if (body->param.flags.has_rest) { /* TODO: why that? */
        body->param.post_start = body->param.rest_start + 1;
    }
}

ブロック内の処理を眺めた感じ、どうみても(オプショナル引数やrest引数のあとに)必須引数があるときの処理に見えます。 args->post_args_num(post argsの個数)をチェックするのではなく、args->first_post_arg(post argsの最初の仮引数の有無)をチェックしているのが気にはなりますが、必須引数の数をチェックするコードに置き換えておきましょう。

if (RB_NODE_LIST_LEN(&args->posts)) {
    ...
}

ここまでできたら仮引数のあるメソッド定義をコンパイルしてみましょう10

$ ./miniruby --parser=parse.y --dump=i -e 'def m(a); end'
...
== disasm: #<ISeq:m@-e:1 (1,0)-(1,13)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0<Arg>
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

$ ./miniruby --parser=parse.y --dump=i -e 'def m(a, *r, b); end'
...
== disasm: #<ISeq:m@-e:1 (1,0)-(1,20)>
local table (size: 3, argc: 1 [opts: 0, rest: 1, post: 1, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a@0<Arg>   [ 2] r@1<Rest>  [ 1] b@2<Post>
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

$ ./miniruby --parser=parse.y --dump=i -e 'def m(a = self); end'
-e: -e:1: iseq_compile_each: unknown node (RB_OPTIONAL_PARAMETER_NODE) (SyntaxError)

オプショナル引数を指定すると失敗します。 なぜ...

オプショナル引数をコンパイルする

RB_OPTIONAL_PARAMETER_NODEコンパイルの仕方がわからないと怒られてしまいました。 iseq_compile_each0には変更を加えてないので、コンパイルの仕方を知っていても困ります。 args->opt_argsをリストに変えたのにあわせてiseq_set_argumentsは更新をしていたのに、なぜこのようなエラーが起きてしまうのでしょうか。

書き換える前のノードをみてみます。 すると、NODE_OPT_ARGNODE_LASGN、つまりローカル変数への代入を表すノードをもっていることがわかります。

なんだこれ...?

$ ruby --parser=parse.y --dump=p -e 'def m(a = true, b = false); end'
...
#         |   +- nd_ainfo.opt_args:
#         |   |   @ NODE_OPT_ARG (id: 4, line: 1, location: (1,6)-(1,25))
#         |   |   +- nd_body:
#         |   |   |   @ NODE_LASGN (id: 3, line: 1, location: (1,6)-(1,14))
#         |   |   |   +- nd_vid: :a
#         |   |   |   +- nd_value:
#         |   |   |       @ NODE_TRUE (id: 2, line: 1, location: (1,10)-(1,14))
#         |   |   +- nd_next:
#         |   |       @ NODE_OPT_ARG (id: 7, line: 1, location: (1,16)-(1,25))
#         |   |       +- nd_body:
#         |   |       |   @ NODE_LASGN (id: 6, line: 1, location: (1,16)-(1,25))
#         |   |       |   +- nd_vid: :b
#         |   |       |   +- nd_value:
#         |   |       |       @ NODE_FALSE (id: 5, line: 1, location: (1,20)-(1,25))
#         |   |       +- nd_next:
#         |   |           (null node)
#         |   +- nd_ainfo.kw_args:
#         |   |   (null node)
...

オプショナル引数をもつメソッド定義と、ローカル変数代入のバイトコードをそれぞれ見てみましょう。

$ ruby --parser=parse.y --dump=i -e 'def m(a = true, b = false); end'
...
== disasm: #<ISeq:m@-e:1 (1,0)-(1,31)>
local table (size: 2, argc: 0 [opts: 2, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] a@0<Opt=0> [ 1] b@1<Opt=4>
0000 putobject                              true                      (   1)
0002 setlocal_WC_0                          a@0
0004 putobject                              false
0006 setlocal_WC_0                          b@1
0008 putnil                                 [Ca]
0009 leave                                  [Re]

$ ruby --parser=parse.y --dump=i -e 'a = true; b = false'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,19)>
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 putobject                              true                      (   1)[Li]
0002 setlocal_WC_0                          a@0
0004 putobject                              false
0006 dup
0007 setlocal_WC_0                          b@1
0009 leave

0000から0004では同じコードが生成されています11。 オプショナル引数というものを考えてみると、引数が与えられなかったときは右辺(truefalse)で初期化するという引数であり、引数もローカル変数も1つの空間で管理されています。 ということはオプショナル引数の中身をローカル変数代入のノードにしておくことでコンパイラのロジックを共有できるということになります。

わー、便利!

というわけで急遽OptionalParameterNode向けにローカル変数代入相当のコンパイルロジックを実装することになります。 書き換え後のローカル変数代入のロジックと重複することになるはずなので、その旨コメントだけ残しておきましょう。

// ローカル変数の代入(書き換え前のノード)
case NODE_LASGN:{
  ID id = RNODE_LASGN(node)->nd_vid;
  int idx = ISEQ_BODY(body->local_iseq)->local_table_size - get_local_var_idx(iseq, id);

  debugs("lvar: %s idx: %d\n", rb_id2name(id), idx);
  CHECK(COMPILE(ret, "rvalue", RNODE_LASGN(node)->nd_value));

  if (!popped) {
      ADD_INSN(ret, node, dup);
  }
  ADD_SETLOCAL(ret, node, idx, get_lvar_level(iseq));
  break;
}

// オプショナル引数(書き換え後のノード)
case RB_OPTIONAL_PARAMETER_NODE: {
  // TODO: These codes are same with `RB_LOCAL_VARIABLE_WRITE_NODE`
  ID id = RB_NODE_OPTIONAL_PARAMETER(node)->name;
  int idx = ISEQ_BODY(body->local_iseq)->local_table_size - get_local_var_idx(iseq, id);

  debugs("lvar: %s idx: %d\n", rb_id2name(id), idx);
  CHECK(COMPILE(ret, "rvalue", RB_NODE_OPTIONAL_PARAMETER(node)->value));

  if (!popped) {
      ADD_INSN(ret, node, dup);
  }
  ADD_SETLOCAL(ret, node, idx, get_lvar_level(iseq));
  break;
}

ハプニングもありましたが、無事コンパイルできるようになりました。

$ ./miniruby --parser=parse.y --dump=i -e 'def m(a = true, b = false); end'
...
== disasm: #<ISeq:m@-e:1 (1,0)-(1,31)>
local table (size: 2, argc: 0 [opts: 2, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] a@0<Opt=0> [ 1] b@1<Opt=4>
0000 putobject                              true                      (   1)
0002 setlocal_WC_0                          a@0
0004 putobject                              false
0006 setlocal_WC_0                          b@1
0008 putnil                                 [Ca]
0009 leave                                  [Re]

困りそうなポイントの3つ目はargs->pre_initargs->post_initによる分岐です。

if (args->pre_init) { /* m_init */
    NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", args->pre_init));
}
if (args->post_init) { /* p_init */
    NO_CHECK(COMPILE_POPPED(optargs, "init arguments (p)", args->post_init));
}

そもそもこれはなんのためのフィールドなのでしょうか。 わからないフィールドがあるときはnode_dump.cを読むのですが12、該当箇所を読んでもピンときません。

        F_NODE(nd_ainfo.pre_init, RNODE_ARGS, "initialization of (pre-)arguments");
        F_NODE(nd_ainfo.post_init, RNODE_ARGS, "initialization of post-arguments");

そういうときはrb_bugという関数を呼ぶようにしてmake mainmake test-allを実行してみます。

@@ -2213,9 +2213,11 @@ iseq_set_arguments(rb_iseq_t *iseq, LINK_ANCHOR *const optargs, const NODE *cons
         body->param.size = arg_size;

         if (args->pre_init) { /* m_init */
+            rb_bug("args->pre_init");
             NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", args->pre_init));
         }
         if (args->post_init) { /* p_init */
+            rb_bug("args->psot_init");
             NO_CHECK(COMPILE_POPPED(optargs, "init arguments (p)", args->post_init));
         }

実際には通らないコードであれば何も起きませんし、使われているコードであればバックトレースを表示して落ちます。

$ make -j4 main
...
linking miniruby
./miniruby: [BUG] args->pre_init

バックトレースを眺めてみると(Init_builtin_yjit+0x1c) [0x105401d80] ../../yjit.rbinc:73という行からコンパイラが走った際に問題の分岐に入るようです。 yjit.rbのノードをみてみると、たしかにpre_initが埋まっているケースがあるようです。

$ ruby --parser=parse.y --dump=p yjit.rb

+- nd_ainfo.pre_init:
|   @ NODE_MASGN (id: 2947, line: 520, location: (520,33)-(520,44))
|   +- nd_value:
|   |   @ NODE_DVAR (id: 2948, line: 520, location: (520,33)-(520,33))
|   |   +- nd_vid: (internal variable: 0x34359214071)

yjit.rbの520行目あたりをみてみるとブロック引数が目に入ります。

      counters.reverse_each do |(name, value)|
        padded_name = name.rjust(longest_name_length, ' ')
        padded_count = format_number_pct(10, value, total)
        out.puts("    #{padded_name}: #{padded_count}")
      end

ここまでくれば再現コードを書くのは簡単です。 def m((a, b), *c, (d, e)); endのときにpre_initpost_initNODE_MASGN(multiple assignmentのノード)が設定されることがわかります。

実際にはf_arg_itemの生成規則にmultiple assignmentのケースが書かれていたのですが、なんか複雑そうだったので特に触れずにおいておいたのでした。

f_arg_item : f_arg_asgn
               {
                   $$ = NEW_ARGS_AUX($1, 1, &NULL_LOC);
               /*% ripper: $:1 %*/
               }
            | tLPAREN f_margs rparen
               {
                   ID tid = internal_id(p);
                   YYLTYPE loc;
                   loc.beg_pos = @2.beg_pos;
                   loc.end_pos = @2.beg_pos;
                   arg_var(p, tid);
                   if (dyna_in_block(p)) {
                       $2->nd_value = NEW_DVAR(tid, &loc);
                   }
                   else {
                       $2->nd_value = NEW_LVAR(tid, &loc);
                   }
                   $$ = NEW_ARGS_AUX(tid, 1, &NULL_LOC);
                   $$->nd_next = (NODE *)$2;
               /*% ripper: mlhs_paren!($:2) %*/
               }
            ;

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

        // TODO: def m4((a, b), c, (d, e), f = false, (g, h)); end
        // if (args->pre_init) { /* m_init */
        //     NO_CHECK(COMPILE_POPPED(optargs, "init arguments (m)", args->pre_init));
        // }
        // if (args->post_init) { /* p_init */
        //     NO_CHECK(COMPILE_POPPED(optargs, "init arguments (p)", args->post_init));
        // }

必須引数、オプショナル引数、rest引数に対応したので今日はこの辺で終わりにします。

おまけ: オプショナル引数のバイトコード

以下はオプショナル引数を含んだメソッド定義に対して生成されるバイトコードです。 記事の中ではローカル変数のバイトコードと同じなんですという話をしましたが、このバイトコードを見たときに違和感を覚えた人は鋭いです。 これをそのまま実行すると、引数が与えられていても常にデフォルト値で上書きされることになります。

$ ruby --parser=parse.y --dump=i -e 'def m(a, opt1 = true, opt2 = false); end'
...

== disasm: #<ISeq:m@-e:1 (1,0)-(1,40)>
local table (size: 3, argc: 1 [opts: 2, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a@0<Arg>   [ 2] opt1@1<Opt=0>[ 1] opt2@2<Opt=4>
0000 putobject                              true                      (   1)
0002 setlocal_WC_0                          opt1@1
0004 putobject                              false
0006 setlocal_WC_0                          opt2@2
0008 putnil                                 [Ca]
0009 leave                                  [Re]

しかし実際にはこのバイトコードで正しく動いています。 ということは、「これってもしかして呼び出し時にプログラムカウンター(pc)を調整していたりするのでは?」という仮説が思いつきます。 実際に確認してみましょう。

メソッドの呼び出し時にはvm_call_method_each_typevm_call_iseq_setupを呼び出します。

static VALUE
vm_call_iseq_setup(rb_execution_context_t *ec, rb_control_frame_t *cfp, struct rb_calling_info *calling)
{
    ...
    const int opt_pc = vm_callee_setup_arg(ec, calling, iseq, cfp->sp - calling->argc, param_size, local_size);
    return vm_call_iseq_setup_2(ec, cfp, calling, opt_pc, param_size, local_size);
}

vm_call_iseq_setupvm_callee_setup_argの戻り値であるopt_pcvm_call_iseq_setup_2に渡しています。 opt_pcってオプショナル(optional)引数のpcっぽいですね。

vm_call_iseq_setup_2vm_call_iseq_setup_normalを呼ぶので、そちらをみましょう。

static inline VALUE
vm_call_iseq_setup_normal(rb_execution_context_t *ec, rb_control_frame_t *cfp, struct rb_calling_info *calling, const rb_callable_method_entry_t *me,
                          int opt_pc, int param_size, int local_size)
{
    const rb_iseq_t *iseq = def_iseq_ptr(me->def);
    VALUE *argv = cfp->sp - calling->argc;
    VALUE *sp = argv + param_size;
    cfp->sp = argv - 1 /* recv */;

    vm_push_frame(ec, iseq, VM_FRAME_MAGIC_METHOD | VM_ENV_FLAG_LOCAL, calling->recv,
                  calling->block_handler, (VALUE)me,
                  ISEQ_BODY(iseq)->iseq_encoded + opt_pc, sp,
                  local_size - param_size,
                  ISEQ_BODY(iseq)->stack_max);
    return Qundef;
}

vm_call_iseq_setup_normalvm_push_frameの第7引数の計算にopt_pcを用いています。 vm_push_frameの第7引数はずばりpcなので、これはpcを調整していますね。

static void
vm_push_frame(rb_execution_context_t *ec,
              const rb_iseq_t *iseq,
              VALUE type,
              VALUE self,
              VALUE specval,
              VALUE cref_or_me,
              const VALUE *pc,
              VALUE *sp,
              int local_size,
              int stack_max)
{...

調整するpcの計算はvm_callee_setup_argから呼ばれるsetup_parameters_complexで行っています。 complexというだけあって長いので、オプショナル引数に関係のある箇所だけ抜粋するとこんな感じになります。

static int
setup_parameters_complex(rb_execution_context_t * const ec, const rb_iseq_t * const iseq,
                         struct rb_calling_info *const calling,
                         const struct rb_callinfo *ci,
                         VALUE * const locals, const enum arg_setup_type arg_setup_type)
{
    ...
    int opt_pc = 0, allow_autosplat = !kw_flag;
    ...

    if (ISEQ_BODY(iseq)->param.flags.has_opt) {
        int opt = args_setup_opt_parameters(args, ISEQ_BODY(iseq)->param.opt_num, locals + ISEQ_BODY(iseq)->param.lead_num);
        opt_pc = (int)ISEQ_BODY(iseq)->param.opt_table[opt];
    }
    ...
    return opt_pc;
}

args_setup_opt_parametersは与えられた(実)引数の数に応じてオプショナル引数を埋め、最終的に埋められたオプショナル引数の数を返します(int opt)。 setup_parameters_complexISEQ_BODY(iseq)->param.opt_tableというテーブルから調整するべきpcをlookupして返しています。 さきほどあげたコードをもとに考えてみます。

$ ruby --parser=parse.y --dump=i -e 'def m(a, opt1 = true, opt2 = false); end'
...

== disasm: #<ISeq:m@-e:1 (1,0)-(1,40)>
local table (size: 3, argc: 1 [opts: 2, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a@0<Arg>   [ 2] opt1@1<Opt=0>[ 1] opt2@2<Opt=4>
0000 putobject                              true                      (   1)
0002 setlocal_WC_0                          opt1@1
0004 putobject                              false
0006 setlocal_WC_0                          opt2@2
0008 putnil                                 [Ca]
0009 leave                                  [Re]

m(1)、つまり必須引数のみが渡されたときは0000から実行してopt1opt2をデフォルト値で初期化する必要があります。 m(1, 2)、つまり1つめのオプショナル引数まで渡されたときは0004から実行してopt2のみをデフォルト値で初期化する必要があります。 m(1, 2, 3)、つまりすべてのオプショナル引数が渡されたときは0008から実行を開始する必要があります。

ここでlocal tableのすぐ下の行をみてみると、opt1@1<Opt=0>opt2@2<Opt=4>のように各オプショナル引数を初期化するためのバイトコードがどこから始まっているかが記載されています。 この情報はparam.opt_tableという配列に格納されています。

opt_tableはcompile.cに定義されているiseq_set_argumentsで構築されます。 

if (args->opt_args) {
    const rb_node_opt_arg_t *node = args->opt_args;
    LABEL *label;
    VALUE labels = rb_ary_hidden_new(1);
    VALUE *opt_table;
    int i = 0, j;

    while (node) {
        label = NEW_LABEL(nd_line(RNODE(node)));
        rb_ary_push(labels, (VALUE)label | 1);
        ADD_LABEL(optargs, label);
        // オプショナル引数の初期化用のバイトコードを生成する
        NO_CHECK(COMPILE_POPPED(optargs, "optarg", node->nd_body));
        node = node->nd_next;
        i += 1;
    }

    /* last label */
    label = NEW_LABEL(nd_line(node_args));
    rb_ary_push(labels, (VALUE)label | 1);
    ADD_LABEL(optargs, label);

    // opt_tableのメモリを確保する
    opt_table = ALLOC_N(VALUE, i+1);

    MEMCPY(opt_table, RARRAY_CONST_PTR(labels), VALUE, i+1);
    for (j = 0; j < i+1; j++) {
        opt_table[j] &= ~1;
    }
    rb_ary_clear(labels);

    body->param.flags.has_opt = TRUE;
    body->param.opt_num = i;
    body->param.opt_table = opt_table;
    arg_size += i;
}

opt_table = ALLOC_N(VALUE, i+1);とオプショナル引数の総数より1つ多く確保しているのは、全てのオプショナル引数が与えられた場合の飛び先を保存する必要があるためでしょう。 rb_ary_push(labels, (VALUE)label | 1);と最下位のbitを立てている13のは、VALUEではないポインターなどをArrayオブジェクトに入れる時によく使われるテクニックです14。 どのくらいよく使われるかというと、あまりにあちらこちらに同じようなマクロがあるのでついに統一されることになりました

おまけ2: first_post_argとはなんなのか

args->post_args_num(post argsの個数)をチェックするのではなく、args->first_post_arg(post argsの最初の仮引数の有無)をチェックしているのが気にはなりますが、必須引数の数をチェックするコードに置き換えておきましょう。

という話をしましたが、first_post_argが何に使われていたか調査してみましょう。

first_post_argが導入されたコミットをみてみると、当時はpostの必須引数がはじまる位置を調べるのにget_dyna_var_idx_at_rawを使っていたことがわかります。この関数はIDを引数に取るので、ノード側に情報を持たせるような仕組みになってました。

その後、_からはじまる変数名は重複して使うことができるため、IDに依存したロジックだとおかしな挙動をするケースがあることがわかり、順序に依存するように変更が入りました。 このときにIDとしてのfirst_post_argの使用は終わったのでしょう。

まとめ

今日の成果です。

  • メソッドが定義できるようになった
  • 仮引数として必須引数、オプショナル引数、rest引数に対応した

次回は残りのキーワード引数とブロック引数に取り組もうかと思います。


  1. fというのはformal argumentのfのことでしょう。
  2. 文法定義から文法を理解しなくてもリファレンスマニュアルを読めばいいのではと思った方もいるかもしれませんが、文法定義のほうが慣れているので文法定義を読みます。
  3. struct rb_node_list2という名前ですが、実は書き換え前の世界ではNODE_LISTというノードがあり、その構造体の型がrb_node_list_tになっているのでstruct rb_node_listという構造体を定義すると型名がぶつかってしまうのです。最終的にはNODE_LISTは消えるはずなので、PRを作るときにstruct rb_node_listにリネームすればよいでしょう。
  4. ノードの場合はノードのメモリを管理する領域をまとめて取得してあり、最後に一気に解放するのでリークの可能性が少ないです。
  5. 実際にNODE_DEF_TEMPNODE_EXITSというparse時にしか使わないノードはすでにあるので、別に変なアプローチではない(はず?)です。
  6. かつてはこのような間借りするアプローチで書かれたコードをみてため息をつくこともありましたが、まあ人生いろいろあります。
  7. 配列の生成規則を変更していないので[true, false]などは未対応ですが、とにかく先に進みます。
  8. 既存のnew_argsとの差分が大きいので、あとからnew_argsを見返せるように一旦別名の関数として定義していますが、最終的にはリネームするので気にしないでください。
  9. 思慮深さとは...
  10. def m(a = self); end。まだ一部のリテラルしか対応していないのでa = 1が書けないのです。
  11. b = falseに相当するバイトコードがオプショナル引数とローカル変数への代入で異なるのは、オプショナル引数の場合は常にpopped = trueコンパイルされるのに対して、ローカル変数への代入(というかstatements)では最後の要素がpopped = falseコンパイルされるケースがあるためです。
  12. もしくは--dump=parsetree+commentをつけてdumpするのでもよいです。
  13. 最下位のbitを立てるとRubyはIntegerオブジェクトだと認識するので、gcが起きた時に諸々の処理をスキップしてくれるようになります。
  14. parse.yでは見かけないので実は最近まで知らなかったです。



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

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