Как работает 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 scope
  • M2 - внешний 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‘а? Почему бы и там еще не поискать?

Ссылки