为什么 Ruby 调试器 return 的值与 运行 时的代码不同?

Why does the Ruby debugger return different values than the code at run time?

看这个简单的Ruby class:

require 'byebug'

class Foo
  def run
    byebug

    puts defined?(bar)
    puts bar.inspect

    bar = 'local string'

    puts defined?(bar)
    puts bar.inspect
  end

  def bar
    'string from method'
  end
end

Foo.new.run

当 运行 对此 class 进行调试时,可以在调试器的控制台中观察到以下行为:

    $ ruby byebug.rb

    [2, 11] in /../test.rb
        2:
        3: class Foo
        4:   def run
        5:     byebug
        6:
    =>  7:     puts defined?(bar)
        8:     puts bar.inspect
        9:
       10:     bar = 'local string'
       11:

在断点处,调试器return具有以下值:

    (byebug) defined?(bar)
    "local-variable"
    (byebug) bar.inspect
    "nil"

请注意 - 尽管调试器的断点在 #5 行 - 它已经知道将在 #10 行中定义一个局部变量 bar 来隐藏方法 bar 并且调试器实际上无法再调用 bar 方法。此时不知道的是字符串 'local string' 将分配给 barbar.

的调试器 returns nil

让我们继续 Ruby 文件中的原始代码并查看其输出:

    (byebug) continue
    method
    "string from method"
    local-variable
    "local string"

在运行行#7时Ruby仍然知道bar确实是一个方法,它仍然可以在[=28=行调用它].然后 #10 行实际上定义了局部变量,该变量以相同的名称和 tTherefore Ruby return 隐藏方法,就像在 #12#13 行中预期的那样。

问题:为什么调试器 return 的值与原始代码不同?似乎可以预见未来。这被认为是功能还是错误?是否记录了此行为?

无论何时进入调试会话,您实际上都在针对代码中那个位置的绑定执行 eval。下面是一段更简单的代码,它重现了让您抓狂的行为:

def make_head_explode
  puts "== Proof bar isn't defined"
  puts defined?(bar)   # => nil

  puts "== But WTF?! It shows up in eval"
  eval(<<~RUBY)
    puts defined?(bar) # => 'local-variable'
    puts bar.inspect   # => nil
  RUBY

  bar = 1
  puts "\n== Proof bar is now defined"
  puts defined?(bar)   # => 'local-variable'
  puts bar.inspect     # => 1
end

当方法 make_head_explode 被提供给解释器时,它被编译为 YARV 指令,一个局部 table,它存储有关方法参数和方法中所有局部变量的信息,以及一个catch table,其中包含方法中的救援信息(如果存在)。

此问题的根本原因在于,由于您在运行时使用 eval 动态编译代码,Ruby 传递了本地 table,其中包括未设置的变量 enry,也进行评估。

首先,让我们使用一个非常简单的方法来演示我们期望的行为。

def foo_boom
  foo         # => NameError
  foo = 1     # => 1
  foo         # => 1
end

我们可以通过使用 RubyVM::InstructionSequence.disasm(method) 提取现有方法的 YARV 字节码来检查这一点。请注意,我将忽略跟踪调用以保持说明整洁。

RubyVM::InstructionSequence.disasm(method(:foo_boom)) 更少跟踪的输出:

== disasm: #<ISeq:foo_boom@(irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] foo
0004 putself
0005 opt_send_without_block <callinfo!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
0008 pop
0011 putobject_OP_INT2FIX_O_1_C_
0012 setlocal_OP__WC__0 2
0016 getlocal_OP__WC__0 2
0020 leave                                                            ( 253)

现在让我们浏览一下跟踪。

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

我们可以在这里看到 YARV 已经识别出我们有局部变量 foo,并将其存储在索引 [2] 处的局部 table 中。如果我们有其他局部变量和参数,它们也会出现在 table.

接下来,当我们尝试在分配 foo 之前调用 foo 时生成的指令:

  0004 putself
  0005 opt_send_without_block <callinfo!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
  0008 pop

让我们来剖析这里发生的事情。 Ruby 根据以下模式编译 YARV 的函数调用:

  • 推送接收者:putself,指函数的顶层作用域
  • 推送参数:none 此处
  • 调用 method/function: 函数调用 (FCALL) 到 foo

接下来我们有在获取foo成为全局变量后的设置说明:

0008 pop
0011 putobject_OP_INT2FIX_O_1_C_
0012 setlocal_OP__WC__0 2
0016 getlocal_OP__WC__0 2
0020 leave                                                            ( 253)

要点:当 YARV 手头有完整的源代码时,它知道何时定义局部变量,并像您期望的那样将对局部变量的过早调用视为 FCALL。

现在让我们看一下使用 eval

的 "misbehaving" 版本
def bar_boom
  eval 'bar'     # => nil, but we'd expect an errror
  bar = 1         # => 1
  bar
end

RubyVM::InstructionSequence.disasm(method(:bar_boom)) 更少跟踪的输出:

== disasm: #<ISeq:bar_boom@(irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] bar
0004 putself
0005 putstring        "bar"
0007 opt_send_without_block <callinfo!mid:eval, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0010 pop
0013 putobject_OP_INT2FIX_O_1_C_
0014 setlocal_OP__WC__0 2
0018 getlocal_OP__WC__0 2
0022 leave                                                            ( 264)

我们再次在索引 2 处的局部变量 table 中看到一个局部变量 bar。我们还有以下 eval 指令:

0004 putself
0005 putstring        "bar"
0007 opt_send_without_block <callinfo!mid:eval, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0010 pop

让我们来剖析这里发生的事情:

  • 推送接收器:再次putself,指的是函数的顶级范围
  • 推送参数:"bar"
  • 调用 method/function: 函数调用 (FCALL) 到 eval

之后,我们对 bar 进行了我们期望的标准分配。

0013 putobject_OP_INT2FIX_O_1_C_
0014 setlocal_OP__WC__0 2
0018 getlocal_OP__WC__0 2
0022 leave                                                            ( 264)

如果我们这里没有 eval,Ruby 就会知道将对 bar 的调用视为函数调用,这会像我们之前的那样爆炸例子。但是,由于 eval 是动态求值的,并且直到运行时才会生成其代码的指令,因此求值发生在已经确定的指令和本地 table 的上下文中,它包含幻影 bar 你看到的。不幸的是,在此阶段,Ruby 并不知道 bar 已初始化 "below" eval 语句。

要深入了解,我建议阅读 Ruby Under a Microscope and the Ruby Hacking Guide's 评估部分。