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


Ruby Parser開発日誌 (24-4) - parse.yが生成するノードを変える ー 完成。仮引数

4日目: キーワード引数とブロック引数

前回に引き続きメソッド定義の仮引数部分の書き換えをすすめていきましょう。 必須引数、オプショナル引数、rest引数は対応済みなので、今回は残りのキーワード引数とブロック引数に取り組みます。

parse.yを眺める

いつものとおり関連する文法を眺めます。

args_tail       : args_tail_basic(arg_value)
                | args_forward // `...` のこと
                ;

%rule args_tail_basic(value) <node_args>
                : f_kwarg(value) ',' f_kwrest opt_f_block_arg
                | f_kwarg(value) opt_f_block_arg
                | f_any_kwrest opt_f_block_arg
                | f_block_arg // `&blk`もしくは`&` のこと
                ;

%rule f_kwarg(value) <node_kw_arg> // `k1: v1, k2: :v2, ...` のこと
                : f_kw(value)
                | f_kwarg(value) ',' f_kw(value)
                ;

%rule f_kw(value) <node_kw_arg>
                : f_label value // `k: val` のこと
                | f_label       // `k:` のことで`k: k`のショートハンド
                ;

f_any_kwrest    : f_kwrest      // `**kw`もしくは`**` のこと
                | f_no_kwarg    // `**nil` のこと!
                ;

**nilは完全に忘れていました1。 キーワード引数を受け付けないメソッドを定義する文法です。

def m(**nil)
end

m(k: 1)
# => test.rb:4:in '<main>': no keywords accepted (ArgumentError)
書き換え前 書き換え後
k1: NODE_KW_ARGNODE_LASGNがセットされ、そのvalueNODE_SPECIAL_REQUIRED_KEYWORD RequiredKeywordParameterNode
k2: :v2 NODE_KW_ARGNODE_LASGNがセットされる OptionalKeywordParameterNode
**kw 専用のノードはなくてnd_ainfo.kw_rest_argNODE_DVARがセットされる KeywordRestParameterNode
** 専用のノードはなくてnd_ainfo.kw_rest_argNODE_DVARがセットされる(idが:**) KeywordRestParameterNodename: nil
**nil 専用のノードはなくてnd_ainfo.no_kwarg1がセットされる NoKeywordsParameterNode
&blk 専用のノードはなくてnd_ainfo.block_arg:blkがセットされる BlockParameterNode
& 専用のノードはなくてnd_ainfo.block_arg:&がセットされる BlockParameterNodename: nil

それぞれ書き換えていきます。

キーワード引数とブロック引数を受け取ってParametersNodeに設定する役割はnew_args_tail2という関数が担っています。 これはもともとあったnew_args_tailという関数に相当します。 ところでnew_args_tailには以下のような処理があります。

static rb_node_args_t *
new_args_tail(struct parser_params *p, rb_node_kw_arg_t *kw_args, ID kw_rest_arg, ID block, const YYLTYPE *kw_rest_loc)
{
    ...
    args->block_arg      = block;
    args->kw_args        = kw_args;

    if (kw_args) {
        /*
         * def foo(k1: 1, kr1:, k2: 2, **krest, &b)
         * variable order: k1, kr1, k2, &b, internal_id, krest
         * #=> <reorder>
         * variable order: kr1, k1, k2, internal_id, krest, &b
         */
        ID kw_bits = internal_id(p), *required_kw_vars, *kw_vars;
        struct vtable *vtargs = p->lvtbl->args;
        rb_node_kw_arg_t *kwn = kw_args;

        if (block) block = vtargs->tbl[vtargs->pos-1];
        vtable_pop(vtargs, !!block + !!kw_rest_arg);
        required_kw_vars = kw_vars = &vtargs->tbl[vtargs->pos];
        while (kwn) {
            if (!NODE_REQUIRED_KEYWORD_P(get_nd_value(p, kwn->nd_body)))
                --kw_vars;
            --required_kw_vars;
            kwn = kwn->nd_next;
        }

        for (kwn = kw_args; kwn; kwn = kwn->nd_next) {
            ID vid = get_nd_vid(p, kwn->nd_body);
            if (NODE_REQUIRED_KEYWORD_P(get_nd_value(p, kwn->nd_body))) {
                *required_kw_vars++ = vid;
            }
            else {
                *kw_vars++ = vid;
            }
        }

        arg_var(p, kw_bits);
        if (kw_rest_arg) arg_var(p, kw_rest_arg);
        if (block) arg_var(p, block);

        args->kw_rest_arg = NEW_DVAR(kw_rest_arg, kw_rest_loc);
    }
    else if (kw_rest_arg == idNil) {
        args->no_kwarg = 1;
    }
    else if (kw_rest_arg) {
        args->kw_rest_arg = NEW_DVAR(kw_rest_arg, kw_rest_loc);
    }

    return node;
}

