LoginSignup
50
21

More than 5 years have passed since last update.

rubyでgroovyやKotlinのitが使えるthats_it gemを作った

Last updated at Posted at 2018-07-18

追記: Ruby 2.7からはこのgemは必要ないです

Ruby 2.7でNumbered parametersが追加されました。最高!
http://bugs.ruby-lang.org/issues/4475

% ruby -ve 'p [1,2,3].map { @1 * 2 }'
ruby 2.7.0dev (2019-03-18 trunk 67289) [x86_64-darwin17]
[2, 4, 6]

TL;DR

groovyやKotlinのitのようにブロックに渡された値をitで参照できるthats_itgemの紹介です。

require "thats_it"

[1, 2, 3].map { it * 2 }
# => [2, 4, 6]

便利ですね。

Kotlinやgroovyのitとは

ラムダ式が1つしかパラメーターを取らない場合、そのパラメーターを宣言しなくても暗黙的に宣言された「it」変数でそのパラメーターを参照できる便利機能。

参考

Higher-Order Functions and Lambdas - Kotlin Programming Language it: implicit name of a single parameter
The Apache Groovy programming language - Closures 2.2. Implicit parameter

忙しい人向けの実装方法解説

ブロックが取る引数が0かつitの呼び出しがある場合にブロックのISeqを書き換えてブロックが1つ引数を取ることにする。1

# 書き換え前
1.yield_self { it }
# 書き換え後
1.yield_self {|it| it }

どのタイミングでブロックを書き換えるか

TracePointを使いメソッド呼び出しのタイミングでメソッドに渡されたブロックのISeqを取得し書き換える。

# Rubyで書かれたメソッドの呼び出し時に呼ばれる
TracePoint.trace(:call) do |tp|
  # ここで書き換える
end

# Cで書かれたメソッドの呼び出し時に呼ばれる
TracePoint.trace(:c_call) do |tp|
  # ここで書き換える
end

ブロックが呼び出されるタイミングで呼び出されるb_callというイベントもあるがそこではISeqを書き換えられない。理由は後述。

ブロックが持っている情報

ブロックのISeqにはブロックが取る引数の情報が保持されている。
以下のような引数を取る場合のISeqを確認してみる。

42.yield_self {|it| it }

rubyではオプションで--dump=insnsを渡すとISeqを確認できる。

% ruby -v --dump=insns -e '42.yield_self {|it| it }'
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,24)>=============================
== catch table
| catch type: break  st: 0000 ed: 0006 sp: 0000 cont: 0006
== disasm: #<ISeq:block in <main>@-e:1 (1,14)-(1,24)>===================
== catch table
| catch type: redo   st: 0001 ed: 0003 sp: 0000 cont: 0001
| catch type: next   st: 0001 ed: 0003 sp: 0000 cont: 0003
|------------------------------------------------------------------------
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] it<Arg>
0000 nop                                                              (   1)[Bc]
0001 getlocal_OP__WC__0 it[Li]
0003 leave            [Br]
|------------------------------------------------------------------------
0000 putobject        42                                              (   1)[Li]
0002 send             <callinfo!mid:yield_self, argc:0>, <callcache>, block in <main>
0006 leave

ブロックのISeqを確認してみると以下のように引数を1つ取ることが分かる。

local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])

一方ブロックが引数を取らない場合はローカル変数が無いので以下のようにlocal tableが省略して表示される。
表示されていないだけで、実際にはlocal tableの情報は引数を渡さない場合もブロックのISeqに保持されており、size: 0, argc: 0となっている。

% ruby -v --dump=insns -e '42.yield_self { it }'
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,20)>=============================
== catch table
| catch type: break  st: 0000 ed: 0006 sp: 0000 cont: 0006
== disasm: #<ISeq:block in <main>@-e:1 (1,14)-(1,20)>===================
== catch table
| catch type: redo   st: 0001 ed: 0005 sp: 0000 cont: 0001
| catch type: next   st: 0001 ed: 0005 sp: 0000 cont: 0005
|------------------------------------------------------------------------
0000 nop                                                              (   1)[Bc]
0001 putself          [Li]
0002 opt_send_without_block <callinfo!mid:it, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
0005 leave            [Br]
|------------------------------------------------------------------------
0000 putobject        42                                              (   1)[Li]
0002 send             <callinfo!mid:yield_self, argc:0>, <callcache>, block in <main>
0006 leave

