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


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

11日目: attr assignment

早いものですでに11日目です。 ここらで一度進捗を確認してみましょう。

ざっと以下のような要素を実装する必要があります。 どうでしょう... 30%くらい実装できたんですかね。 思ったより大変な気がしてきました。

  • リテラル
    • nil
    • true
    • false
    • self
    • __LINE__
    • __FILE__
    • __ENCODING__
    • array
    • hash
    • string
    • integer
    • float
    • rational
    • imaginary
    • regex
    • range
    • interpolationを含むstring
    • interpolationを含むsymbol
    • interpolationを含むregex
    • lambda
  • 変数の参照と代入
  • 定義
    • メソッド定義
      • multiple assignment (def m((a, b)))が未対応
      • forwarding (...)が未対応
    • クラス定義 / モジュール定義 / シングルトンクラス定義
  • メソッド呼び出し
    • いわゆる通常のメソッド呼び出し
    • attr assignment (struct.field = foo)
    • array assignment with operator (ary[1] += foo)
    • attr assignment with operator (struct.field += foo)
    • assignment with && / || operator (foo &&= bar)
    • constant declaration with operator (A::B ||= 1)
  • 制御構文
    • if
    • unless
    • flipflop
    • case when
    • while / until
    • for
    • retry / rescue / ensure
    • && / || / and / or
    • yield
    • return
    • super
    • break / next / redo
    • BEGIN
    • END
  • パターンマッチング
  • その他
    • alias
    • undef
    • defined?

今日は引き続きメソッド呼び出し関連ということでattr assignmentに取り組みたいと思います。 余力があればaliasやundefあたりもさくっと実装して進捗を出したいところでもあります。

attr assignment

struct.field = fooの形をした代入をattr assignmentと呼んでいます。 これは実際にはfield=というメソッドをfooを引数として呼び出すことになります。

書き換え前後のノードを確認する

書き換え前のノードはNODE_ATTRASGNです。 細かくみるとattr assignmentには3つの異なる形式があります。

  • struct.field = foo
  • struct&.field = foo
  • a[i] = foo

最初の2つはattrset関数、最後の1つはaryset関数で生成しています。

static NODE *
attrset(struct parser_params *p, NODE *recv, ID atype, ID id, const YYLTYPE *loc)
{
    if (!CALL_Q_P(atype)) id = rb_id_attrset(id);
    return NEW_ATTRASGN(recv, id, 0, loc);
}

static NODE *
aryset(struct parser_params *p, NODE *recv, NODE *idx, const YYLTYPE *loc)
{
    aryset_check(p, idx);
    return NEW_ATTRASGN(recv, tASET, idx, loc);
}

attrsetではCALL_Q_P、つまり&.なのか.なのかで生成するメソッドのidを変更しています。 .の場合は:field=&.の場合は:fieldという形のidがNODE_ATTRASGNに設定されます。

arysetではaryset_checkという関数でa[args] = 1argsの部分をチェックしています。 aryset_checkの最後の方を読むと、この関数はキーワード引数やブロック引数が渡されてないことを確認するための関数であることがわかります。

static void
aryset_check(struct parser_params *p, NODE *args)
{
    NODE *block = 0, *kwds = 0;
    if (args && nd_type_p(args, NODE_BLOCK_PASS)) {
        block = RNODE_BLOCK_PASS(args)->nd_body;
        args = RNODE_BLOCK_PASS(args)->nd_head;
    }
    if (args && nd_type_p(args, NODE_ARGSCAT)) {
        args = RNODE_ARGSCAT(args)->nd_body;
    }
    if (args && nd_type_p(args, NODE_ARGSPUSH)) {
        kwds = RNODE_ARGSPUSH(args)->nd_body;
    }
    else {
        for (NODE *next = args; next && nd_type_p(next, NODE_LIST);
             next = RNODE_LIST(next)->nd_next) {
            kwds = RNODE_LIST(next)->nd_head;
        }
    }
    if (kwds && nd_type_p(kwds, NODE_HASH) && !RNODE_HASH(kwds)->nd_brace) {
        yyerror1(&kwds->nd_loc, "keyword arg given in index assignment");
    }
    if (block) {
        yyerror1(&block->nd_loc, "block arg given in index assignment");
    }
}

