Как работает constant lookup в `#instance_eval`
Недавно плотно позанимался с методом BasicObject#instance_eval
(полезный для построение DSL-ей в Ruby) и сделал для себя парочку
открытий.
У меня создалось впечатление, что вместо принципа “наименьшего сюрприза”
разработчики здесь использовали подход “а давайте всех удивим” или
“пусть никто не догадается, как это работает”. Особенно меня порадовало,
как работает поиск констант в коде, который выполняется
#instance_eval
‘ом. Не нашел ничего об этом в документации да и в
интернете ничего путного не нагуглилось. Поэтому опишу здесь как
#instance_eval
, и особенно constant lookup, работает. Уточню, что
речь идет о Ruby 3.1.
Что мы знаем об #instance_eval
Давайте вспомним, как #instance_eval
должен работать. Согласно
документации
#instance_eval
выполняет код в контексте объекта, а именно:
- подменяет self - теперь это сам объект - и
- дает доступ к instance variables объекта
Также можно вызывать методы объекта, в том числе приватные, не указывая receiver‘а.
class KlassWithSecret
def initialize
@secret = 99
end
private
def the_secret
"Ssssh! The secret is #{@secret}."
end
end
k = KlassWithSecret.new
k.instance_eval { @secret } #=> 99
k.instance_eval { the_secret } #=> "Ssssh! The secret is 99."
k.instance_eval {|obj| obj == self } #=> true
Метод #instance_eval
принимает:
- блок
- или строчку кода
k.instance_eval "@secret" #=> 99
k.instance_eval "the_secret" #=> "Ssssh! The secret is 99."
Блок выполняется в контексте, где он определен, - это обычное поведение блока как замыкания. То есть все кроме self и instance variables приходит из этого контекста - а это константы, локальные переменные и class variables.
В отличие от блока, строка с кодом не привязана сама по себе к
конкретному месту в коде, объекту или классу. И ожидаемо, она
выполняется в контексте, где вызван сам метод #instance_eval
(т.е то
место, где стоит вызов obj.instance_eval(...)
).
Class variables lookup
С class variables все просто - class variable lookup прямолинейно
проходится по иерархии классов объекта, где вызван #instance_eval
(caller). Именно caller‘а, а не receiver‘а. Помним же, что от
receiver‘а приходят только self и instance variables. Поэтому
остается только caller.
В следующем примере class variable находится в родительском классе вызывающего объекта:
class A
@@foo = "class variable in class A"
end
class B < A
def get_class_variable
"".instance_eval "@@foo"
end
end
B.new.get_class_variable # => "class variable in class A"
А вот с константами все сложнее.
Constant lookup
Напомню логику constant lookup в Ruby:
- Ruby начинает искать константу в текущем lexical scope (классе или модуле)
- если не находит - тогда проверяет внешние lexical scope‘ы.
module A
FOO = 'I am defined in module A'
module B
module C
def self.get_constant
FOO
end
end
end
end
A::B::C.get_constant # => "I am defined in module A"
Далее Ruby ищет константу в иерархии наследования - проверяет текущий
класс, затем родительский класс итд до Object
и BasicObject
.
class A
FOO = 'I am defined in class A'
end
class B < A
def get_constant
FOO
end
end
B.new.get_constant # => "I am defined in class A"
Constant lookup в #instance_eval
Если #instance_eval
вызван с блоком - константы ищутся в контексте,
где определен блок. Но когда #instance_eval
вызван со строкой… Здесь
и начинается самое интересное.
object.instance_eval("FOO")
В случае с #instance_eval
возникает неоднозначность - появляются два
потенциальных источника констант - вызывающий объект (caller) и объект, на
котором вызван #instance_eval
(receiver), а точнее:
- lexical scope‘ы и иерархия классов объекта, где
#instance_eval
вызван (caller scope) с одной стороны, - и иерархия классов объекта, на котором
#instance_eval
вызван (receiver), с другой.
И непонятно чего ждать от Ruby. Непонятно даже как это должно работать.
Разработчики Ruby разрешили неоднозначность любопытным образом - Ruby
использует и caller и receiver. При вызове #instance_eval
со строкой
стандартный механизм constant lookup расширяется и Ruby ищет константу
в следующих местах и следующем порядке:
- singleton class receiver‘а
- класс receiver‘а
- caller lexical scope
- внешние lexical scope‘ы для caller scope‘а
- класс receiver‘а (опять)
- цепочка наследования класса receiver‘а
Пример
Рассмотрим на примере.
module M1
module M2
class A
end
class B < A
def foo(obj)
obj.instance_eval "FOO"
end
end
end
end
module M3
module M4
class C
end
class D < C
end
end
end
Если вызвать метод foo
, то:
- receiver - это объект класса
D
, который наследует классC
и вложен в модулиM4
иM3
(внешние lexical scope‘ы) - caller scope - это класс
B
, который наследует классA
и вложен в модулиM2
иM1
.
object = M3::M4::D.new
M1::M2::B.new.foo(object)
При поиске константы FOO
классы и модули будут проверяться в следующем
порядке:
- singleton class receiver‘а (
object
) D
- класс receiver‘аB
- caller scopeM2
- внешний lexical scope для классаB
M1
- внешний lexical scope для модуляM2
D
- класс receiver‘аC
- родительский класс классаD
Наглядно это выглядит так:
module M1
# 4
module M2
# 3
class A
end
class B < A # caller
# 2
def foo(obj)
obj.instance_eval "FOO"
end
end
end
end
module M3
module M4
class C
# 6
end
class D < C # receiver
# 1, 5
end
end
end
Наблюдения
Замечу, что здесь игнорируются lexical scope‘ы класса receiver‘а
(модули M4
и M3
) и иерархия наследования класса caller scope‘а
(класс A
).
Еще один момент - механизм constant lookup в #instance_eval
в Ruby
3.0 и ранее немного отличается. В Ruby 3.1 (как описано выше) поиск начинается с
singleton class‘а receiver‘а, а затем идет класс receiver‘а и
caller lexical scope. В Ruby 3.0 второй шаг пропускался - после
singleton class‘а receiver‘а проверялся сразу caller lexical scope. А
класс receiver‘а проверялся уже после цепочки lexical scopes, как
часть иерархии наследования для receiver‘а.
PS
Ситуация с #instance_eval
ожидаемая - получилось как получилось.
Логика странная и сложная. Нигде не описана и меняется в минорных
версиях Ruby. Трудно представить (и негде прочитать) мотивы
разработчиков.
Возникают также вопросы:
- а зачем ставить singleton class и класс receiver‘а на первое место?
- и почему игнорируется иерархия классов caller‘а? Почему бы и там еще не поискать?