Почему отладчик 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 по оценке.