Почему отладчик 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 Ruby все еще знает, что bar действительно является методом, и он все еще может вызвать его в строке #8. Тогда l ine #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, локальную таблицу, в которой хранятся сведения о аргументах метода и всех локальных переменных в методе, а таблица catch, которая содержит информацию о спасениях внутри метод, если он есть.

Основной причиной этой проблемы является то, что, поскольку вы динамически компилируете код во время выполнения с помощью eval, Ruby передает локальную таблицу, которая включает в себя неустановленную переменную 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:[email protected](irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: [email protected], 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: [email protected], 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, ссылаясь на область действия верхнего уровня
  • Push аргументы: нет здесь
  • Вызвать метод/функцию: вызов функции (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)

Key takeaway: когда 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:[email protected](irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: [email protected], 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

Позвольте рассказать, что здесь происходит:

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

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