なぜarysetでは&.のチェックが不要で、attrsetではキーワード引数やブロック引数のチェックが不要なのでしょうか。 そのような組み合わせは文法定義上ありえないからです。

arysetは以下のような生成規則から呼び出されます。 ここで'['は終端記号であり&[のような形にはなりません。 なので&.のチェックは不要です。

lhs | primary_value '[' opt_call_args rbracket
        {
            $$ = aryset(p, $1, $3, &@$);
        /*% ripper: aref_field!($:1, $:3) %*/
        }

一方でattrsetは以下のような生成規則から呼び出されます。 今度は引数に当たる部分がありません。 なのでキーワード引数やブロック引数をそもそも渡すことができません。

lhs | primary_value tCOLON2 tIDENTIFIER
        {
            $$ = attrset(p, $1, idCOLON2, $3, &@$);
        /*% ripper: field!($:1, $:2, $:3) %*/
        }

ここまでを踏まえて書き換え前後のノードについてまとめておきます。

nd_mid name
struct.field = foo ATTRASGN :field= CallNode :field=
struct&.field = foo ATTRASGN :field CallNode :field=
a[i] = foo ATTRASGN :[]= CallNode :[]=

parse.yを変更する

まずNEW_ATTRASGNマクロに対応するNEW_RB_ATTRASGNマクロを定義します。 他のメソッド呼び出しと同様にNEW_RB_CALLのラッパーとして定義するのが良いでしょう。

+#define NEW_RB_ATTRASGN(r,m,a,loc) NEW_RB_CALL(r,m,a,0,loc)

aryset関数は型の調整だけなので割愛します。 attrset関数は&.かどうかに関係なく:field=形式のidを使用するようになるので、CALL_Q_Pによる分岐を削除します。

 static NODE *
-attrset(struct parser_params *p, NODE *recv, ID atype, ID id, const YYLTYPE *loc)
+attrset(struct parser_params *p, rb_node_t *recv, ID atype, ID id, const YYLTYPE *loc)
 {
-    if (!CALL_Q_P(atype)) id = rb_id_attrset(id);
-    return NEW_ATTRASGN(recv, id, 0, loc);
+    return NEW_ATTRASGN(recv, rb_id_attrset(id), 0, loc);
 }

aryset_checkは引数のrb_arguments_node_tが持っているリストをループするコードに書き換えればいいでしょう。

static void
aryset_check(struct parser_params *p, rb_arguments_node_t *args)
{
    rb_node_t *block = 0, *kwds = 0;
    rb_node_list2_t *list = &args->arguments;

    for (size_t i = 0; i < RB_NODE_LIST_LEN(list); i++) {
        const rb_node_t *node = list->nodes[i];

        switch (nd_type(node)) {
          case RB_KEYWORD_HASH_NODE:
            block = node;
            break;
          case RB_BLOCK_ARGUMENT_NODE:
            kwds = node;
            break;
          default:
            break;
        }
    }
    if (kwds) {
        yyerror1(&kwds->location, "keyword arg given in index assignment");
    }
    if (block) {
        yyerror1(&block->location, "block arg given in index assignment");
    }
}

minirubyをビルドして生成されるノードを確認します。

$ ./miniruby --parser=parse.y -e 'a[i] = v'
./miniruby: [BUG] unexpected node: RB_CALL_NODE

...
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(rb_bug+0x28) [0x102d7b70c] ../../error.c:1116
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(node_assign+0xdc) [0x102e88db8] parse.y:14843
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(ruby_yyparse+0x33e0) [0x102e7ced4] parse.y:3182
....

[BUG]で落ちました。

えーっと?

static rb_node_t *
node_assign(struct parser_params *p, rb_node_t *lhs, rb_node_t *rhs, struct lex_context ctxt, const YYLTYPE *loc)
{
    if (!lhs) return 0;

    switch (RB_NODE_TYPE(lhs)) {
      // case NODE_CDECL:
      case RB_GLOBAL_VARIABLE_WRITE_NODE:
      case NODE_LASGN:
      // case NODE_DASGN:
      // case NODE_MASGN:
      case RB_CLASS_VARIABLE_WRITE_NODE:
        set_nd_value(p, lhs, rhs);
        rb_nd_set_loc(lhs, loc);
        break;

      // case NODE_ATTRASGN:
      //   RNODE_ATTRASGN(lhs)->nd_args = arg_append(p, RNODE_ATTRASGN(lhs)->nd_args, rhs, loc);
      //   nd_set_loc(lhs, loc);
      //   break;

      default:
        rb_bug("unexpected node: %s", rb_node_type_to_str(RB_NODE_TYPE(lhs)));
        UNREACHABLE_RETURN(0);
    }

    return lhs;
}

代入式全体のparseが終わった時に右辺の値を左辺のCallNodeargumentsに追加する必要があるのでした。 例えばstruct.field = fooであればstruct.fieldまでをparseしたタイミングでCallNodeを生成しているので、= fooまでparseしたときにCallNodeargumentsfooを追加する必要があるということです。

こんな感じに書き換えればいいでしょう。

static rb_node_t *
node_assign(struct parser_params *p, rb_node_t *lhs, rb_node_t *rhs, struct lex_context ctxt, const YYLTYPE *loc)
{
      ...
      case RB_CALL_NODE:
        // TODO: Assert call node is NODE_ATTRASGN
        node_arg_append(p, RB_NODE_CALL(lhs)->arguments, rhs, loc);
        rb_nd_set_loc(lhs, loc);
        break;

      default:
        rb_bug("unexpected node: %s", rb_node_type_to_str(RB_NODE_TYPE(lhs)));
        UNREACHABLE_RETURN(0);
      ...
}

再度minirubyをビルドして、試してみます。

$ ./miniruby --parser=parse.y  -e 'a[i] = v'
-e:1:in '<main>': undefined local variable or method 'a' for main (NameError)

ん? 実行できてる? なんか動いてしまいましたね。

$ ./miniruby --parser=parse.y  -e 's.f = nil'
./miniruby: [BUG] Segmentation fault at 0x0000000000000020

...
/usr/lib/system/libsystem_platform.dylib(_sigtramp+0x38) [0x19722e584]
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(rb_node_list_append+0x20) [0x104277bbc] ../../parser_node_list.c:55
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(rb_node_list_append+0x20) [0x104277bbc] ../../parser_node_list.c:55
/Users/kaneko.y/source/ruby/ruby/build/dev/miniruby(node_arg_append+0x2c) [0x104274f30] parse.y:14693
...

うん?

あー。attrsetのときはrb_arguments_node_t *NULLで初期化しているのでした。

static rb_node_t *
attrset(struct parser_params *p, rb_node_t *recv, ID atype, ID id, const YYLTYPE *loc)
{
    return NEW_RB_ATTRASGN(recv, rb_id_attrset(id), 0, loc);
}

attrset関数を呼んだ場合、その後右辺の値がargumentsに追加されることは確定しています。 ということでattrset関数のなかで空のargumentsを生成しておくのでいいでしょう。

static rb_node_t *
attrset(struct parser_params *p, rb_node_t *recv, ID atype, ID id, const YYLTYPE *loc)
{
    return NEW_RB_ATTRASGN(recv, rb_id_attrset(id), NEW_RB_ARGUMENTS0(&NULL_LOC), loc);
}

再度minirubyをビルドして試しましょう。

$ ./miniruby --parser=parse.y -e 's.f = nil'
-e:1:in '<main>': undefined local variable or method 's' for main (NameError)

やっぱりなぜか実行までできる...

attr assignmentをコンパイルする

一応生成されるバイトコードをみてみましょう。

# Before
ruby --parser=parse.y --dump=i -e "h[:k]= :v"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,9)>
0000 putnil                                                           (   1)[Li]
0001 putself
0002 opt_send_without_block                 <calldata!mid:h, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0004 putobject                              :k
0006 putobject                              :v
0008 setn                                   3
0010 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0012 pop
0013 leave