ブロック呼び出し周りのRubyVMの動作

ブロックを呼び出すコードを読めばブロックのISeqを取る方法やどう書き換えればいいかがが分かる。

以下のようなブロックを呼び出す内容のプログラムのISeqを確認する。

def foo
  yield 42
end
% ruby -v --dump=insns -e 'def foo; yield 42; end'
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,22)>=============================
0000 putspecialobject 1                                               (   1)[Li]
0002 putobject        :foo
0004 putiseq          foo
0006 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0009 leave
== disasm: #<ISeq:foo@-e:1 (1,0)-(1,22)>================================
0000 putobject        42                                              (   1)[LiCa]
0002 invokeblock      <callinfo!argc:1, ARGS_SIMPLE>
0004 leave            [Re]

invokeblock命令で呼び出しているよう。ruby/rubyをcloneしてgit grepする2

% git grep invokeblock
bootstraptest/test_insns.rb:  [ 'invokeblock',            <<~'},', ], # {
compile.c:  ADD_INSN1(ret, line, invokeblock, new_callinfo(iseq, 0, FIX2INT(argc), flag, keywords, FALSE));
doc/ChangeLog-1.9.3:    * insns.def (invokeblock): fix of splat argument.
doc/ChangeLog-1.9.3:    * insns.def (invokeblock): check block is created by lambda
doc/ChangeLog-2.0.0:    * insns.def (send, invokesuper, invokeblock, opt_*), vm_core.h:
insns.def:invokeblock

YARVの命令はinsns.defファイルで定義されているのでinsns.definvokeblockを読む。3

invokeblock

コメントから見てわかるようにyieldに対応している。
vm_invoke_blockを実行してますね。
この辺でgdbで止めてデバッグします。

insns.def
/**
  @c method/iterator
  @e yield(args)
  @j yield を実行する。
 */
DEFINE_INSN
invokeblock
(CALL_INFO ci)
(...)
(VALUE val)  // inc += 1 - ci->orig_argc;
{
    struct rb_calling_info calling;
    calling.argc = ci->orig_argc;
    calling.block_handler = VM_BLOCK_HANDLER_NONE;
    calling.recv = GET_SELF();


    val = vm_invoke_block(ec, GET_CFP(), &calling, ci);
    if (val == Qundef) {
        RESTORE_REGS();
        NEXT_INSN();
    }
}

vm_invoke_block

ブロックとして渡された引数があるか確認、ブロックが渡されてなければno block givenの例外を投げる。
ふつうにrubyでyieldした際はvm_invoke_iseq_blockが呼ばれるようです。

vm_insnhelper.c
static VALUE
vm_invoke_block(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp, struct rb_calling_info *calling, const struct rb_call_info *ci)
{
    VALUE block_handler = VM_CF_BLOCK_HANDLER(reg_cfp);
    VALUE type = GET_ISEQ()->body->local_iseq->body->type;
    int is_lambda = FALSE;


    if ((type != ISEQ_TYPE_METHOD && type != ISEQ_TYPE_CLASS) ||
        block_handler == VM_BLOCK_HANDLER_NONE) {
        rb_vm_localjump_error("no block given (yield)", Qnil, 0);
    }


  again:
    switch (vm_block_handler_type(block_handler)) {
      case block_handler_type_iseq:
        {
            const struct rb_captured_block *captured = VM_BH_TO_ISEQ_BLOCK(block_handler);
            return vm_invoke_iseq_block(ec, reg_cfp, calling, ci, is_lambda, captured);
        }
      case block_handler_type_ifunc:
        {
            const struct rb_captured_block *captured = VM_BH_TO_IFUNC_BLOCK(block_handler);
            return vm_invoke_ifunc_block(ec, reg_cfp, calling, ci, captured);
        }
      case block_handler_type_proc:
        is_lambda = block_proc_is_lambda(VM_BH_TO_PROC(block_handler));
        block_handler = vm_proc_to_block_handler(VM_BH_TO_PROC(block_handler));
        goto again;
      case block_handler_type_symbol:
        return vm_invoke_symbol_block(ec, reg_cfp, calling, ci, VM_BH_TO_SYMBOL(block_handler));
    }
    VM_UNREACHABLE(vm_invoke_block: unreachable);
    return Qnil;
}

