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


Ruby Parser開発日誌 (24-10) - parse.yが生成するノードを変える ー ローカル変数

10日目: ローカル変数を扱う

ここまででメソッド呼び出しがおおよそできたので、今回はローカル変数に対応してブロックの仮引数やitといった特殊な変数にアクセスできるようにしていきましょう。

変数は2日目にも取り上げましたが、そのときは簡単に対応できるインスタンス変数やクラス変数に限定して実装したのでした。

yui-knk.hatenablog.com

ローカル変数の大変さはブロックの内側と外側で見えるものが異なる点にあるのでした。

sum = 0

10.times do |i|
  j = i
  sum += j
end

ローカル変数のスコープ

Rubyでは以下の4つの定義では新しいスコープが作られます。

  • メソッド定義
  • クラス定義
  • シングルトンクラス定義
  • module定義
i = 0

class C
  defined?(i) ? p(i) : p(:undefined) #=> :undefined
  i = 1

  def m
    defined?(i) ? p(i) : p(:undefined) #=> :undefined
    i = 2
    p i #=> 2
  end

  p i #=> 1
end

p i #=> 0

o = C.new
o.m

一方でブロックは外側のローカル変数にアクセスすることができます。

i = 0

1.tap do
  i = 1
  j = 2
  p i #=> 1
end

p i #=> 1
defined?(j) ? p(j) : p(:undefined) #=> :undefined

スコープの挙動について確認したので、parse.yとcompile.cの既存の実装がどうなっているか確認することにしましょう。

struct local_varsstruct vtable - parse.yにおけるローカル変数の管理

これらの異なる2つのスコープを管理するためにparse.yではstruct local_varsstruct vtableという2つの構造体を使います。 class定義やメソッド定義によって生じるスコープを管理するのがstruct local_varsで、ブロックによって生じるスコープを管理するのがstruct vtableです。

// ブロックによるスコープ
struct vtable {
    ID *tbl;
    int pos;
    int capa;
    struct vtable *prev;
};

// classやメソッド定義などによるスコープ
struct local_vars {
    struct vtable *args;
    struct vtable *vars;
    struct vtable *used;
# if WARN_PAST_SCOPE
    struct vtable *past;
# endif
    struct local_vars *prev;
    struct {
        NODE *outer, *inner, *current;
    } numparam;
    NODE *it;
};

struct vtable *prev;struct local_vars *prev;というフィールドからも明らかなように、これらの構造体はどちらも1つ外側のスコープに対するポインタをもつリスト構造になっています。

i = 0

class C
  def m 
    j = 0
    1.times do |k|
      l = k
    end
  end
end

というスクリプトlocal_varsvtableを可視化すると以下のようになります。

地の部分(main)、class定義、メソッド定義のそれぞれに対してlocal_varsが割り当てられます。 またそれぞれにvtableが与えられます。

メソッド定義の中にはブロックが1つあります。 このブロックに対してはメソッド定義とは独立したvtableが与えられます。 ブロックの引数(k)やブロック内部で定義されているローカル変数(l)は、ブロックに対して割り当てられたvtableで管理します。 このようにvtableを割り当てることで、klをメソッドmとは独立したスコープで管理することができるのです。

以下のコードではjは変数アクセスですが、nはメソッド呼び出しです。 このようにブロックの内側をparseするときは、そのブロックの外側で定義されているローカル変数の一覧についても知る必要があります。

def m 
  j = 0

  1.times do |k|
    l = 1
    j
    k
    l
    n
  end
end

ブロックのvtableはその外側にあるvtableへの参照を持っているので、ブロックの外側に定義されているローカル変数についても知ることができるのです。

ちなみにメソッド定義のlocal_varsはクラス定義のlocal_varsへの参照を持ちますが、ローカル変数の探索はvtableのなかだけで行われるので、メソッド定義とクラス定義ではローカル変数が完全に分離されています。

class C
  i = 0

  def m
    j = 1
    10.times do
      i # これは変数ではなく、メソッド呼び出しになる
    end
  end
end

LVARDVARとはなんなのか

parse.yにおいてはブロック内部で定義・参照しているローカル変数をDVAR、そうでないローカル変数をLVARとして、それぞれ別のノードを生成します。

def m 
  j = 0 # LASGN
  j # LVAR

  1.times do |k|
    l = 1 # DASGN
    j # DVAR
    k # DVAR
    l # DVAR
    n # DVAR
  end
