Почему отладчик 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, он уже знает, что в строке #10 будет определена локальная переменная bar, которая затеняет метод bar, и отладчик фактически больше не может вызывать метод bar. На данный момент неизвестно, что строка 'local string' будет присвоена bar. Отладчик возвращает nil для bar.

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

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

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

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


person spickermann    schedule 09.03.2017    source источник
comment
Я бы не сказал, что это Руби, но byebug. Если вы заключите его в rescue NoMethodError и puts defined?(bar) до и после присвоения, вы увидите, что это "method" перед этой строкой и "local-variable" после нее.   -  person ndnenkov    schedule 09.03.2017
comment
Обратите внимание, что двойная стрелка Byebug уже указывает на строку 12, а не 11.   -  person Stefan    schedule 09.03.2017
comment
@Stefan, я хоть и такой же, но не то. Если вы поместите другой оператор, который что-то делает, на отдельной строке между ними, вы все равно получите тот же результат.   -  person ndnenkov    schedule 09.03.2017
comment
@ndn тот же результат означает, что Byebug сообщает "local-variable", тогда как p defined?(bar) перед назначением будет печатать "method"?   -  person Stefan    schedule 09.03.2017
comment
@ndn У меня была такая же мысль о byebug, но отладчик Ruby показывает такое же поведение. Перетащите код в test.rb (убрав ошибку). Затем запустите ruby -r debug test.rb. В точке останова проверьте локальные переменные с помощью v l, и bar => nil действительно появится   -  person gwcodes    schedule 09.03.2017
comment
@ Стефан, точно.   -  person ndnenkov    schedule 09.03.2017
comment
@gwcodes Ruby распознает bar как локальную переменную в начале метода (local_variables вернет [:bar]). Но он обрабатывается как таковой только после присваивания, т.е. bar преобразуется в метод заранее, а затем в локальную переменную.   -  person Stefan    schedule 09.03.2017
comment
@gwcodes, действительно, это не byebug специфично. Воспроизводится также с binding.irb из 2.4.0.   -  person ndnenkov    schedule 09.03.2017
comment
@ Стефан, кажется, ты прав. Вы знаете, появилось ли это что-то в новых версиях Ruby? Вся моя жизнь была ложью! xd   -  person ndnenkov    schedule 09.03.2017
comment
@ndn мистическое на стадии парсера. На самом деле я понятия не имею, как это работает. Есть ли хорошая книга о внутренностях Ruby?   -  person Stefan    schedule 09.03.2017
comment
@ Стефан: да, есть. Подберите копию рубина под микроскопом.   -  person Sergio Tulentsev    schedule 09.03.2017
comment
@SergioTulentsev, я слышал об этом, но никогда не читал. Развернута ли там эта конкретная тема?   -  person ndnenkov    schedule 09.03.2017
comment
Книга @SergioTulentsev прибывает завтра, спасибо! :-)   -  person Stefan    schedule 09.03.2017
comment
@ndn: прошло много времени с тех пор, как я его читал, но я помню, что он описывает синтаксический анализ и выполнение метода наиболее подробно. Вплоть до кодов операций. :)   -  person Sergio Tulentsev    schedule 09.03.2017
comment
@SergioTulentsev, круто, я тоже пойму. :)   -  person ndnenkov    schedule 09.03.2017
comment
(они должны мне за это заплатить!)   -  person Sergio Tulentsev    schedule 09.03.2017
comment
@ndn: поздравляю с золотым рубиновым значком :)   -  person Sergio Tulentsev    schedule 09.03.2017
comment
@SergioTulentsev, спасибо. (:   -  person ndnenkov    schedule 09.03.2017
comment
Я просто брошу его сюда: bugs.ruby-lang.org/issues/10314   -  person Aleksei Matiushkin    schedule 09.03.2017
comment
Я переработал вопрос, чтобы было понятнее. Часть об исключении вводила в заблуждение и вызывала путаницу, потому что на самом деле исключение не имело ничего общего с проблемой, вызванной отладчиком.   -  person spickermann    schedule 09.03.2017
comment
@spickermann, вы можете еще больше упростить вопрос. Я думаю, что суть этого заключается в этом. Насколько я могу судить, отладчики просто работают с данной привязкой.   -  person ndnenkov    schedule 10.03.2017
comment
Таким образом, похоже, что это проблема не отладчика, а проблема с binding. Из документации: Объекты класса Binding инкапсулируют контекст выполнения в определенном месте кода и сохраняют этот контекст для использования в будущем. Переменные, методы, значение self и, возможно, блок итератора, к которому можно получить доступ в этом контексте, все сохраняются. Это кажется не на 100% правильным, потому что ist не является в каком-то конкретном месте в код (привязка знает больше, чем место в коде), а также методы и переменные точно не сохраняются. Следует ли считать это ошибкой?   -  person spickermann    schedule 10.03.2017
comment
@spickermann, я бы не сказал, что описание неточное. Это зависит от вашей интерпретации. Место в коде не обязательно означает строку в коде. Сомневаюсь, что это баг, просто дырявые внутренности. Это не регресс, начиная с версии 1.8.7 вы по-прежнему получаете то же поведение.   -  person ndnenkov    schedule 10.03.2017
comment
@spickermann Это проблема не с binding, а с eval, и нет, это не ошибка: просто невероятно сложно предотвратить такое поведение. Код сначала оценивается в байт-код YARV и таблицу локальных переменных, а затем во время выполнения eval() выполняется в контексте существующего байт-кода и таблицы локальных переменных, в которой хранится ссылка на локальную переменную, которую вы видите.   -  person fny    schedule 16.03.2017
comment
Обновленный с помощью нескольких дампов инструкций, я могу понять, почему это происходит, но я не могу сказать, является ли это преднамеренным или понятным поведением.   -  person fny    schedule 20.03.2017


Ответы (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, Ruby знал бы, что обращается с вызовом bar как с вызовом функции, которая взорвалась бы, как в нашем предыдущем примере. Однако, поскольку eval оценивается динамически и инструкции для его кода не будут сгенерированы до времени выполнения, оценка происходит в контексте уже определенных инструкций и локальной таблицы, которая содержит фантом bar, который вы видите. К сожалению, на данном этапе Ruby не знает, что bar был инициализирован «ниже» оператора eval.

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

person fny    schedule 14.03.2017