vm_invoke_iseq_block

引数周りのスタックを調整してからYARVのcontrol frameを積んでる。

vm_insnhelper.c
/* ruby iseq -> ruby block */


static VALUE
vm_invoke_iseq_block(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp,
                     struct rb_calling_info *calling, const struct rb_call_info *ci,
                     int is_lambda, const struct rb_captured_block *captured)
{
    const rb_iseq_t *iseq = rb_iseq_check(captured->code.iseq);
    const int arg_size = iseq->body->param.size;
    VALUE * const rsp = GET_SP() - calling->argc;
    int opt_pc = vm_callee_setup_block_arg(ec, calling, ci, iseq, rsp, is_lambda ? arg_setup_method : arg_setup_block);


    SET_SP(rsp);


    vm_push_frame(ec, iseq,
                  VM_FRAME_MAGIC_BLOCK | (is_lambda ? VM_FRAME_FLAG_LAMBDA : 0),
                  captured->self,
                  VM_GUARDED_PREV_EP(captured->ep), 0,
                  iseq->body->iseq_encoded + opt_pc,
                  rsp + arg_size,
                  iseq->body->local_table_size - arg_size, iseq->body->stack_max);


    return Qundef;
}

struct rb_captured_block *からブロックのISeqが取得できることが分かりました。
vm_push_frameまで見ます。

vm_insnhelper.c
    const rb_iseq_t *iseq = rb_iseq_check(captured->code.iseq);

vm_push_frame

vm_push_frameのシグネチャは以下

vm_insnhelper.c
static inline rb_control_frame_t *
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)

vm_push_frameの後ろから3番目の引数がspになっているのがわかる。spにはrsp + arg_sizeが入るよう。

vm_insnhelper.c
    vm_push_frame(ec, iseq,
                  VM_FRAME_MAGIC_BLOCK | (is_lambda ? VM_FRAME_FLAG_LAMBDA : 0),
                  captured->self,
                  VM_GUARDED_PREV_EP(captured->ep), 0,
                  iseq->body->iseq_encoded + opt_pc,
                  rsp + arg_size, /* ここがスタックの底になる */
                  iseq->body->local_table_size - arg_size, iseq->body->stack_max);

rsparg_sizeは以下のように計算されていますね。

vm_insnhelper.c
    const int arg_size = iseq->body->param.size;
    VALUE * const rsp = GET_SP() - calling->argc;

arg_sizeにはブロックのISeqの引数の数の情報が入る。
ブロックの引数を書かない場合ここのarg_size0になる。
calling->argcにはyieldに渡した引数の数が入っている、なので1
callingの型はstruct rb_calling_info *で対応するのはこの部分かな?

0002 invokeblock      <callinfo!argc:1, ARGS_SIMPLE>

rspには現在のスタックポインターから呼び出し側でyieldに渡して積んだ引数を引いた部分が入る。
つまり引数0のブロックを呼び出す場合いくらyieldに引数を渡してもブロックの実行前に破棄されてしまうよう。4

ブロック呼び出し時の動作雑な理解

  • cfpからblock_handlerが取れる
    • block_handlerは4種類ある
      • iseq
      • ifunc
      • proc
      • symbol
  • iseqのblock_handlerからはiseqが取れる
  • iseq->body->param.sizeが0だとyield 1, 2, 3, 4, ...といくらyieldに引数を渡しても捨てられてしまう

b_callでブロックのISeqを書き換えても意味なさ

以下のようなプログラムをgdbで実行します。

TracePoint.trace(:b_call) do |tp|
  1.yield_self
end

def foo
  yield 42
end

foo {}

vm_invoke_iseq_blockrb_obj_yield_selfにbreakpointを仕掛けて呼び出される順序を確認。