丁寧にコメントまで書いてあります。 CRubyのコードにおいて自明な箇所にコメントが書いてあることは稀です。ということはここではなにか深淵なことをしているのだと勘が働きます。 いつもの通り、まずはコメントアウトして進むことにしましょう。 ノードを書き換えたことでこの部分を理解しなくて済む可能性が何%かあるので、その可能性に賭けます。

compile.cを変更する

k1:k2: v2iseq_set_arguments_keywordsで、そのほかはiseq_set_argumentsで処理をしています。 基本的にはノードの構造体を変えたのにあわせていつものようにマクロなどを書き換えればよいでしょう。

k1:k2: v2でいままで同じノードを使っていたところを、今回の書き換えではRequiredKeywordParameterNodeOptionalKeywordParameterNodeのように異なるノードで表現するようになったので、その点は注意が必要です。

// Before
static int
iseq_set_arguments_keywords(rb_iseq_t *iseq, LINK_ANCHOR *const optargs,
                            const struct rb_args_info *args, int arg_size)
{
    ...
    while (node) {
        const NODE *val_node = get_nd_value(node->nd_body);
        VALUE dv;

        if (val_node == NODE_SPECIAL_REQUIRED_KEYWORD) {
            ++rkw;
        }
        else {

// After
static int
iseq_set_arguments_keywords(rb_iseq_t *iseq, LINK_ANCHOR *const optargs,
                            const rb_parameters_node_t *args, int arg_size)
{
    ...
    for (size_t i = 0; i < RB_NODE_LIST_LEN(keywords); i++) {
        const NODE *node = keywords->nodes[i];
        VALUE dv;

        switch (nd_type(node)) {
          case RB_REQUIRED_KEYWORD_PARAMETER_NODE:
            ++rkw;
            break;
          case RB_OPTIONAL_KEYWORD_PARAMETER_NODE: {

余談ですがparse.yでruby2_keywordsを設定している箇所は実質デッドコード2のはずなので、本体のブランチで先に消しておきましょう

さてここまでできたらコンパイルしてみます。

def m1(k1:, k2: false, **kw)
end

def m2(**)
end

def m3(**nil)
end

def m4(&blk)
end

def m5(&)
end
== disasm: #<ISeq:m1@../../test.rb:1 (1,0)-(2,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: 3])
[ 3] k1@0       [ 2] k2@1       [ 1] kw@2
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

== disasm: #<ISeq:m2@../../test.rb:4 (4,0)-(5,3)>
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: 0])
[ 1] "**"@0<AnonKwrest>
0000 putnil                                                           (   4)[Ca]
0001 leave                                  [Re]

== disasm: #<ISeq:m3@../../test.rb:7 (7,0)-(8,3)>
0000 putnil                                                           (   7)[Ca]
0001 leave                                  [Re]

== disasm: #<ISeq:m4@../../test.rb:10 (10,0)-(11,3)>
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: 0, kw: -1@-1, kwrest: -1])
[ 1] blk@0<Block>
0000 putnil                                                           (  10)[Ca]
0001 leave                                  [Re]

== disasm: #<ISeq:m5@../../test.rb:13 (13,0)-(14,3)>
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: 0, kw: -1@-1, kwrest: -1])
[ 1] "&"@0<Block>
0000 putnil                                                           (  13)[Ca]
0001 leave                                  [Re]

一見良さそうですが、もとのiseqと比べるとm1local tableに差分が出ています。

// Before
== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: 3])
[ 4] k1@0       [ 3] k2@1       [ 2] ?@2        [ 1] kw@3<Kwrest>
0000 putnil                                                           (   1)[Ca]
0001 leave                                                            (   2)[Re]

// After
== disasm: #<ISeq:m1@../../test.rb:1 (1,0)-(2,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: 3])
[ 3] k1@0       [ 2] k2@1       [ 1] kw@2
0000 putnil                                                           (   1)[Ca]
0001 leave

[ 2] ?@2が消えています。

仮引数が3つしかないのに、なんでlocal tableに4つも領域があるんだ?

local tableとinternal ID

突然ですがiseqのlocal tableはどこで構築されるかでしょう。 答えはcompile.cのiseq_set_local_tableです。

        iseq_set_local_table(iseq, RNODE_SCOPE(node)->nd_tbl, (NODE *)RNODE_SCOPE(node)->nd_args);
        iseq_set_arguments(iseq, ret, (NODE *)RNODE_SCOPE(node)->nd_args);
        iseq_set_parameters_lvar_state(iseq);

iseq_set_local_tableはというと...があるときの分岐を無視して読めば、SCOPEノードのもっているnd_tblをiseqのlocal_tableにコピーしています。