# After
./miniruby --parser=parse.y --dump=i -e  "h[:k]= :v"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,9)>
0000 putself                                                          (   1)[Li]
0001 opt_send_without_block                 <calldata!mid:h, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putobject                              :k
0005 putobject                              :v
0007 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0009 leave

全然違いますね。うん、知ってた。

正しいバイトコードを眺めます。

0000 putnil                  (   1)[Li] # なにこれ?
0001 putself
0002 opt_send_without_block  <calldata!mid:h, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0004 putobject               :k
0006 putobject               :v
0008 setn                    3          # なにこれ?
0010 opt_aset                <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0012 pop                                # なにこれ?
0013 leave

スタックの気持ちになってみます。

# 0001 putself まで
self
nil

# 0002 opt_send_without_blockまで
h の戻り値
nil

# 0006 putobject :v まで
:v
:k
h の戻り値
nil

setnは初めてみる命令なので定義を見ておきます。 英語の説明がなんとなく間違っている気がしますが、これはstack topの値(val)をstackの上からn番目にコピーする命令のようです。 ここでstack topはn = 0で表します。

/* set Nth stack entry to stack top */
DEFINE_INSN
setn
(rb_num_t n)
(..., VALUE val)
(VALUE val)
// attr rb_snum_t sp_inc = 0;
{
    TOPN(n) = val;
}