% gdb $(rbenv which ruby)
GNU gdb (Gentoo 8.1 p1) 8.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://bugs.gentoo.org/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from /home/sei/.rbenv/versions/2.5.1/bin/ruby...done.
(gdb) break vm_invoke_iseq_block if rb_vm_top_self() == calling->recv
Breakpoint 1 at 0x1850fd: file vm_insnhelper.c, line 2630.
(gdb) break rb_obj_yield_self
Breakpoint 2 at 0x909da: file object.c, line 577.
(gdb) run --disable-gems -e 'TracePoint.trace(:b_call) {|tp| 1.yield_self }; def foo; yield 42; end; foo
{}'
Starting program: /home/sei/.rbenv/versions/2.5.1/bin/ruby --disable-gems -e 'TracePoint.trace(:b_call) {|tp| 1.yield_self }; def foo; yield 42; end; foo {}'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
[New Thread 0x7ffff7ff6700 (LWP 15626)]
Error in testing breakpoint condition:
Couldn't get registers: そのようなプロセスはありません.
An error occurred while in a function called from GDB.
Evaluation of the expression containing the function
(rb_vm_top_self) will be abandoned.
When the function is done executing, GDB will silently stop.
Selected thread is running.
(gdb) frame 0
#0  vm_invoke_iseq_block (ec=0x555555a79258, reg_cfp=0x7ffff7fc5f80, calling=0x7fffffffd8b0,
    ci=0x555555babf60, is_lambda=0, captured=0x7ffff7fc5fc8) at vm_insnhelper.c:2630
2630        const rb_iseq_t *iseq = rb_iseq_check(captured->code.iseq);
(gdb) c
Continuing.

Thread 1 "ruby" hit Breakpoint 2, rb_obj_yield_self (obj=3) at object.c:577
577         RETURN_SIZED_ENUMERATOR(obj, 0, 0, rb_obj_size);
(gdb) frame 0
#0  rb_obj_yield_self (obj=3) at object.c:577
577         RETURN_SIZED_ENUMERATOR(obj, 0, 0, rb_obj_size);
(gdb) c
Continuing.
[Thread 0x7ffff7ff6700 (LWP 15626) exited]
[Inferior 1 (process 15622) exited normally]

見ての通りvm_invoke_blockが呼ばれてからrb_obj_yield_selfが呼び出されている。

  1. yieldの引数をスタックに積む
  2. invokeblockが呼ばれる
  3. vm_invoke_blockが呼ばれる
  4. vm_invoke_iseq_blockが呼ばれる
  5. vm_push_frameが呼ばれる(このタイミングでスタックに積んだ引数が捨てられる)
  6. TracePointのb_callが呼ばれる

6の段階でISeqを書き換えても5の段階で既にyieldに渡された値がすてられてしまう。
なので書き換えるなら4を呼ぶ前にISeqを書き換えないといけない。

6の段階のcontrol frameのスタックを見てみると以下のようになっている。
0006がyield_selfの呼び出しで、0005がTracePoint作成時に渡したブロック、0004がfooに渡したブロックとなっており、既に制御フレームが積まれているのが分かる。

(gdb) call rb_vmdebug_stack_dump_raw_current()
-- Control frame information -----------------------------------------------
c:0006 p:---- s:0021 e:000020 CFUNC  :yield_self
c:0005 p:0005 s:0017 e:000016 BLOCK  -e:1 [FINISH]
c:0004 p:0001 s:0013 e:000012 BLOCK  -e:1
c:0003 p:0004 s:0010 e:000009 METHOD -e:1
c:0002 p:0029 s:0006 E:002660 EVAL   -e:1 [FINISH]
c:0001 p:0000 s:0003 E:000460 (none) [FINISH]

ブロックのISeqをメソッド呼び出し時に取得する

以下のようなコードをgdbで実行してrb_obj_yield_selfにbreakポイントを仕掛けブロックのISeqを取る方法を確認する。

TracePoint.trace(:call) do |tp|
  1.yield_self
end

def foo
  yield 42
end

foo { 42 }

とりあえず止まった

% gdb $(rbenv which ruby)
GNU gdb (Gentoo 8.1 p1) 8.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://bugs.gentoo.org/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from /home/sei/.rbenv/versions/2.5.1/bin/ruby...done.
(gdb) source ~/src/github.com/ruby/ruby/.gdbinit
(gdb) break rb_obj_yield_self
Breakpoint 1 at 0x909da: file object.c, line 577.
(gdb) run --disable-gems -e 'TracePoint.trace(:call) {|tp| 1.yield_self }; def foo; yield 42; end; foo { 42 }
'
Starting program: /home/sei/.rbenv/versions/2.5.1/bin/ruby --disable-gems -e 'TracePoint.trace(:call) {|tp| 1.yield_self }; def foo; yield 42; end; foo {}'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
[New Thread 0x7ffff7ff6700 (LWP 16662)]