static int
iseq_set_local_table(rb_iseq_t *iseq, const rb_ast_id_table_t *tbl, const NODE *const node_args)
{
    unsigned int size = tbl ? tbl->size : 0;
    unsigned int offset = 0;

    if (node_args) {
        struct rb_args_info *args = &RNODE_ARGS(node_args)->nd_ainfo;

        // If we have a function that only has `...` as the parameter,
        // then its local table should only be `...`
        // FIXME: I think this should be fixed in the AST rather than special case here.
        if (args->forwarding && args->pre_args_num == 0 && !args->opt_args) {
            CHECK(size >= 3);
            size -= 3;
            offset += 3;
        }
    }

    if (size > 0) {
        ID *ids = ALLOC_N(ID, size);
        MEMCPY(ids, tbl->ids + offset, ID, size);
        ISEQ_BODY(iseq)->local_table = ids;

        enum lvar_state *states = ALLOC_N(enum lvar_state, size);
        // fprintf(stderr, "iseq:%p states:%p size:%d\n", iseq, states, (int)size);
        for (unsigned int i=0; i<size; i++) {
            states[i] = lvar_uninitialized;
            // fprintf(stderr, "id:%s\n", rb_id2name(ISEQ_BODY(iseq)->local_table[i]));
        }
        ISEQ_BODY(iseq)->lvar_states = states;
    }
    ISEQ_BODY(iseq)->local_table_size = size;

    debugs("iseq_set_local_table: %u\n", ISEQ_BODY(iseq)->local_table_size);
    return COMPILE_OK;
}

ここでおもむろに構文木を見てみます。

def m1(k1:, k2: false)
end
#         @ NODE_SCOPE (id: 10, line: 2, location: (1,0)-(2,3))
#         +- nd_tbl: :k1,:k2,(internal variable: 0x34359214071)

なんかinternal variableという見慣れないやつがいます。

def m1(k1:)
end
#         @ NODE_SCOPE (id: 7, line: 2, location: (1,0)-(2,3))
#         +- nd_tbl: :k1,(internal variable: 0x34359214079)

def m1(k1: 1)
end
#         @ NODE_SCOPE (id: 8, line: 2, location: (1,0)-(2,3))
#         +- nd_tbl: :k1,(internal variable: 0x34359214079)

オプショナルキーワード引数かどうかに関係なく、キーワード引数を指定するとinternal variableが追加されるようです。 ここまでくれば何が悪いかわかりますね。 さきほどコメントアウトしたnew_args_tailの以下の部分が必要だったのです。

    if (kw_args) {
        /*
         * def foo(k1: 1, kr1:, k2: 2, **krest, &b)
         * variable order: k1, kr1, k2, &b, internal_id, krest
         * #=> <reorder>
         * variable order: kr1, k1, k2, internal_id, krest, &b
         */
        ID kw_bits = internal_id(p), *required_kw_vars, *kw_vars;
        struct vtable *vtargs = p->lvtbl->args;
        rb_node_kw_arg_t *kwn = kw_args;

        if (block) block = vtargs->tbl[vtargs->pos-1];
        // block引数とキーワードrest引数の分だけ変数をpopする
        vtable_pop(vtargs, !!block + !!kw_rest_arg);
        required_kw_vars = kw_vars = &vtargs->tbl[vtargs->pos];

        // tblの末尾を起点にして、必須キーワード引数とオプショナルキーワード引数の開始位置を計算する
        // 必須キーワード引数, オプショナルキーワード引数の順に並べたいので、`required_kw_vars`は毎回1つ戻すが、
        // `kw_vars`はオプショナルキーワード引数のときだけ戻す
        while (kwn) {
            if (!NODE_REQUIRED_KEYWORD_P(get_nd_value(p, kwn->nd_body)))
                --kw_vars;
            --required_kw_vars;
            kwn = kwn->nd_next;
        }

        // 必須キーワード引数とオプショナルキーワード引数を設定し直す
        for (kwn = kw_args; kwn; kwn = kwn->nd_next) {
            ID vid = get_nd_vid(p, kwn->nd_body);
            if (NODE_REQUIRED_KEYWORD_P(get_nd_value(p, kwn->nd_body))) {
                *required_kw_vars++ = vid;
            }
            else {
                *kw_vars++ = vid;
            }
        }

        // `internal_id`を必須キーワード引数, オプショナルキーワード引数の後に入れる
        arg_var(p, kw_bits);
        // キーワードrest引数とblock引数を入れ直す
        if (kw_rest_arg) arg_var(p, kw_rest_arg);
        if (block) arg_var(p, block);

        args->kw_rest_arg = NEW_DVAR(kw_rest_arg, kw_rest_loc);
    }
  • variable order: k1, kr1, k2, &b, internal_id, krestvariable order: k1, kr1, k2, **krest, &b, internal_idの順番じゃないのか?
  • if (block) block = vtargs->tbl[vtargs->pos-1];blockが変わることがあるのか?
  • 必須キーワード引数をオプショナルキーワード引数の前にもってくる必要があるのか?

