Ruby — очень сложный язык программирования. Он невероятно красивый и читабельный, однако у него есть множество тем и особенностей, которые могут оставаться "темным лесом" даже для опытного Ruby-разработчика. Одной из таких тем является поиск констант.


Несмотря на заголовок, гнева в посте не будет.


Целью этого поста не является детальное объяснение алгоритма поиска. Я бы сказал, что целью является привлечение внимания разработчиков к теме. Отчасти, это просто крик души.


Пример


Я рассмотрю один небольшой пример. Для начала определим несколько констант:


module M
  A = 'm'
end

module Namespace
  A = 'ns'
  class C
    include M
  end
end

У нас есть один миксин M, модуль Namespace и принадлежащий ему класс C. В модулях определенно по константе A, которые мы и будем искать.


Как думаете, что выведет следующий код? Я помещу ответы ниже, чтобы они не бросались в глаза.


puts Namespace::C::A

module Namespace
  class C
    puts A
  end
end

Теперь давайте определим пару методов:


module M
  def m
    A
  end
end

module Namespace
  class C
    def f
      A
    end
  end
end

class Namespace::C
  def g
    A
  end
end

x = Namespace::C.new
puts x.f
puts x.g
puts x.m

Как думаете, есть ли между ними разница?


Ответы


Вот полный код нашего примера с ответами в комментариях:


module M
  A = 'm'
end

module Namespace
  A = 'ns'
  class C
    include M
  end
end

puts Namespace::C::A # m

module Namespace
  class C
    puts A # ns
  end
end

module M
  def m
    A
  end
end

module Namespace
  class C
    def f
      A
    end
  end
end

class Namespace::C
  def g
    A
  end
end

x = Namespace::C.new
puts x.f # ns
puts x.g # m
puts x.m # m

Т.е. выводом программы будет:


m
ns
ns
m
m

Мини-объяснение


Кратко говоря, поиск констант происходит в несколько этапов:


  1. Поиск в т.н. лексической области видимости. Т.е. поиск будет происходить в зависимости от того, в каком месте определена текущая строчка кода. Например, в самом первом выводе интерпретатор находится на верхнем уровне (top-level) и выводит константу Namespace::C::A, а во втором выводе он сначала входит в модуль Namespace, потом входит в класс C и только тогда делает puts. Подробнее об этом можно узнать, почитав про вложенность (nesting), в частности, метод Module.nesting.
  2. Если первый этап не был успешным, то интерпретатор начинает "опрос" миксинов и родительских классов. Для каждого из опрошенных на первом этапе модулей.
  3. Если предыдущий этап не дал результатов, проверяется верхний уровень (top-level). На самом деле, можно опустить этот пункт, т.к. он по сути включается во второй, т.к. top-level — это класс Object
  4. На этом этапе константа считается ненайденной и вызывается метод const_missing по аналогии с method_missing. Полагаю, этот метод и утилизируется в Ruby on Rails для автозагрузки и перезагрузки кода.

Таким образом:


# Мы на верхнем уровне.
# На первом этапе проверяется только С
# На втором этапе константа находится внутри M
puts Namespace::C::A # m

module Namespace
  class C
    # Мы в Namespace -> Namespace::C
    # На первом этапе константа находится внутри Namespace
    puts A # ns
  end
end

module M
  def m
    # Мы находимся внутри M. На первом же этапе константа найдена
    A # m
  end
end

module Namespace
  class C
    def f
      # Мы находимся в Namespace -> Namespace::C
      A # ns
    end
  end
end

class Namespace::C
  def g
    # Мы находимся в Namespace::C (в модуль Namespace мы не входили)
    # Первый этап не увенчается успехом
    # На втором этапе мы находим нужную константу в миксине
    A # m
  end
end

Заключение


Можно сказать, Ruby заставляет нас при написании в коде констант вычислять их значение относительно написанного кода, а не относительно контекста выполнения (очень странно звучит, простите).