Thread 1 "ruby" hit Breakpoint 1, rb_obj_yield_self (obj=3) at object.c:577
577         RETURN_SIZED_ENUMERATOR(obj, 0, 0, rb_obj_size);

メソッドは2つ上のフレームのよう

(gdb) call rb_vmdebug_stack_dump_raw_current()
-- Control frame information -----------------------------------------------
c:0005 p:---- s:0018 e:000017 CFUNC  :yield_self
c:0004 p:0005 s:0014 e:000013 BLOCK  -e:1 [FINISH]
c:0003 p:0001 s:0010 e:000009 METHOD -e:1
c:0002 p:0029 s:0006 E:002660 EVAL   -e:1 [FINISH]
c:0001 p:0000 s:0003 E:000460 (none) [FINISH]

今のcontrol frameはruby_current_execution_context_ptrで取れる。5

(gdb) p *(ruby_current_execution_context_ptr->cfp)
$40 = {pc = 0x0, sp = 0x7ffff7ec60a0, iseq = 0x0, self = 3, ep = 0x7ffff7ec6098, block_code = 0x0}
(gdb) call rb_p((ruby_current_execution_context_ptr->cfp)->self)
1

cfpは--して伸びる。試しに-したCFPを見ると使われてない空のフレームがあるのが分かる。

(gdb) p *(ruby_current_execution_context_ptr->cfp - 1)
$49 = {pc = 0x0, sp = 0x7ffff7ec60b8, iseq = 0x0, self = 3, ep = 0x7ffff7ec60b0, block_code = 0x0}
(gdb) p *(ruby_current_execution_context_ptr->cfp - 2)
$50 = {pc = 0x0, sp = 0x0, iseq = 0x0, self = 0, ep = 0x0, block_code = 0x0}
(gdb) p *(ruby_current_execution_context_ptr->cfp - 3)
$51 = {pc = 0x0, sp = 0x0, iseq = 0x0, self = 0, ep = 0x0, block_code = 0x0}

なので2つ上のメソッド呼び出しのcfpを見るときは+ 2すると見れる。

(gdb) p *(ruby_current_execution_context_ptr->cfp + 2)
$23 = {pc = 0x555555babee8, sp = 0x7ffff7ec6060, iseq = 0x555555a95d80, self = 93824998023880,
  ep = 0x7ffff7ec6058, block_code = 0x0}

さきほど見たvm_invoke_blockでブロックハンドラーを取得していた関数VM_CF_BLOCK_HANDLERを呼んでみる。

(gdb) p VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2))
$52 = 140737353899977

vm_invoke_block同様VM_BH_TO_ISEQ_BLOCKを呼んでstruct rb_captured_block *に変換する。

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))
$54 = (const struct rb_captured_block *) 0x7ffff7fc5fc8
(gdb) p *VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))
$55 = {self = 93824998023880, ep = 0x555555bb0090, code = {iseq = 0x555555a95c90,
    ifunc = 0x555555a95c90, val = 93824997743760}}

vm_invoke_block_iseqのようにiseqにアクセスしてみる。

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq
$56 = (const rb_iseq_t *) 0x555555a95c90
(gdb) p *VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq
$57 = {flags = 28698, reserved1 = 0, body = 0x555555babfb0, aux = {compile_data = 0x8, loader = {
      obj = 8, index = 0}, trace_events = 8}}

このままだとよくわからないのでdisasmした結果を見てみる。

(gdb) call rb_p(rb_iseq_disasm(VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq))
"== disasm: #<ISeq:block in <main>@-e:1 (1,74)-(1,80)>===================\n== catch table\n| catch type: redo   st: 0001 ed: 0003 sp: 0000 cont: 0001\n| catch type: next   st: 0001 ed: 0003 sp: 0000 cont: 0003\n|------------------------------------------------------------------------\n0000 nop                                                              (   1)[Bc]\n0001 putobject        42[Li]\n0003 leave            [Br]\n"

目的のブロックが取れている。

ブロックのISeqを書き換える

この辺のうちflagsはそれぞれの種類の引数があるかどうかをあらわしている。

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param
$70 = {flags = {has_lead = 0, has_opt = 0, has_rest = 0, has_post = 0, has_kw = 0, has_kwrest = 0,
    has_block = 0, ambiguous_param0 = 0}, size = 0, lead_num = 0, opt_num = 0, rest_start = 0,
  post_start = 0, post_num = 0, block_start = 0, opt_table = 0x0, keyword = 0x0}