スタックの操作を引き続き考えていきます。

# 0008 setn 3 まで
:v
:k
h の戻り値
:v

# 0010 opt_aset まで
[]= の戻り値
:v

# 0012 pop まで
:v

ということで=の右辺にある:vが最終的にスタックに残ります1。 これはもしかして... ということで簡単なスクリプトを書いて確認します。

class A
  def []=(*a)
    false
  end
end

a = A.new
p(a[0] = 1)
#=> 1

ということで、Ruby#m=というメソッドを呼び出した時に、メソッドの戻り値ではなく=の右辺が式の値になるということがわかりました2

compile.cを変更する

compile.cのもともとの実装をみるとNODE_ATTRASGNとそれ以外のメソッド呼び出しで関数が分かれていることがわかります。 それはそう... という感じがします。

case NODE_CALL:   /* obj.foo */
case NODE_OPCALL: /* foo[] */
  if (compile_call_precheck_freeze(iseq, ret, node, node, popped) == TRUE) {
      break;
  }
case NODE_QCALL: /* obj&.foo */
case NODE_FCALL: /* foo() */
case NODE_VCALL: /* foo (variable or call) */
  if (compile_call(iseq, ret, node, type, node, popped, false) == COMPILE_NG) {
      goto ng;
  }
  break;
...
case NODE_ATTRASGN:
  CHECK(compile_attrasgn(iseq, ret, node, popped));
  break;

attr assignmentの場合は文法上ブロックが渡せないことや、生成されるバイトコードCallNodeとは結構違うことを考え、処理する関数を分けるというアプローチを引き続き採用することにします。

さてattr assignmentのときのノードもCallNodeに統一したので、コンパイラでattr assignmentかどうか区別する方法が必要です。 CALL_NODE_FLAGS_ATTRIBUTE_WRITEというフラグがあるようなので、このフラグを立てるようにparse.yを修正します。 またs&.f= 1のときにCALL_NODE_FLAGS_SAFE_NAVIGATIONがたつようにもしておきます。