など、コードをみていると細かいところが気にはなりますが、まずは大元のコードを残したまま書き換えるのがいいでしょう。

    if (kw_args) {
        /*
         * def foo(k1: 1, kr1:, k2: 2, **krest, &b)
         * variable order: k1, kr1, k2, &b, internal_id, krest
         * #=> <reorder>
         * variable order: kr1, k1, k2, internal_id, krest, &b
         */
        ID kw_bits = internal_id(p), *required_kw_vars, *kw_vars;
        ID block_id = 0;
        struct vtable *vtargs = p->lvtbl->args;
        rb_array_node_t *kwn = kw_args;

        if (block) block_id = vtargs->tbl[vtargs->pos-1];
        vtable_pop(vtargs, !!block + !!kw_rest_arg);
        required_kw_vars = kw_vars = &vtargs->tbl[vtargs->pos];

        for (size_t i = 0; i < RB_NODE_LIST_LEN(&kwn->elements); i++) {
            rb_node_t *node = kwn->elements.nodes[i];
            if (!RB_NODE_TYPE_P(node, RB_REQUIRED_KEYWORD_PARAMETER_NODE))
                --kw_vars;
            --required_kw_vars;
        }

        for (size_t i = 0; i < RB_NODE_LIST_LEN(&kwn->elements); i++) {
            rb_node_t *node = kwn->elements.nodes[i];
            ID vid = get_kw_nd_vid(p, node);
            if (RB_NODE_TYPE_P(node, RB_REQUIRED_KEYWORD_PARAMETER_NODE)) {
                *required_kw_vars++ = vid;
            }
            else {
                *kw_vars++ = vid;
            }
        }

        rb_node_list_move(&node->keywords, &kw_args->elements);
        arg_var(p, kw_bits);
        if (kw_rest_arg) arg_var(p, get_kw_nd_vid(p, kw_rest_arg));
        if (block) arg_var(p, block_id);

        // args->kw_rest_arg = NEW_DVAR(kw_rest_arg, kw_rest_loc);
    }

    if (kw_rest_arg) {
        node->keyword_rest = kw_rest_arg;
    }

元のコードではkw_rest_argがない場合でもkw_argsがあればargs->kw_rest_argをセットしていましたが、これをやると生成されるノードが変わってしまうので、この部分だけはロジックを変更しています。

def m1(k1:, k2: false, **kw)
end

// Before
== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: 3])
[ 4] k1@0       [ 3] k2@1       [ 2] ?@2        [ 1] kw@3<Kwrest>
0000 putnil                                                           (   1)[Ca]
0001 leave

// After
== disasm: #<ISeq:m1@../../test.rb:1 (1,0)-(2,3)>
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: 3])
[ 4] k1@0       [ 3] k2@1       [ 2] ?@2        [ 1] kw@3<Kwrest>
0000 putnil                                                           (   1)[Ca]
0001 leave                                  [Re]

これで無事に?@2がlocal tableに確保されるようになりました。

オプショナルキーワード引数のコンパイル

オプショナル引数に続いてオプショナルキーワード引数をコンパイルできるようにします。 書き換え前のノードを見ればわかるように、どちらもNODE_LASGNコンパイルすることで実装されていました。 共通する処理をcompile_lasgnという関数に切り出すタイミングが来たようです。

+static int
+compile_lasgn(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, ID id, const NODE *const valn, int popped)
+{
+    int idx = ISEQ_BODY(iseq)->local_table_size - get_local_var_idx(iseq, id);
+
+    debugs("lvar: %s idx: %d\n", rb_id2name(id), idx);
+    CHECK(COMPILE(ret, "rvalue", valn));
+
+    if (!popped) {
+        ADD_INSN(ret, node, dup);
+    }
+    ADD_SETLOCAL(ret, node, idx, get_lvar_level(iseq));
+    return COMPILE_OK;
+}
+

       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));
+        CHECK(compile_lasgn(iseq, ret, node, id, RB_NODE_OPTIONAL_PARAMETER(node)->value, 1));
         break;
       }

ということでキーワード引数も完了です。 長かった...

おまけ: キーワード引数の実装をもっと調べる

さて、今回の実装で出てきた?というlocal tableのエントリーについて調べておきましょう。 先に書いておくと多分この調査は邪念であり、真にやるべきは同じiseqを生成するようになんとかすることでしょう。

では調査開始です。

どんなときにこの?が使われるか調べます。

def m1(k1:)
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 1@1, kwrest: -1])
[ 2] k1@0       [ 1] ?@1
0000 putnil                                                           (   1)[Ca]
0001 leave                                                            (   2)[Re]

?@1は使われてなさそうです。

キーワード引数を増やしてみましょう。

def m1(k1:, k2:)
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@2, kwrest: -1])
[ 3] k1@0       [ 2] k2@1       [ 1] ?@2
0000 putnil                                                           (   1)[Ca]
0001 leave                                                            (   2)[Re]

やっぱり使われてない。

オプショナルにしてみるとどうでしょう。