foo {|x| x }のような、1つ引数を取るブロックの場合は以下のようになっている。

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param
$71 = {flags = {has_lead = 1, has_opt = 0, has_rest = 0, has_post = 0, has_kw = 0, has_kwrest = 0,
    has_block = 0, ambiguous_param0 = 1}, size = 1, lead_num = 1, opt_num = 0, rest_start = 0,
  post_start = 0, post_num = 0, block_start = 0, opt_table = 0x0, keyword = 0x0}

それに合わせてflagshas_leadsizeambiguous_param0を1に書き換えていく。

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param.flags.ambiguous_param0 = 1
$75 = 1
(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param.flags.has_lead = 1
$76 = 1
(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param.size = 1
$77 = 1
(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.param
$78 = {flags = {has_lead = 1, has_opt = 0, has_rest = 0, has_post = 0, has_kw = 0, has_kwrest = 0,
    has_block = 0, ambiguous_param0 = 1}, size = 1, lead_num = 0, opt_num = 0, rest_start = 0,
  post_start = 0, post_num = 0, block_start = 0, opt_table = 0x0, keyword = 0x0}

これでスタックに値が積まれているはず、continueしてみる。

(gdb) c
Continuing.
-e:1: [BUG] Stack consistency error (sp: 14, bp: 13)
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]

-- Control frame information -----------------------------------------------
c:0004 p:0004 s:0014 e:000013 BLOCK  -e:1
c:0003 p:0004 s:0010 e:000009 METHOD -e:1
c:0002 p:0029 s:0006 E:002680 EVAL   -e:1 [FINISH]
c:0001 p:0000 s:0003 E:000460 (none) [FINISH]

エラーが出る。git grepでエラーメッセージを調べると以下がヒット。

vm_insnhelper.c
static void
vm_stack_consistency_error(const rb_execution_context_t *ec,
               const rb_control_frame_t *cfp,
               const VALUE *bp)
{
    const ptrdiff_t nsp = VM_SP_CNT(ec, cfp->sp);
    const ptrdiff_t nbp = VM_SP_CNT(ec, bp);
    static const char stack_consistency_error[] =
    "Stack consistency error (sp: %"PRIdPTRDIFF", bp: %"PRIdPTRDIFF")";
#if defined RUBY_DEVEL
    VALUE mesg = rb_sprintf(stack_consistency_error, nsp, nbp);
    rb_str_cat_cstr(mesg, "\n");
    rb_str_append(mesg, rb_iseq_disasm(cfp->iseq));
    rb_exc_fatal(rb_exc_new3(rb_eFatal, mesg));
#else
    rb_bug(stack_consistency_error, nsp, nbp);
#endif
}

vm_stack_consistency_errorで探すとleaveする際にしかこのエラーは出ないよう。

insns.def
/**
  @c method/iterator
  @e return from this scope.
  @j このスコープから抜ける。
 */
DEFINE_INSN
leave
()
(VALUE val)
(VALUE val)
{
    if (OPT_CHECKED_RUN) {
    const VALUE *const bp = vm_base_ptr(reg_cfp);
    if (reg_cfp->sp != bp) {
        vm_stack_consistency_error(ec, reg_cfp, bp);
    }
    }


    RUBY_VM_CHECK_INTS(ec);


    if (vm_pop_frame(ec, GET_CFP(), GET_EP())) {
#if OPT_CALL_THREADED_CODE
    rb_ec_thread_ptr(ec)->retval = val;
    return 0;
#else
    return val;
#endif
    }
    else {
    RESTORE_REGS();
    }
}

vm_base_ptrの計算を見てみる

vm_insnhelper.c
static VALUE *
vm_base_ptr(const rb_control_frame_t *cfp)
{
    const rb_control_frame_t *prev_cfp = RUBY_VM_PREVIOUS_CONTROL_FRAME(cfp);


    if (cfp->iseq && VM_FRAME_RUBYFRAME_P(cfp)) {
    VALUE *bp = prev_cfp->sp + cfp->iseq->body->local_table_size + VM_ENV_DATA_SIZE;
    if (cfp->iseq->body->type == ISEQ_TYPE_METHOD) {
        /* adjust `self' */
        bp += 1;
    }
#if VM_DEBUG_BP_CHECK
    if (bp != cfp->bp_check) {
        fprintf(stderr, "bp_check: %ld, bp: %ld\n",
            (long)(cfp->bp_check - GET_EC()->vm_stack),
            (long)(bp - GET_EC()->vm_stack));
        rb_bug("vm_base_ptr: unreachable");
    }
#endif
    return bp;
    }
    else {
    return NULL;
    }
}

local_table_sizeの書き換えが必要。

もう一度同じところまで進めてlocal_table_sizeも合わせて書き換える

(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.ise
q->body.local_table_size
$82 = 0
(gdb) p VM_BH_TO_ISEQ_BLOCK(VM_CF_BLOCK_HANDLER((ruby_current_execution_context_ptr->cfp + 2)))->code.iseq->body.local_table_size = 1
(gdb) c
Continuing.
[Thread 0x7ffff7ff6700 (LWP 20326) exited]
[Inferior 1 (process 20325) exited normally]

正常終了するようになった

積まれた引数を取得する

ブロックを書き換えて、CFPにyieldに渡された値が捨てられずにスタックに積まれたままになった。

vm_invoke_iseq_blockを呼び出したcfpではSET_SP(rsp);でspがyieldで積んだとこを指してるので、vm_invoke_blockを呼び出したcfpのspにアクセスすると取れる。

スタックの様子

ブロック呼び出し時のスタックの様子

vm_insnhelper.c
static VALUE
vm_invoke_iseq_block(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp,
                     struct rb_calling_info *calling, const struct rb_call_info *ci,
                     int is_lambda, const struct rb_captured_block *captured)
{
    const rb_iseq_t *iseq = rb_iseq_check(captured->code.iseq);
    const int arg_size = iseq->body->param.size;
    VALUE * const rsp = GET_SP() - calling->argc;
    int opt_pc = vm_callee_setup_block_arg(ec, calling, ci, iseq, rsp, is_lambda ? arg_setup_method : arg_setup_block);

この時点ではこういう感じ、cfp->spが引数の1つ上を指してる。引数の個数が1つなんでrspはspの1つ下

_home_sei_src_github.com_hanachin_stackgraph_index.html.png

vm_insnhelper.c

    SET_SP(rsp);

SET_SP呼び出すとcfp->spがここに移動する

_home_sei_src_github.com_hanachin_stackgraph_index.html (2).png

vm_push_frame呼び出す

vm_insnhelper.c

    vm_push_frame(ec, iseq,
                  VM_FRAME_MAGIC_BLOCK | (is_lambda ? VM_FRAME_FLAG_LAMBDA : 0),
                  captured->self,
                  VM_GUARDED_PREV_EP(captured->ep), 0,
                  iseq->body->iseq_encoded + opt_pc,
                  rsp + arg_size,
                  iseq->body->local_table_size - arg_size, iseq->body->stack_max);


    return Qundef;
}

vm_push_frameでは新しいcfpが作られる

vm_insnhelper.c
static inline rb_control_frame_t *
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)
{
    rb_control_frame_t *const cfp = ec->cfp - 1;
    int i;


    vm_check_frame(type, specval, cref_or_me, iseq);
    VM_ASSERT(local_size >= 0);


    /* check stack overflow */
    CHECK_VM_STACK_OVERFLOW0(cfp, sp, local_size + stack_max);


    ec->cfp = cfp;


    /* setup new frame */
    cfp->pc = (VALUE *)pc;
    cfp->iseq = (rb_iseq_t *)iseq;
    cfp->self = self;
    cfp->block_code = NULL;


    /* setup vm value stack */


    /* initialize local variables */
    for (i=0; i < local_size; i++) {
    *sp++ = Qnil;
    }


    /* setup ep with managing data */
    VM_ASSERT(VM_ENV_DATA_INDEX_ME_CREF == -2);
    VM_ASSERT(VM_ENV_DATA_INDEX_SPECVAL == -1);
    VM_ASSERT(VM_ENV_DATA_INDEX_FLAGS   == -0);
    *sp++ = cref_or_me; /* ep[-2] / Qnil or T_IMEMO(cref) or T_IMEMO(ment) */
    *sp++ = specval     /* ep[-1] / block handler or prev env ptr */;
    *sp   = type;       /* ep[-0] / ENV_FLAGS */


    cfp->ep = sp;
    cfp->sp = sp + 1;


#if VM_DEBUG_BP_CHECK
    cfp->bp_check = sp + 1;
#endif


    if (VMDEBUG == 2) {
    SDR();
    }


    return cfp;
}

このときISeqのブロックの引数が0の時と引数1のときではvm_push_frameに渡されるspの位置がこういう感じでずれている。

_home_sei_src_github.com_hanachin_stackgraph_index.html (3).png

ISeqの引数0のときはこういう感じでspに積まれていた42が上書きされてしまう。

_home_sei_src_github.com_hanachin_stackgraph_index.html (4).png

ISeqの引数が1のときはこういう感じで上書きされず残っている

_home_sei_src_github.com_hanachin_stackgraph_index.html (5).png

積まれた引数を取るメソッドを生やす

こういうプログラムだと

def foo
  yield 42
end

foo { it }

itメソッド呼び出した際のcontrol frameのスタックはこうなっている

_home_sei_src_github.com_hanachin_stackgraph_index.html (7).png

なのでitで積まれた引数を取るには以下のような感じの2通りのやり方で取れる

def it
  # ここで1つ上のcfpを参照するとブロックのcfpが取れる、1つ上のcfp->ep - 3にアクセスする
  # or
  # ここで2つ上のcfpを参照するとブロックを呼び出したときのcfpが取れる、メソッド呼び出し時のcfp->spにアクセスする
end

_home_sei_src_github.com_hanachin_stackgraph_index.html (6).png

実装

rb_control_frame_tの型などはrubyのAPIとしてinclude出来るヘッダーが公開されていないので必要な部分をコピーしてくる。

バージョンごとに構造体が違うのでバージョンごとの定義を用意する。
https://github.com/hanachin/thats_it/blob/master/ext/thats_it/thats_it_20500.h
https://github.com/hanachin/thats_it/blob/master/ext/thats_it/thats_it_20600.h

RUBY_API_VERSIONで分岐して各バージョンにあった定義を読み込む。

thats_it_20600.h
#if RUBY_API_VERSION_CODE >= 20600
#include "thats_it_20600.h"
#elif RUBY_API_VERSION_CODE >= 20500
#include "thats_it_20500.h"
#endif

現在実行中のcontrol frameはこんな感じでexternしておいて

extern rb_execution_context_t *ruby_current_execution_context_ptr;

こうやれば取れる

ruby_current_execution_context_ptr->cfp

control frameやiseq書き換え周りはCで書いている。
https://github.com/hanachin/thats_it/blob/master/ext/thats_it/thats_it.c

TracePoint仕掛けるのはrubyの方が楽に書けそうなのでrubyで書いた。
https://github.com/hanachin/thats_it/blob/master/lib/thats_it.rb

まとめ

C拡張を書けばTracePointでメソッド呼び出し時に強引にブロックのISeqを取得できる。
メソッド呼び出し時のタイミングであれば渡されたブロックのISeqを書き換えられる。
ブロックは呼び出されるまで評価されないためISeqを実行する前にISeqを書き換えられる。

マクロだーーー!!!


  1. 実際には書き換え後のコードと同様の動きをするわけではない。ローカル変数itには何も入っておらずitは変数参照ではなくメソッド呼び出しとなっている。RubyVMとISeqの動作はRubyのコードで表現できないので雰囲気だけ掴んでもらえれば。 

  2. https://github.com/ruby/ruby/tree/v2_5_1 

  3. RubyVMの設計についてのドキュメント参考 https://github.com/ruby/ruby/blob/v2_5_1/template/yarvarch.ja 

  4. 例えば呼び出す際にyield 1, 2, 3するとcalling->argc3、なのでスタックに3つ値が積まれた状態だとrspは積まれていない底を指している。新しいスタックの底はrsp + arg_sizeで決定されるので引数0の場合は積んである1, 2, 3は後からスタックを積む際に上書きされて消えてしまう。ブロックが3つ引数を取る場合はrsp + 3で3つの値が積まれた状態を指すようになる。このへん説明難しいのでRubyのしくみを読みましょう... 

  5. Rubyのオブジェクトをプリント出来るデバッグ用のC関数 https://github.com/ruby/ruby/blob/v2_5_1/io.c#L7655 

50
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
21