end

このコードではブロックの内外で参照しているjは変化しませんが、ブロックの外側ではLVARノードが、ブロックの内側ではDVARノードが生成されます。 これらのノードはコンパイルするときの探索の仕方が異なります。

local_iseqparent_iseq - compile.cにおけるローカル変数の管理

compile.cやISeqはlocal_iseqparent_iseqという2つの異なるフィールドを使って他のISeqへの参照を管理しています。 これらのフィールドの値がどのようなものになるかはISeqの種類によります。 ちなみにISeqの種類は以下のように9種類あります1

enum rb_iseq_type {
    ISEQ_TYPE_TOP,
    ISEQ_TYPE_METHOD,
    ISEQ_TYPE_BLOCK,
    ISEQ_TYPE_CLASS,
    ISEQ_TYPE_RESCUE,
    ISEQ_TYPE_ENSURE,
    ISEQ_TYPE_EVAL,
    ISEQ_TYPE_MAIN,
    ISEQ_TYPE_PLAIN
};

新しくISeqをつくるときに呼ばれるset_relation関数を眺めてみましょう。

static void
set_relation(rb_iseq_t *iseq, const rb_iseq_t *piseq)
{
    struct rb_iseq_constant_body *const body = ISEQ_BODY(iseq);
    const VALUE type = body->type;

    /* set class nest stack */
    if (type == ISEQ_TYPE_TOP) {
        body->local_iseq = iseq;
    }
    else if (type == ISEQ_TYPE_METHOD || type == ISEQ_TYPE_CLASS) {
        body->local_iseq = iseq;
    }
    else if (piseq) {
        RB_OBJ_WRITE(iseq, &body->local_iseq, ISEQ_BODY(piseq)->local_iseq);
    }

    if (piseq) {
        RB_OBJ_WRITE(iseq, &body->parent_iseq, piseq);
    }

    if (type == ISEQ_TYPE_MAIN) {
        body->local_iseq = iseq;
    }
}

ISeqは種類も多くまだ全てを把握できてないですが、おおよそこういうことだろうというのは想像できます。

  • parent_iseqはISeqの生成時にparentが指定された場合に設定される。たとえばMETHODはparentが指定されないがBLOCKはparentが指定される2
  • local_iseqはMETHOD, CLASS, TOP, MAINの場合には自分自身を、それ以外のISeqではあればparent_iseqlocal_iseqを引き継ぐ

メソッド定義の中にブロックが2つあるケースでは、それらのISeqは外側から順番にMETHOD, BLOCK, BLOCKとなります。

parent_iseqに関していえば、1つ目のBLOCKMETHODを、2つ目のBLOCKは1つ目のBLOCKを参照しています。

local_iseqに関していえばMETHOD, BLOCK, BLOCKのいずれもMETHODを参照しています。

ノードの種類とコンパイル

ローカル変数の代入・参照はsetlocalgetlocalという命令にコンパイルされます。 これらの命令は参照するブロックの深さ(level)とlocal tableにおける変数の位置(idx)の2つをオペランドとしてとるのでした。

DVARにせよLVARにせよ、命令としてはsetlocalgetlocalコンパイルされます。 ということはlevelidxをどのように計算するかがポイントになります。

DVARコンパイル

まずはDVARコンパイルをみてみましょう。

iseq_compile_each0をみると、DVARの処理はすべてget_dyna_var_idxが行っていることがわかります3

case NODE_DVAR:{
  int lv, idx, ls;
  debugi("nd_vid", RNODE_DVAR(node)->nd_vid);
  if (!popped) {
      idx = get_dyna_var_idx(iseq, RNODE_DVAR(node)->nd_vid, &lv, &ls);
      if (idx < 0) {
          COMPILE_ERROR(ERROR_ARGS "unknown dvar (%"PRIsVALUE")",
                        rb_id2str(RNODE_DVAR(node)->nd_vid));
          goto ng;
      }
      ADD_GETLOCAL(ret, node, ls - idx, lv);
  }
  break;
}

get_dyna_var_idxをみてみると、get_dyna_var_idx_at_rawという関数がローカル変数を見つけるまで、parent_iseqを1つずつ遡っていることがわかります。 遡るたびにlv++することでlevelの計算をしています。