def m1(k1:, k2: false)
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: -1])
[ 3] k1@0       [ 2] k2@1       [ 1] ?@2
0000 putnil                                                           (   1)[Ca]
0001 leave                                                            (   2)[Re]

?@2は使われてなさそうです。

falseなんてデフォルト値は真っ先に最適化されそうなので、最適化できなさそうな値をデフォルト値にいれてみましょう。

def m1(k1:, k2: obj.m)
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: -1])
[ 3] k1@0       [ 2] k2@1       [ 1] ?@2
0000 checkkeyword                           3, 0                      (   1)
0003 branchif                               12
0005 putself
0006 opt_send_without_block                 <calldata!mid:obj, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0008 opt_send_without_block                 <calldata!mid:m, argc:0, ARGS_SIMPLE>
0010 setlocal_WC_0                          k2@1
0012 putnil                                 [Ca]
0013 leave                                                            (   2)[Re]

checkkeywordという命令が出現しました。 命令をざっとみると、

  • キーワードをチェックする(checkkeyword)
  • キーワードがあれば0012までジャンプする(branchif)
  • キーワードがなければobj.mを呼び出す(putselfから二つ目のopt_send_without_blockまで)
  • メソッド呼び出しの結果をk2に代入する(setlocal_WC_0)

となっています。 いかにもオプショナル引数の実装という感じがします。

直感的にはcheckkeywordという命令が?@2を使っているのではないかという気がします。 checkkeywordを詳しくみてみます。

/* check keywords are specified or not. */
DEFINE_INSN
checkkeyword
(lindex_t kw_bits_index, lindex_t keyword_index)
()
(VALUE ret)
{
    ret = vm_check_keyword(kw_bits_index, keyword_index, GET_EP());
}

static VALUE
vm_check_keyword(lindex_t bits, lindex_t idx, const VALUE *ep)
{
    const VALUE kw_bits = *(ep - bits);

    if (FIXNUM_P(kw_bits)) {
        unsigned int b = (unsigned int)FIX2ULONG(kw_bits);
        if ((idx < VM_KW_SPECIFIED_BITS_MAX) && (b & (0x01 << idx)))
            return Qfalse;
    }
    else {
        VM_ASSERT(RB_TYPE_P(kw_bits, T_HASH));
        if (rb_hash_has_key(kw_bits, INT2FIX(idx))) return Qfalse;
    }
    return Qtrue;
}

コードの行数は大したことがないけど、情報量が多い...

まずcheckkeywordオペランドの意味を確認しましょう。 第一オペランドlindex_t kw_bits_indexは最終的に*(ep - bits)とEPからのオフセット計算に使われています。 一方で第二オペランドlindex_t keyword_indexはhashのlookupに使われています。

EP周辺のデータの置き方ってどういう順番になっていたっけ?ということで関連するマクロを眺めます。

// vm_core.h
#define VM_ENV_DATA_SIZE             ( 3)

#define VM_ENV_DATA_INDEX_ME_CREF    (-2) /* ep[-2] */
#define VM_ENV_DATA_INDEX_SPECVAL    (-1) /* ep[-1] */
#define VM_ENV_DATA_INDEX_FLAGS      ( 0) /* ep[ 0] */
#define VM_ENV_DATA_INDEX_ENV        ( 1) /* ep[ 1] */

#define VM_ENV_INDEX_LAST_LVAR              (-VM_ENV_DATA_SIZE)

えーとつまり、こんな感じでEPからみて-3以前(-3を含む)がローカル変数用の領域になっているようです。

[FLAGS]     <- ep[ 0] = EP
[SPECVAL]   <- ep[-1]
[ME_CREF]   <- ep[-2]
[local var] <- ep[-3] = LAST_LVAR
[local var] <- ep[-4]
...以下local varが続く

ということはcheckkeyword 3, xはlocal tableの最後の要素である?@2にアクセスしているようです。

ためしに?@2の後ろにlocal変数を足してみるとcheckkeywordの第一オペランドがその分だけ変化します。

def m1(k1:, k2: obj.m)
  a = 1
  b = 2
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(4,3)>
local table (size: 5, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@1, kwrest: -1])
[ 5] k1@0       [ 4] k2@1       [ 3] ?@2        [ 2] a@3        [ 1] b@4
0000 checkkeyword                           5, 0                      (   1)
0003 branchif                               12
0005 putself
...

?@2が使われるケースをみつけたので、次は?@2のなかに何が入っているか確認します。

vm_check_keyword関数に書いてあるように、ここにはFixnum3もしくはHashのオブジェクトが入っているようです。 で、ここにフラグが立っているときはデフォルト値による初期化をスキップしているのでした。 ということは?@2には「オプショナルキーワード引数においてどのキーが渡されているか」という情報が入っているのではないでしょうか。

#define VM_KW_SPECIFIED_BITS_MAX (32-1) /* TODO: 32 -> Fixnum's max bits */ (vm_core.h)


