Почему отладчик Ruby возвращает значения, отличные от кода во время выполнения?

Посмотрите на этот простой класс Ruby:

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

При запуске этого класса в консоли отладчика можно наблюдать следующее поведение:

    $ 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:

В точке останова отладчик возвращает следующие значения:

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

Обратите внимание, что - хотя точка останова отладчика находится в линии #5 - он уже знает, что будет локальная переменная bar определяется в строке #10 это будет затенять метод bar и отладчик на самом деле больше не может вызвать bar метод. На данный момент не известно, что строка 'local string' будет назначен на bar, Отладчик возвращает nil за bar,

Давайте продолжим с исходным кодом в файле Ruby и посмотрим на его вывод:

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

Во время выполнения в очереди #7 Руби до сих пор знает, что bar это действительно метод, и он все еще может вызывать его в строке #8, Тогда я #10 на самом деле определяет локальную переменную, которая скрывает метод с тем же именем, и поэтому Ruby возвращает, как ожидается, в строке #12 а также #13,

Вопросы: почему отладчик возвращает значения, отличные от исходного кода? Кажется, что он может смотреть в будущее. Это считается особенностью или ошибкой? Это поведение задокументировано?

1 ответ

Решение

Всякий раз, когда вы заходите в сеанс отладки, вы эффективно выполняете 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, локальную таблицу, в которой хранится информация об аргументах метода и всех локальных переменных в методе, и таблицу перехвата, которая включает информацию об спасениях внутри метода, если таковые имеются.

Основной причиной этой проблемы является то, что, поскольку вы динамически компилируете код во время выполнения с eval Ruby также передает в eval локальную таблицу, в которую входит неустановленная переменная enry.

Для начала давайте воспользуемся очень простым методом, который демонстрирует ожидаемое нами поведение.

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

Мы можем проверить это путем извлечения байтового кода YARV для существующего метода с RubyVM::InstructionSequence.disasm(method), Обратите внимание, что я собираюсь игнорировать вызовы трассировки, чтобы сохранить инструкции аккуратными.

Выход для 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]. Если бы у нас были другие локальные переменные и аргументы, они также появились бы в этой таблице.

Далее у нас есть инструкции, сгенерированные, когда мы пытаемся позвонить foo перед его назначением:

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

Давайте рассмотрим, что здесь происходит. Ruby компилирует вызовы функций для YARV по следующей схеме:

  • Push-приемник: putself, ссылаясь на область действия верхнего уровня
  • Толковые аргументы: здесь нет
  • Вызовите метод / функцию: вызов функции (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

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)

Снова мы видим локальную переменную, bar в таблице locals с индексом 2. У нас также есть следующие инструкции для eval:

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

Давайте рассмотрим, что здесь происходит:

  • Нажмите приемник: снова putself, ссылаясь на область действия верхнего уровня
  • Нажмите аргументы: "бар"
  • Вызовите метод / функцию: вызов функции (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 здесь, Руби знал бы, чтобы обработать призыв к bar как вызов функции, который взорвался бы, как это было в нашем предыдущем примере. Тем не менее, так как eval динамически оценивается, и инструкции для его кода не будут генерироваться до времени выполнения, оценка происходит в контексте уже определенных инструкций и локальной таблицы, которая содержит фантом bar что ты видишь. К сожалению, на данном этапе Ruby не знает, что bar был инициализирован "ниже" оператора eval.

Для более глубокого погружения я бы рекомендовал прочитать Ruby Under the Microscope и раздел Ruby Hacking Guide по оценке.

Другие вопросы по тегам