#define NEW_RB_ATTRASGN(r,m,a,fl,loc) NEW_RB_CALL(r,m,a,fl|RB_CALL_NODE_FLAGS_ATTRIBUTE_WRITE,loc)

static rb_node_t *
attrset(struct parser_params *p, rb_node_t *recv, ID atype, ID id, const YYLTYPE *loc)
{
    int flags = CALL_Q_P(atype) ? RB_CALL_NODE_FLAGS_SAFE_NAVIGATION : 0;
    return NEW_RB_ATTRASGN(recv, rb_id_attrset(id), NEW_RB_ARGUMENTS0(&NULL_LOC), flags, loc);
}

compile.cはCallNodeを処理する先頭で分岐すればよいでしょう。 compile_attrasgnの書き換えはいつものように構造体が変化したことによるマクロの書き換えが主なので割愛します。

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

正しくコンパイルできるようになりました。

$ ./miniruby --parser=parse.y --dump=i -e  "a[:k] = :v"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
0000 putnil                                                           (   1)[Li]
0001 putself
0002 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0004 putobject                              :k
0006 putobject                              :v
0008 setn                                   3
0010 opt_aset                               <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0012 pop
0013 leave

おまけ: いままでどうやってa.b = 1a&.b = 1を区別していたか

通常のメソッド呼び出しではQCALLという専用のノードでa&.bというメソッド呼び出しを表現していました。 しかしa&.b = 1用のノードはなく、a.b = 1a&.b = 1NODE_ATTRASGNというノードを用いていました。 ではどのようにしてこの2つを判別していたかというと、ノードに設定されたメソッド名をみて判断していました。

a.b = 1のときはnd_mid: :b==がついているのに対して、c&.d = 2のときはnd_mid: :d=がついていません。

$ ruby --parser=parse.y --dump=p -e  "a.b = 1; c&.d = 2"

#     |   @ NODE_ATTRASGN (id: 1, line: 1, location: (1,0)-(1,7))*
#     |   +- nd_recv:
#     |   |   @ NODE_VCALL (id: 0, line: 1, location: (1,0)-(1,1))
#     |   |   +- nd_mid: :a
#     |   +- nd_mid: :b=
#     +- nd_head (2):
#         @ NODE_ATTRASGN (id: 5, line: 1, location: (1,9)-(1,17))*
#         +- nd_recv:
#         |   @ NODE_VCALL (id: 4, line: 1, location: (1,9)-(1,10))
#         |   +- nd_mid: :c
#         +- nd_mid: :d

これはNODE_ATTRASGNをつくるときに&.かどうかでrb_id_attrsetを呼び出すかを変えることで実現しています。

// parse.y
static NODE *
attrset(struct parser_params *p, NODE *recv, ID atype, ID id, const YYLTYPE *loc)
{
    if (!CALL_Q_P(atype)) id = rb_id_attrset(id);
    return NEW_ATTRASGN(recv, id, 0, loc);
}

compile.cではrb_is_attrset_idという関数を使ってmidをチェックして&.かどうかの判断をし、rb_id_attrsetを呼び出してmid=を付与したidを取得してバイトコードに埋め込むようなっています。

static int
compile_attrasgn(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, int popped)
{
    ...
    if (!rb_is_attrset_id(mid)) {
        /* safe nav attr */
        mid = rb_id_attrset(mid);
        else_label = qcall_branch_start(iseq, recv, &branches, node, node);
    }
    ...
    return COMPILE_OK;
}

まとめ

今日の成果です。

  • a.b = 1a[0] = 1といったattr assignmentと呼ばれる文法の対応をした

余裕はなったのでaliasやundefなどはまた今度ということで。


  1. 0000 putnilという最初の命令はsetnで書き込む領域をスタックに確保するための命令だということがわかります。
  2. もしかするとこの挙動は常識なのかもしれませんが、筆者は今回バイトコードを眺めて初めて知りました。



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

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