static void
args_setup_kw_parameters(rb_execution_context_t *const ec, const rb_iseq_t *const iseq, const rb_callable_method_entry_t *cme,
                         VALUE *const passed_values, const int passed_keyword_len, const VALUE *const passed_keywords,
                         VALUE *const locals)
{
    ...
    for (di=0; i<key_num; i++, di++) {
        if (args_setup_kw_parameters_lookup(acceptable_keywords[i], &locals[i], passed_keywords, passed_values, passed_keyword_len)) {
            // オプショナルキーワード引数が渡されている場合
            found++;
        }
        else {
            // オプショナルキーワード引数が渡されていない場合
            if (UNDEF_P(default_values[di])) {
                locals[i] = Qnil;

                if (LIKELY(i < VM_KW_SPECIFIED_BITS_MAX)) {
                    // unspecified_bits (Cのint)にbitを立てる
                    unspecified_bits |= 0x01 << di;
                }
                else {
                    if (NIL_P(unspecified_bits_value)) {
                        // Fixnumで扱える上限を超えたときはhashに変換する
                        /* fixnum -> hash */
                        int j;
                        unspecified_bits_value = rb_hash_new();

                        for (j=0; j<VM_KW_SPECIFIED_BITS_MAX; j++) {
                            if (unspecified_bits & (0x01 << j)) {
                                rb_hash_aset(unspecified_bits_value, INT2FIX(j), Qtrue);
                            }
                        }
                    }
                    rb_hash_aset(unspecified_bits_value, INT2FIX(di), Qtrue);
                }
            }
            else {
                locals[i] = default_values[di];
            }
        }
    }

    if (NIL_P(unspecified_bits_value)) {
        // Cのintで管理しきれたので、Fixnumへ変換する
        unspecified_bits_value = INT2FIX(unspecified_bits);
    }
    locals[key_num] = unspecified_bits_value;
}

args_setup_kw_parameters関数で呼び出し時のキーワード引数を扱っているので関係するコードを眺めます。 unspecified_bitsということで、渡されていないオプショナルキーワード引数の場合にビットがたつ、もしくはhashのvalueにtrueが入るようになっています。 オプショナルキーワード引数が渡されているかという情報を管理するのに毎回hashを作るのは無駄なので、その数が十分に小さいときにはbit列(int)で管理しているのですね。

ということで、?@2には「オプショナルキーワード引数においてどのキーが渡されていないか」という情報が入っているのでした。

おまけ2:オプショナルキーワード引数の最適化

ところでオプショナルキーワード引数のデフォルト値によって、メソッド定義内に命令列が生成されるケースとされないケースがあるのでした。 どうみても最適化をしていますね。 次の例ではk1: falseに対応する命令は存在しませんが、k2: obj.mに対応する命令は存在します(0000から0010まで)。

def m1(k1: false, k2: obj.m, k3: 1)
end

== disasm: #<ISeq:m1@test.rb:1 (1,0)-(2,3)>
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 3@0, kwrest: -1])
[ 4] k1@0       [ 3] k2@1       [ 2] k3@2       [ 1] ?@3
0000 checkkeyword                           3, 1                      (   1)
0003 branchif                               12
0005 putself
0006 opt_send_without_block                 <calldata!mid:obj, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0008 opt_send_without_block                 <calldata!mid:m, argc:0, ARGS_SIMPLE>
0010 setlocal_WC_0                          k2@1
0012 putnil                                 [Ca]
0013 leave                                                            (   2)[Re]

m()として呼び出したときにk1: falsek3: 1はどのようにセットされるのでしょうか。 メソッドmの内部で処理していないということはメソッドを呼び出す側でなんとかしているはずです。 一方でメソッドの呼び出し側としては、それぞれのキーに対応するデフォルト値を知る必要があります。 このような仮説を持ったうえで、バイトコードを生成するロジックと、引数を設定するロジックをもう一度みてみましょう。

まずはバイトコードを生成するロジックから。