static int
get_dyna_var_idx(const rb_iseq_t *iseq, ID id, int *level, int *ls)
{
    int lv = 0, idx = -1;
    const rb_iseq_t *const topmost_iseq = iseq;

    while (iseq) {
        idx = get_dyna_var_idx_at_raw(iseq, id);
        if (idx >= 0) {
            break;
        }
        iseq = ISEQ_BODY(iseq)->parent_iseq;
        lv++;
    }

    if (idx < 0) {
        COMPILE_ERROR(topmost_iseq, ISEQ_LAST_LINE(topmost_iseq),
                      "get_dyna_var_idx: -1");
    }

    *level = lv;
    *ls = ISEQ_BODY(iseq)->local_table_size;
    return idx;
}

となるとget_dyna_var_idx_at_rawが引数として渡されたiseqにおいて、ローカル変数(id)が存在するかどうかをチェックする関数であると当たりがつきます。

static int
get_dyna_var_idx_at_raw(const rb_iseq_t *iseq, ID id)
{
    unsigned int i;

    for (i = 0; i < ISEQ_BODY(iseq)->local_table_size; i++) {
        if (ISEQ_BODY(iseq)->local_table[i] == id) {
            return (int)i;
        }
    }
    return -1;
}

まさしくそのとおりで、get_dyna_var_idx_at_rawは与えられたiseqlocal_tableを1つずつ調べて、引数idと一致するものがないか確認しています。

ブロックを内側から外側に辿っていくさいに、parent_iseqが利用されていることがわかりました。

LVARコンパイル

つぎにLVARコンパイルです。 ブロックの内側でのローカル変数へのアクセスがDVARであったのに対して、LVARはそれ以外の場所におけるローカル変数へのアクセスを表しているのでした。

そのコンパイル処理はcompile_lvarという関数が行なっています。

case NODE_LVAR:{
  if (!popped) {
      compile_lvar(iseq, ret, node, RNODE_LVAR(node)->nd_vid);
  }
  break;
}

compile_lvarではget_local_var_idxでindexの解決を、get_lvar_levelでlevelの解決をします。

static void
compile_lvar(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *line_node, ID id)
{
    int idx = ISEQ_BODY(ISEQ_BODY(iseq)->local_iseq)->local_table_size - get_local_var_idx(iseq, id);

    debugs("id: %s idx: %d\n", rb_id2name(id), idx);
    ADD_GETLOCAL(ret, line_node, idx, get_lvar_level(iseq));
}

index解決につかわれるget_local_var_idxlocal_iseqを決め打ちしてget_dyna_var_idx_at_rawを呼び出します。 LVARにおいてはアクセスするローカル変数がブロックのなかにいないことがparserレベルで保証されています(いるはずです)。 なのでlocal_iseqに限定してlocal tableを探索すればよいという実装になっています。

static int
get_local_var_idx(const rb_iseq_t *iseq, ID id)
{
    int idx = get_dyna_var_idx_at_raw(ISEQ_BODY(iseq)->local_iseq, id);

    if (idx < 0) {
        COMPILE_ERROR(iseq, ISEQ_LAST_LINE(iseq),
                      "get_local_var_idx: %d", idx);
    }

    return idx;
}

levelを計算するget_lvar_level関数はparent_iseqlocal_iseqが一致するまでの距離を計算します。 引数iseqがMETHODやCLASSであればwhileループが回ることはないので0になりますし、METHOD直下のBLOCKであればループが一回だけ回って1が返ります。

static int
get_lvar_level(const rb_iseq_t *iseq)
{
    int lev = 0;
    while (iseq != ISEQ_BODY(iseq)->local_iseq) {
        lev++;
        iseq = ISEQ_BODY(iseq)->parent_iseq;
    }
    return lev;
}

parse.yを書き換える

既存の実装を理解したのでノードを書き換えていきます。 書き換え前はローカル変数の参照はNODE_LVAR, NODE_DVAR、ローカル変数の代入はNODE_LASGN, NODE_DASGNと合計で4種類のノードを使用していました。 書き換え後はローカル変数の参照はLocalVariableReadNode、代入はLocalVariableWriteNodeになります。

ざっとみた感じDVARの処理はLVARを包括しているようなので、基本戦略はDVAR相当に処理を寄せることになります。

ノードは統合するにせよ、開発中はもともとLVARだったのかDVARだったのかがぱっと見で分かるようにしておくと何かあったときに調査が簡単になりそうです。

そこでparse.yではNEW_LVARNODE_LVARといった書き方は可能な限り残すようにしておきます4