Ruby style guide определяет одно хорошее правило:
определять и переоткрывать вложенные классы/модули нужно явным образом. Т.е. никогда не нужно писать class A::B. Этого простого правила достаточно, чтобы избегать сюрпризов и в большинстве случаев не задумываться о поиске констант вовсе.


Что можно почитать:



Update


Пользователь DsideSPb дал полезный комментарий о дополнительной особенности поиска констант. Правда, она была удалена в последнем (2.5.0) релизе.


Лично я не знаю всех деталей, но при некоторых обстоятельствах при указании неправильного пути до константы интерпретатор может подменить ее на таковую из top-level. Однако работает это далеко не во всех случаях:


# 1.rb
class A; end
class B; end
A::B # вернет B, но выдаст предупреждение

# 2.rb
class A; end
module M; end
A::M # ==> M с предупреждением
M::A # ==> NameError

Комментарии (10)


  1. Nondv Автор
    22.01.2018 19:29
    +1

    А еще я ненавижу маркдаун хабра (хабрдаун? О__О), который заставляет меня удалять переносы строк :(


  1. l1tero
    22.01.2018 23:11

    Эм… Если честно, то я не понял в чем проблема? Переопределение констант — плохая идея изначально и вполне логично то, что при переопределении они зависят от выполняемого кода.


    1. Nondv Автор
      22.01.2018 23:14

      ни о каком переопределении речь не идет. Где Вы это в посте увидели?


      Речь идет о коллизии имен, которая происходит потому, что в Ruby не обязательно указывать полный путь до константы.


      module M1
        A = :m1
        module M2
          A = :m2
        end
      end
      
      M1::A # ==> :m1
      M1::M2::A # ==> :m2
      
      module M1
        puts A # which one?
        module M2
          puts A # and this?
        end
      end


      1. l1tero
        22.01.2018 23:38

        module M
          A = 'm'
        end
        
        module Namespace
          A = 'ns'
          class C
            include M
          end
        end
        

        Ну как по мне, то include как раз и переопределит одноименную константу, указанную выше, что как мне кажется вполне логично.


        1. Nondv Автор
          22.01.2018 23:54

          module M
            A = 'm'
          end
          
          module Namespace
            A = 'ns'
            class C
              include M
              puts A
            end
          end

          Выведет ns. Если бы Вы внимательно прочитали пост, то знали бы это ;)


          Опять же, не используйте слово "переопределит". В английском языке это называется, если не ошибаюсь, "shadowing", т.е. они могут "перекрывать" друг друга. Константы Namespace::A и M::A друг другу не мешают. Вопрос в том, на какую из них будет ссылаться просто A


  1. Nondv Автор
    22.01.2018 23:52

    deleted


  1. DsideSPb
    23.01.2018 00:34
    +1

    Забавный факт: в недавно (месяц назад) выпущенном Ruby 2.5 кусок алгоритма, представленный вами в п. 3 ("проверяется верхний уровень") отпилен.


    1. Nondv Автор
      23.01.2018 00:50

      Прекрасное дополнение! Совсем вылетело это из головы.


      Однако, это не совсем то, о чем я говорил (хотя стоило это добавить).
      Фича, которую выпилили, подменяла константу в случае, если был указан неправильный путь к ней. Причем, там все достаточно хитро. Например:


      # 1.rb
      class A; end
      class B; end
      A::B # вернет A, но выдаст предупреждение
      
      # 2.rb
      class A; end
      module M; end
      M::A # ==> NameError

      Эта фича срабатывает только при явных ошибках в коде (явное указание неправильного пути к константе) и действительно является поводом ненависти:) Хотя лично я проблемы с ней встречал всего пару раз в каком-то легаси коде.


      Anyway, спасибо огромное за ценный комментарий


      1. Nondv Автор
        23.01.2018 00:55

        опечатался. A::B вернет B


      1. DsideSPb
        23.01.2018 01:34

        Точно. То есть, да, отпилен лишь частично. Похоже, что только для случаев, когда в качестве контекста поиска явно указан класс (а для модулей это и так не работало). Тесткейс.


        Никак не заставлю себя открыть "Ruby Under a Microscope", там наверняка про это есть :/