static int
iseq_set_arguments_keywords(rb_iseq_t *iseq, LINK_ANCHOR *const optargs,
                            const struct rb_args_info *args, int arg_size)
{
    ...
    const VALUE default_values = rb_ary_hidden_new(1);
    const VALUE complex_mark = rb_str_tmp_new(0);
    int kw = 0, rkw = 0, di = 0, i;

    body->param.flags.has_kw = TRUE;
    body->param.keyword = keyword = ZALLOC_N(struct rb_iseq_param_keyword, 1);

    while (node) {
        kw++;
        node = node->nd_next;
    }
    arg_size += kw;
    keyword->bits_start = arg_size++;

    node = args->kw_args;
    while (node) {
        const NODE *val_node = get_nd_value(node->nd_body);
        VALUE dv;

        if (val_node == NODE_SPECIAL_REQUIRED_KEYWORD) {
            // 必須キーワード引数のとき
            ++rkw;
        }
        else {
            // オプショナルキーワード引数のとき
            switch (nd_type(val_node)) {
              case NODE_SYM:
                dv = rb_node_sym_string_val(val_node);
                break;
              ...
              case NODE_NIL:
                dv = Qnil;
                break;
              case NODE_TRUE:
                dv = Qtrue;
                break;
              case NODE_FALSE:
                dv = Qfalse;
                break;
              default:
                NO_CHECK(COMPILE_POPPED(optargs, "kwarg", RNODE(node))); /* nd_type_p(node, NODE_KW_ARG) */
                dv = complex_mark;
            }

            keyword->num = ++di; // この行必要?
            rb_ary_push(default_values, dv);
        }

        node = node->nd_next;
    }

    keyword->num = kw;
    ...
    keyword->required_num = rkw;
    keyword->table = &body->local_table[keyword->bits_start - keyword->num];

    if (RARRAY_LEN(default_values)) {
        VALUE *dvs = ALLOC_N(VALUE, RARRAY_LEN(default_values));

        for (i = 0; i < RARRAY_LEN(default_values); i++) {
            VALUE dv = RARRAY_AREF(default_values, i);
            if (dv == complex_mark) dv = Qundef;
            if (!SPECIAL_CONST_P(dv)) rb_ractor_make_shareable(dv);
            RB_OBJ_WRITE(iseq, &dvs[i], dv);
        }

        keyword->default_values = dvs;
    }
    return arg_size;
}

そのものずばりでdefault_valuesというArrayを作っています。 デフォルト値のノードが特定のノードの場合、例えばシンボル(NODE_SYM)やtrue(NODE_TRUE)、false(NODE_FALSE)のときは対応する値がdefault_valuesに追加されます。 それ以外、例えばメソッド呼び出しなどの場合には最終的にQundefdefault_valuesに追加されます4def m1(k0:, k1: false, k2: obj.m, k3: 1); endという定義があった場合、default_values[false, undef, 1]というようになります。

引数を設定するロジックではコンパイル時に計算したdefault_valuesをもとに、デフォルト値が最適化済みかどうかに応じて処理を切り替えています。

static void
args_setup_kw_parameters(rb_execution_context_t *const ec, const rb_iseq_t *const iseq, const rb_callable_method_entry_t *cme,
                         VALUE *const passed_values, const int passed_keyword_len, const VALUE *const passed_keywords,
                         VALUE *const locals)
{
    ...
    for (di=0; i<key_num; i++, di++) {
        if (args_setup_kw_parameters_lookup(acceptable_keywords[i], &locals[i], passed_keywords, passed_values, passed_keyword_len)) {
            // オプショナルキーワード引数が渡されている場合
            found++;
        }
        else {
            // オプショナルキーワード引数が渡されていない場合
            if (UNDEF_P(default_values[di])) {
                // デフォルト値が最適化できない値の場合
                // nilで初期化したうえで、checkkeyword命令用にlocal tableの`?`の情報を更新する
                locals[i] = Qnil;

                if (LIKELY(i < VM_KW_SPECIFIED_BITS_MAX)) {
                    // unspecified_bits (Cのint)にbitを立てる
                    unspecified_bits |= 0x01 << di;
                }
                else {
                    if (NIL_P(unspecified_bits_value)) {
                        // Fixnumで扱える上限を超えたときはhashに変換する
                        /* fixnum -> hash */
                        int j;
                        unspecified_bits_value = rb_hash_new();

                        for (j=0; j<VM_KW_SPECIFIED_BITS_MAX; j++) {
                            if (unspecified_bits & (0x01 << j)) {
                                rb_hash_aset(unspecified_bits_value, INT2FIX(j), Qtrue);
                            }
                        }
                    }
                    rb_hash_aset(unspecified_bits_value, INT2FIX(di), Qtrue);
                }
            }
            else {
                // デフォルト値が最適化できる値の場合
                // コンパイル時に計算した値をそのまま使えるので、ローカル変数のテーブルを埋める
                locals[i] = default_values[di];
            }
        }
    }

    if (NIL_P(unspecified_bits_value)) {
        // Cのintで管理しきれたので、Fixnumへ変換する
        unspecified_bits_value = INT2FIX(unspecified_bits);
    }
    locals[key_num] = unspecified_bits_value;
}

おまけ3: キーワード引数の順番

さきほどparse.yでIDの入れ替えを行った時に

必須キーワード引数をオプショナルキーワード引数の前にもってくる必要があるのか?

という疑問が生じました。 が、どうも... 必要がありそうです...

キーワード引数に関してiseqにどのような情報が残っているか考えてみましょう。

const struct rb_iseq_param_keyword {
    int num; // 必須 or オプショナルキーワード引数の総数
    int required_num; // オプショナルキーワード引数の総数
    int bits_start; // local tableのうち、checkkeyword命令用の情報を格納している位置を表すindex。つまりは`?@2`の位置
    int rest_start; // local tableのうち、restキーワード引数の位置を表すindex
    const ID *table; // local tableのうち、最初のキーワード引数の位置へのポインタ
    VALUE *default_values; // コンパイル時に決定可能なデフォルト値を保存するためのArray
} *keyword;