+#define NEW_LVAR(v,loc) NEW_RB_LOCAL_VARIABLE_READ(v,loc)
+#define NEW_DVAR(v,loc) NEW_RB_LOCAL_VARIABLE_READ(v,loc)

+#define NODE_LVAR RB_LOCAL_VARIABLE_READ_NODE
+#define NODE_DVAR RB_LOCAL_VARIABLE_READ_NODE
+#define NODE_LASGN RB_LOCAL_VARIABLE_WRITE_NODE
+#define NODE_DASGN RB_LOCAL_VARIABLE_WRITE_NODE

例えば変数の種類に応じて生成するノードを変えるgettable関数は次のようになります。

    switch (id_type(id)) {
      case ID_LOCAL:
        if (dyna_in_block(p) && dvar_defined_ref(p, id, &vidp)) {
            if (NUMPARAM_ID_P(id) && (numparam_nested_p(p) || it_used_p(p))) return 0;
            if (vidp) *vidp |= LVAR_USED;
            node = NEW_DVAR(id, loc);
            return node;
        }
        if (local_id_ref(p, id, &vidp)) {
            if (vidp) *vidp |= LVAR_USED;
            node = NEW_LVAR(id, loc);
            return node;
        }

switch ... caseで2つのノードが並ぶとコンパイルエラーになるので、そのような場合はDVARの処理に寄せておきます。

static void
mark_lvar_used(struct parser_params *p, NODE *rhs)
{
    ID *vidp = NULL;
    if (!rhs) return;
    switch (nd_type(rhs)) {
      // case NODE_LASGN:
      //   if (local_id_ref(p, RNODE_LASGN(rhs)->nd_vid, &vidp)) {
      //       if (vidp) *vidp |= LVAR_USED;
      //   }
      //   break;
      case NODE_DASGN:
        if (dvar_defined_ref(p, RNODE_DASGN(rhs)->nd_vid, &vidp)) {
            if (vidp) *vidp |= LVAR_USED;
        }
        break;
    }
}

compile.cを書き換える

compile.cでもparse.y同様にDVARおよびDASGNの処理に寄せます。 DVARにせよDASGNにせよ、その処理の中心はget_dyna_var_idx関数によるindexとlevelの解決です。

get_dyna_var_idx関数はparent_iseqが取得できる限りループを回すので、BLOCKの外側にいるMETHODまでを探索に含めます。 またいきなりMETHODのISeqをiseq引数として渡した場合にも、そのiseqを探索してくれます。 なのでループの回数が若干増える可能性はありますが、DVARおよびDASGNの処理に寄せれば、LVAR相当のノードがきてもうまく動くはずです5

static int
get_dyna_var_idx(const rb_iseq_t *iseq, ID id, int *level, int *ls)
{
    int lv = 0, idx = -1;
    const rb_iseq_t *const topmost_iseq = iseq;

    while (iseq) {
        idx = get_dyna_var_idx_at_raw(iseq, id);
        if (idx >= 0) {
            break;
        }
        iseq = ISEQ_BODY(iseq)->parent_iseq;
        lv++;
    }

書き換え前後のコードを比較すると、構造体の変更に伴うマクロやフィールド名の変更のみであることがわかるでしょう。

// Before
case NODE_DVAR:{
  int lv, idx, ls;
  debugi("nd_vid", RNODE_DVAR(node)->nd_vid);
  if (!popped) {
      idx = get_dyna_var_idx(iseq, RNODE_DVAR(node)->nd_vid, &lv, &ls);
      if (idx < 0) {
          COMPILE_ERROR(ERROR_ARGS "unknown dvar (%"PRIsVALUE")",
                        rb_id2str(RNODE_DVAR(node)->nd_vid));
          goto ng;
      }
      ADD_GETLOCAL(ret, node, ls - idx, lv);
  }
  break;
}

// After
case RB_LOCAL_VARIABLE_READ_NODE: { // LVAR and DVAR
  int lv, idx, ls;
  rb_local_variable_read_node_t *cast = RB_NODE_LOCAL_VARIABLE_READ(node);
  debugi("nd_vid", cast->name);
  if (!popped) {
      idx = get_dyna_var_idx(iseq, cast->name, &lv, &ls);
      if (idx < 0) {
          COMPILE_ERROR(ERROR_ARGS "unknown dvar (%"PRIsVALUE")",
                        rb_id2str(cast->name));
          goto ng;
      }
      ADD_GETLOCAL(ret, node, ls - idx, lv);
  }
  break;
}

ここまでできたらRubyスクリプトを実行してみます。

# == disasm: #<ISeq:<main>@../../test2.rb:1 (1,0)-(5,3)>
# local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 1] l@0
# 0000 putobject                              false                     (   1)[Li]
# 0002 setlocal_WC_0                          l@0
# 0004 putobject                              true                      (   2)[Li]
# 0006 send                                   <calldata!mid:tap, argc:0>, block in <main>
# 0009 leave
# 
# == disasm: #<ISeq:block in <main>@../../test2.rb:2 (2,11)-(4,5)>
# local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 1] t@0<AmbiguousArg>
# 0000 putself                                                          (   3)[LiBc]
# 0001 getlocal_WC_1                          l@0
# 0003 opt_send_without_block                 <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
# 0005 pop
# 0006 putself                                                          (   4)[Li]
# 0007 getlocal_WC_0                          t@0
# 0009 opt_send_without_block                 <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
# 0011 leave                                  [Br]

l = false
true.tap do |t|
  p l #=> false
  p t #=> true
end

ブロックの外側でlにアクセスするときはsetlocal_WC_0(_0はlevelが0という意味)を使い、ブロックの内側でlにアクセスするときはgetlocal_WC_1を使うようになっています。 またブロックの内側でtにアクセスするときはgetlocal_WC_0を使うようになっているので、正しく実装できていると思います。

itをサポートする

itに対応するノードをItLocalVariableReadNodeへ分離したので、コンパイラも対応する必要があります。

$ ./miniruby --parser=parse.y -e 'true.tap do it end'
-e: -e:1: iseq_compile_each: unknown node (RB_IT_LOCAL_VARIABLE_READ_NODE) (SyntaxError)

さてitは常に最も近いブロックの第一引数を参照します。

false.tap do
  p it # => false

  true.tap do
    p it # => true
  end
end

またブロックの引数を明示しているときにitを使うとsyntax errorになります。

#=> ordinary parameter is defined (SyntaxError)
false.tap do |i|
  p it
end

#=> ordinary parameter is defined (SyntaxError)
false.tap do ||
  p it
end

以上を踏まえて常にgetlocal 1, 0コンパイルすればよいでしょう。 念のため現在のiseqのlocal tableのサイズが1であることを確認するようにしています。

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

+      case RB_IT_LOCAL_VARIABLE_READ_NODE: {
+        if (ISEQ_BODY(iseq)->local_table_size != 1) {
+            COMPILE_ERROR(ERROR_ARGS "local_table_size is %d",
+                          ISEQ_BODY(iseq)->local_table_size);
+            goto ng;
+        }
+
+        if (!popped) {
+            ADD_GETLOCAL(ret, node, 1, 0);
+        }
+        break;
+      }
       case RB_LOCAL_VARIABLE_READ_NODE: { // LVAR and DVAR
         int lv, idx, ls;

うまく動いているようです。

$ ./miniruby --parser=parse.y -e 'true.tap do p it end'
true

おまけ: struct local_varsstruct vtableについてもう少し詳しく調べる

parse.yでローカル変数を管理するのに使っている2つの構造体、struct local_varsstruct vtableについてもう少しだけ見ておきます。

まずはlocal_varsから。

struct local_vars {
    struct vtable *args;
    struct vtable *vars;
    struct vtable *used;
# if WARN_PAST_SCOPE
    struct vtable *past;
# endif
    struct local_vars *prev;
    struct {
        NODE *outer, *inner, *current;
    } numparam;
    NODE *it;
};

構造体をみると、args, vars, usedと3つのstruct vtableが存在しています。 これらはそれぞれ、引数の管理、ローカル変数の管理、ローカル変数を使用したかどうかの管理に使われます。

argsvarsは例えば未使用のローカル変数に関して警告を出す時にそれらを区別する必要があるためです。 引数については未使用であったとしても警告を出しません。 そのためにはこれらを分けて管理する必要があります。

local_tbl関数を呼ぶことでargsvarsがmergeされたidの配列を取得することができます。

static rb_ast_id_table_t *
local_tbl(struct parser_params *p)
{
    int cnt_args = vtable_size(p->lvtbl->args);
    int cnt_vars = vtable_size(p->lvtbl->vars);
    int cnt = cnt_args + cnt_vars;
    int i, j;
    rb_ast_id_table_t *tbl;

    if (cnt <= 0) return 0;
    tbl = rb_ast_new_local_table(p->ast, cnt);
    MEMCPY(tbl->ids, p->lvtbl->args->tbl, ID, cnt_args);
    /* remove IDs duplicated to warn shadowing */
    for (i = 0, j = cnt_args; i < cnt_vars; ++i) {
        ID id = p->lvtbl->vars->tbl[i];
        if (!vtable_included(p->lvtbl->args, id)) {
            tbl->ids[j++] = id;
        }
    }
    if (j < cnt) {
        tbl = rb_ast_resize_latest_local_table(p->ast, j);
    }

    return tbl;
}

ISeqは引数とローカル変数をまとめてlocal tableで管理するため、parserでもblockやメソッド定義などのノードをつくるときにはlocal_tbl関数を呼んでidの配列をつくってノードに設定します。

struct vtableで面白いのは各struct local_varsstruct vtable *varsのrootでしょう。

#define DVARS_INHERIT ((void*)1)
#define DVARS_TOPSCOPE NULL
#define DVARS_TERMINAL_P(tbl) ((tbl) == DVARS_INHERIT || (tbl) == DVARS_TOPSCOPE)

struct vtable {
    ID *tbl;
    int pos;
    int capa;
    struct vtable *prev;
};

static void
local_push(struct parser_params *p, int toplevel_scope)
{
    struct local_vars *local;
    int inherits_dvars = toplevel_scope && compile_for_eval;
    int warn_unused_vars = RTEST(ruby_verbose);

    local = ALLOC(struct local_vars);
    local->prev = p->lvtbl;
    local->args = vtable_alloc(0);
    local->vars = vtable_alloc(inherits_dvars ? DVARS_INHERIT : DVARS_TOPSCOPE);
    ...
    local->used = warn_unused_vars ? vtable_alloc(0) : 0;
    ...
}

struct vtableは自身の1つ前のstruct vtableに対する参照をもつリスト構造をしています。 通常リスト構造をつくるとき、先頭(もしくは末尾)がもつ参照は0になっていることが多いと思います。

struct vtableargsusedの場合はstruct vtable *prev;0で初期化されます。 しかしvarsだけは特別でDVARS_TOPSCOPE (0)もしくはDVARS_INHERIT (1)のいずれかで初期化されます。

これは#evalなどでparse対象の文字列の周りにある変数を管理するために必要な分類です。

i = 0
3.times do |j|
  eval("p [i, j]")
end
#=> [0, 0]
#=> [0, 1]
#=> [0, 2]

"p [i, j]"をevalするとき、ijがローカル変数として定義されているかどうかをparse時に知る必要があります。 つまりparserは一番外側のスコープをparseするときにさらに外側の変数を調べる必要があるかどうかを知っていないといけません。 この区分を管理するための方法がDVARS_TOPSCOPEDVARS_INHERITなのです。

例えばdvar_defined_refという関数はevalのときは指定されたidを文字列の外側まで探索しにいきます。 外側というのはp->parent_iseqであり、つまりevalのさいにはiseqが必要になっています。

int
dvar_defined_ref(struct parser_params *p, ID id, ID **vidrefp)
{
    struct vtable *vars, *args, *used;
    int i;

    args = p->lvtbl->args;
    vars = p->lvtbl->vars;
    used = p->lvtbl->used;

    while (!DVARS_TERMINAL_P(vars)) {
        if (vtable_included(args, id)) {
            return 1;
        }
        if ((i = vtable_included(vars, id)) != 0) {
            if (used && vidrefp) *vidrefp = &used->tbl[i-1];
            return 1;
        }
        args = args->prev;
        vars = vars->prev;
        if (!vidrefp) used = 0;
        if (used) used = used->prev;
    }

    if (vars == DVARS_INHERIT && !NUMPARAM_ID_P(id)) {
        return rb_dvar_defined(id, p->parent_iseq);
    }

    return 0;
}

おまけ2: LVARの挙動は必要ないのか

いままでLVARDVARに分かれていたローカル変数をDVARに寄せる形で一つに統合しました。 ではLVARに相当するノード、およびそこから生成されるバイトコードは必要ないのでしょうか。

compile_lvarの処理を思い出すと、LVARコンパイル時に常にlocal_iseqを見に行くのでした。 これは端的にいえば、ブロックを無視して外側の変数を指定することを意味します。

パッと思いつく範囲でRubyにそのような振る舞いをする変数はないと思うので、おそらく統一して大丈夫だと思います。

おまけ3: argument stack underflowデバッグする

さきほど試したスクリプトの最後にlの値を確認するコードを追加して実行してみます。

l = false
true.tap do |t|
  p l
  p t
end

p l # 追加した行

するとargument stack underflow (-1)というエラーが発生します。 0010 popという命令が何か悪さをしているようです。

-- raw disasm--------
   trace: 1
   0000 putobject            false                                       (   1)
   0002 setlocal_WC_0        3                                           (   1)
   trace: 1
 <L000> [sp: 0, unremovable: 1, refcnt: 1]
   0004 putobject            true                                        (   2)
   0006 send                 <calldata:tap, 0>, nil                      (   2)
 <L001> [sp: 1, unremovable: 0, refcnt: 2]
   0009 pop                                                              (   2)
*  0010 pop                                                              (   2)
   trace: 1
   0011 putself                                                          (   7)
   0012 getlocal_WC_0        3                                           (   7)
   0014 opt_send_without_block <calldata:p, 1>                           (   7)
   0016 leave                                                            (   7)
---------------------
../../test2.rb:2: argument stack underflow (-1)
../../test2.rb: compile error (SyntaxError)

正しいバイトコードと比較してみると、たしかに0010 popが余分にみえます。

$ ruby --parser=parse.y --dump=i test2.rb
== disasm: #<ISeq:<main>@test2.rb:1 (1,0)-(7,4)>
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] l@0
0000 putobject                              false                     (   1)[Li]
0002 setlocal_WC_0                          l@0
0004 putobject                              true                      (   2)[Li]
0006 send                                   <calldata!mid:tap, argc:0>, block in <main>
0009 pop
0010 putself                                                          (   7)[Li]
0011 getlocal_WC_0                          l@0
0013 opt_send_without_block                 <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
0015 leave

...

argument stack underflowというメッセージとpopが多いということから、スタックの底をさらにpopしようとしたのでしょう。 おそらくスタックは以下のように遷移しているはずです。

# putobject false
false

# setlocal_WC_0
(empty)

# putobject true
true

# send :tap
true

# pop
(empty)

# pop!
ERROR!

これをデバッグするわけですが、まず0010 popがどのノードから生成されているかを知りたいところです。 compile.cをざっと眺めた感じ、CPDEBUGというマクロがdebug modeかどうかを切り替えているようだとわかります。

/**
 * debug function(macro) interface depend on CPDEBUG
 * if it is less than 0, runtime option is in effect.
 *
 * debug level:
 *  0: no debug output
 *  1: show node type
 *  2: show node important parameters
 *  ...
 *  5: show other parameters
 * 10: show every AST array
 */

#ifndef CPDEBUG
#define CPDEBUG 0
#endif

一度CPDEBUG 10にしてみたのですが、どこでpopが入ったのかちょっとすぐにはわかりません。 0010 pop ( 2)という行番号からcompile_call周りが怪しいと思うので、CallNodeコンパイル直後の命令列をdumpするようにします。 見よう見まねでdump_disasm_listという関数を呼ぶようにします。

diff --git a/compile.c b/compile.c
index 001a0b6432..3f40f9d95e 100644
--- a/compile.c
+++ b/compile.c
@@ -10078,6 +10078,7 @@ compile_call_core(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const nod
     if (popped) {
         ADD_INSN(ret, line_node, pop);
     }
+dump_disasm_list(FIRST_ELEMENT(ret));
     return COMPILE_OK;
 }

レッツ実行。 メソッド呼び出しは何ヶ所かあるので、目的の#tapメソッドを探します。

$ ./miniruby --parser=parse.y ../../test2.rb
# これは知りたいやつとは違う
-- raw disasm--------
  trace: 100
  0000 nop                                                              (   2)
<L000> [sp: -1, unremovable: 0, refcnt: 0]
  trace: 1
  0001 putself                                                          (   3)
  0002 getlocal             3, 1                                        (   3)
  0005 send                 <calldata:p, 1>, nil                        (   3)
  0008 pop                                                              (   3)
---------------------
...
-- raw disasm--------
  trace: 1
  0000 putobject            false                                       (   1)
  0002 setlocal             3, 0                                        (   1)
  trace: 1
<L000> [sp: -1, unremovable: 0, refcnt: 0]
  0005 putobject            true                                        (   2)
  0007 send                 <calldata:tap, 0>, nil                      (   2)
  0010 pop                                                              (   2)
---------------------

するとCallNodeコンパイルが終わった時点ではpopが1つ追加されていることがわかります。 ということはcompile_call_coreから戻ったあとに、どこかでさらにpopが追加されているようです。

今回はブロック付きのメソッド呼び出しなのでcompile_iter0関数を見てみます。

static int
compile_iter0(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, const enum node_type type, int popped)
{
    switch (type) {
      case RB_CALL_NODE: {
        EXPECT_NODE("compile_iter0", RB_NODE_CALL(node)->block, RB_BLOCK_NODE, COMPILE_NG);
        ISEQ_COMPILE_DATA(iseq)->current_block = child_iseq =
            NEW_CHILD_ISEQ(RB_NODE_CALL(node)->block, make_name_for_block(iseq),
                           ISEQ_TYPE_BLOCK, line);
        CHECK(compile_call(iseq, ret, node, type, popped));
        // この時点では`0010 pop`が末尾にある
      }
    }

    {
      ...
    }

    if (popped) {
        ADD_INSN(ret, line_node, pop); // !?
    }

    ...
    return COMPILE_OK;
}

いや、なんか最後にpopを足している箇所がありますね...

もともとはどうしていたか確認すると、COMPILEというマクロでメソッド呼び出し部分をコンパイルしていました。 このCOMPILEというのはpopped = falseコンパイルするマクロです。

static int
compile_iter(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, int popped)
{
    if (nd_type_p(node, NODE_FOR)) {
        CHECK(COMPILE(ret, "iter caller (for)", RNODE_FOR(node)->nd_iter));

        ISEQ_COMPILE_DATA(iseq)->current_block = child_iseq =
            NEW_CHILD_ISEQ(RNODE_FOR(node)->nd_body, make_name_for_block(iseq),
                           ISEQ_TYPE_BLOCK, line);
        ADD_SEND_WITH_BLOCK(ret, line_node, idEach, INT2FIX(0), child_iseq);
    }
    else {
        ISEQ_COMPILE_DATA(iseq)->current_block = child_iseq =
            NEW_CHILD_ISEQ(RNODE_ITER(node)->nd_body, make_name_for_block(iseq),
                           ISEQ_TYPE_BLOCK, line);
        CHECK(COMPILE(ret, "iter caller", RNODE_ITER(node)->nd_iter));
    }

    {
      ...
    }

    if (popped) {
        ADD_INSN(ret, line_node, pop);
    }
    ...
    return COMPILE_OK;
}

ということでcompile_callpopped = falseで呼び出すように修正します。

diff --git a/compile.c b/compile.c
index 001a0b6432..23cef7e175 100644
--- a/compile.c
+++ b/compile.c
@@ -8861,7 +8861,7 @@ compile_iter0(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *const node, c
         ISEQ_COMPILE_DATA(iseq)->current_block = child_iseq =
             NEW_CHILD_ISEQ(RB_NODE_CALL(node)->block, make_name_for_block(iseq),
                            ISEQ_TYPE_BLOCK, line);
-        CHECK(compile_call(iseq, ret, node, type, popped));
+        CHECK(compile_call(iseq, ret, node, type, 0));
       }
     }

ちゃんと実行できるようになりました。

$ ./miniruby --parser=parse.y ../../test2.rb
# l = false
# true.tap do |t|
#   p l
#   p t
# end

# p l # 追加した行

false
true
false

まとめ

今日の成果です。

  • ブロックも含め、ローカル変数の代入・参照を対応した
  • itを対応した

  1. moduleのISeqのタイプはISEQ_TYPE_CLASSです。
  2. NEW_ISEQマクロはparentを指定しない際のマクロで、NEW_CHILD_ISEQマクロはparentを指定する際のマクロです。シングルトンクラス定義の際はNEW_ISEQが、クラス定義とモジュール定義ではNEW_CHILD_ISEQが使われていますが、これは意図的なのか現状では調査しきれていません。
  3. 最終的にls - idxをしているのは、実行時にEPがローカルテーブルの末尾の方にあるため。compile.cを読むだけであればidxの解決について注目すれば十分でしょう。
  4. 最終的にPRを作るタイミングで綺麗にすればいいでしょう。
  5. もともとメソッド直下で定義されているローカル変数であっても、ブロック内部で参照されている場合にはDVARノードになっていました。このことを考えると全てをDVARに寄せてもループの数は変わらないと思います。



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

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