ということで必須キーワード引数の総数(num - required_num)やオプショナルキーワード引数の総数(required_num)、それぞれのキーの名前(table[i])はわかりますが、それぞれの引数が必須なのかオプショナルなのかという情報は直接は残っていません。 ではどうしているかというと必須キーワード引数とオプショナルキーワード引数をそれぞれ固めてlocal tableに配置しているんですね。 そのような配置になっているのでargs_setup_kw_parametersで必須キーワード引数とオプショナルキーワード引数それぞれの処理を書くことができるのです。

    // 必須キーワード引数が与えられているかチェックする
    for (i=0; i<req_key_num; i++) {
        ID key = acceptable_keywords[i];
        if (args_setup_kw_parameters_lookup(key, &locals[i], passed_keywords, passed_values, passed_keyword_len)) {
            found++;
        }
        else {
            if (!missing) missing = rb_ary_hidden_new(1);
            rb_ary_push(missing, ID2SYM(key));
        }
    }

    if (missing) argument_kw_error(ec, iseq, cme, "missing", missing);

    // オプショナルキーワード引数に関する処理をする
    // iは必須キーワード引数を処理した状態になっている
    for (di=0; i<key_num; i++, di++) {
        if (args_setup_kw_parameters_lookup(acceptable_keywords[i], &locals[i], passed_keywords, passed_values, passed_keyword_len)) {
            found++;
        }
        else {
            ...
        }
    }

おまけ4: ノードのlocalsはどうあるべきか

書き換え前の世界ではinternal idを生成したり、idの順番を入れ替えたりすることで、コンパイラが扱いやすい形でNODE_SCOPEnd_tblを管理してきました。 今回の書き換えではそれを踏襲してparserの中でinternal idを生成し、idの順番を調整しました。 しかしCRubyのコンパイラを離れてノードを考えるとき、ノードのもつローカル変数のテーブルがどうあるとよいかというのは一考の余地があるでしょう。 ここで異なるVM実装としてのmrubyでは... という話ができるとカッコよかったんですが、今回は時間切れです。

現時点での仮説だけ書いておくと、仮引数に関するテーブルと(仮引数を除いた)ローカル変数に関するテーブルは分けて取得できると便利なのではないかと思っています。

というのもDefNodeが与えられた時に、そこで使われているローカル変数の一覧を取得するためにはbodyの全てのノードを辿る必要があります。 このような再帰的な処理はどうせparse時に行うので、そのときに情報を集めておいて欲しいと思うことでしょう。 またローカル変数は呼び出しもととの調整がいらないので、その順番などに自由度あるように見えます。

一方で仮引数はというと、呼び出しもととの調整が必要なのでコンパイラの都合を強く受けます。 その代わりと言ってはなんですが、仮引数はParametersNodeのもとにフラットに構成されているようです(これは因果が逆転している気もしなくもないけど)。

ということは仮引数をlocal tableにマッピングする作業はParametersNode配下のノードをもとにコンパイラが行い、その結果にDefNodeのもつローカル変数のテーブルを結合する、というのがいまのところベストかなぁと考えています。

まとめ

今日の成果です。

  • キーワード引数とブロック引数に対応した

そういえば今回compile.cにも手を入れるにあたって「Rubyのしくみ Ruby Under a Microscope」をパラパラめくることがあるのですが、その本の最後に付録として笹田さんの寄稿した文章があります。 寄稿文の本当に最後に(その当時)開発中のRuby 2.2ではキーワード引数の実装をより効率的なものにするために、"コンパイル時にキーワード引数の名前をまとめておき、呼び出し側では値のみを渡すことにした" (P. 372)とあります。 この本を読んでいた10年前(!?)にはきっと、「へー、よくわからないけどすごい。でもまあ自分には関係ないし」と思っていたことでしょう。 10年後にまさか自分がそこを理解する日がくるとはとちょっと感慨深い気持ちになりました。


  1. &nilについてもRuby 4.1に入りそうな雰囲気です(Feature #19979)。
  2. ifdefでチェックしているFORWARD_ARGS_WITH_RUBY2_KEYWORDSがいまは定義されないようになっているためです。
  3. Rubyの世界ではIntegerに統一されて久しいですが、CRubyの世界ではFixnumとBignumがいます。
  4. Qundefはこういうときに使われる便利な"オブジェクト"です。Rubyスクリプトの世界ではQundefを作ることができないので、内部的に未定義であることを表現するときに使えます。Qnil(nil)を使ってしまうとk2: nilと区別がつかなくなるので、Qundefを使っているのでしょう。Qundefについて詳しくはRubyソースコード完全解説を参照してください。



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

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