Что такое «синглтон-класс» в Ruby

Наверное каждый разработчик Ruby знает о существовании «синглтон-класса» (англ. Singleton Class, также известен как «метакласс» и «eigenclass»), но при этом не каждый знает что это и какова его роль. В то же время понимание синглтон-класса является важным и полезным, и открывает новые возможности для программиста, особенно в метапрограммировании. Цель данной статьи - дать представление о синглтон-классе, его роли и особенностях.

Не путайте термин «Singleton Class» с термином «Singleton Pattern», обозначающим шаблон проектирования «Одиночка», который также имеет реализацию в Ruby с использованием одноименного модуля Singleton, но это уже другая история.

Итак, приступим. Предположим у нас есть вполне обычный Ruby-класс A и объект этого класса a.

class A
  def foo
    "bar"
  end
end

a = A.new
a.foo # => "bar"

Как известно, для любого Ruby объекта можно определить методы, которые будет иметь только данный конкретный объект, но не будут иметь другие экземпляры того же класса. Такие методы называются «синглтон-методами» и для их определения нужно явно указать какому объекту они принадлежат, подставив объект перед именем метода. Покажем это на примере уже имеющегося у нас объекта a.

# определение синглтон-метода для объекта `a`
def a.singleton_foo
  "singleton_bar"
end

# синглтон-методы доступны только тем объектам, для которых они определены
a.singleton_foo # => "singleton_bar"

# и не доступны другим экземплярам того же класса
a2 = A.new
a2.singleton_foo # => NoMethodError: undefined method `singleton_foo' for #<A:0x0000000001a696f8>

Если вы заметили что-то общее в терминах «синглтон-метод» и «синглтон-класс» и это заставило вас задуматься, то на этот раз интуиция вас не подвела. Эти понятия действительно тесно связаны, а подробности вы найдете далее в статье.

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

Проверим, что класс A действительно ничего не знает о существовании определенного нами для его объекта a синглтон-метода.

A.instance_methods.include?(:foo)           # => true
A.instance_methods.include?(:singleton_foo) # => false

Неужели Ruby нарушает принципы ООП? Возможно где-то и нарушает, но точно не в этом месте. Синглтон-методы хранятся в специальном неявном классе, индивидуальном для каждого объекта в Ruby. Как вы уже догадались, это и есть синглтон-класс. В Ruby имеется метод singleton_class, с помощью которого можно получить ссылку на синглтон-класс любого объекта.

# получаем синглтон-класс объкта `a`
singleton_class_of_a = a.singleton_class # => #<Class:#<A:0x0000000001ab6958>>

# находим потерянный метод
singleton_class_of_a.instance_methods.include?(:singleton_foo) # => true

Теперь вы знаете что такое синглтон-класс и как его получить. Но на этом история не заканчивается, всё самое интересное и сложное впереди. Давайте на данном этапе подведем некоторые промежуточные итоги.

Что нужно запомнить:

  1. В Ruby можно для любого объекта определить методы, которые будут доступны только этому объекту, а не всем экземплярам того же класса. Такие методы называются «синглтон-методами».
  2. Синглтон-методы объекта хранятся в специальном неявном классе этого объекта, который называется «сингтон-классом».
  3. Каждый объект имеет свой индивидуальный синглтон-класс, который можно получить с помощью метода singleton_class.

Классы - тоже объекты

Может быть кто-то до сих пор не обратил на это внимание, но фактически в Ruby абсолютно всё является объектами, даже сами классы. Рассмотрим к примеру класс String. Мы можем пользоваться им как обычным объектом:

  • присвоить его переменной;
  • передать методу как аргумент;
  • получить его уникальный идентификатор с помощью метода object_id;
  • и, самое главное, узнать экземпляром какого класса он является.

# переменная хранит ссылку на класс
string_class = String

# передача класса как аргумент метода
def class_name(klass)
  klass.name
end

class_name(String) # => "String"
# или с использованием переменной, ссылающейся на класс
class_name(string_class) # => "String"

# идентификатор объекта-класса
String.object_id # => 5349120

# экземпляром какого класса является класс String
String.class # => Class

# повеселимся )
String.class.class.class.class.class.class # => Class
Class.class # => Class

В Ruby каждый класс является по сути объектом и сам по себе, как объект, является экземпляром класса Class, который, в свою очередь, как объект-класс, тоже является экземпляром класса Class.

Далее в статье для удобства я буду называть объекты, которые являются классами, «объектами-классами».

Зачем я вам всё это рассказываю и как это связано с синглтон-классами?

Если любой класс является объектом, то для него, по аналогии с обычными объектами, можно определять синглтон-методы. Я вам больше скажу, вы это уже много раз видели и делали сами, даже не осознавая этого. Рассмотрим следующий код:

class A
  def self.foo2
    "bar2"
  end
end

При определении метода на этот раз использовался префикс self.. Вы, конечно, уже знакомы с таким способом определения метода и знаете, что в данном случае метод становится не методом экземпляра, а методом класса.

A.foo2     # => "bar2"
A.new.foo2 # => NoMethodError: undefined method `foo2' for #<A:0x0000000000dde000>

Ключевое слово self, используемое внутри тела класса является ссылкой на этот класс. Таким образом данный код определения метода класса эквивалентен следующему коду:

class A
  def A.foo2
    "bar2"
  end
end

И в данном случае абсолютно ничего не изменится, даже если вынести определение метода из тела класса:

class A
end

def A.foo2
  "bar2"
end

Таким образом мы определили метод с явным указанием его владельца - класса A. Если вы вернетесь в самое начало статьи, то обнаружите, что мы уже проделывали что-то подобное, но тогда владельцем метода являлся не класс, а объект.

def a.singleton_foo
  "singleton_bar"
end

Вы уже догадались к чему я веду? Методы класса являются ничем иным, как синглтон-методами этого класса, если рассматривать класс как объект. В этом можно убедиться с помощью метода singleton_methods, позволяющего получить синглтон-методы объекта.

A.singleton_methods # => [:foo2]

Если класс может иметь синглтон-методы, значит у него должен быть синглтон-класс, где эти методы хранятся? Давайте проверим.

singleton_class_of_A = A.singleton_class
singleton_class_of_A.instance_methods.include?(:foo2) # => true

Да, так и есть.

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

При определении синглтон-методов мы всегда явно указывали их владельцев. Но этого можно не делать, если определять методы в контексте самого синглтон-класса, так же, как методы экземпляра определяются в контексте своего класса. Получить контекст синглтон-класса объекта можно с помощью конструкции class << object, где object - это и есть тот объект, контекст синглтон-класса которого мы хотим получить.

a = A.new

# открываем контекст синглтон-класса объекта `a`
class << a
  # в контексте синглтон-класса не нужно явно указывать объект-владельца метода
  def foo1
    "bar1"
  end

  def foo2
    "bar2"
  end
end

a.foo1 # => "bar1"
a.foo2 # => "bar2"

Вы уже наверняка встречались с такой конструкцией при определении методов класса.

class A
  class << self
    # определение методов класса
    # ...
  end
end

Ключевое слово self в контексте класса указывает на сам класс и приведенный выше код эквивалентен следующему:

class << A
  # определение методов класса
  # ...
end

Подведем итоги второй части статьи.

Что нужно запомнить:

  1. Классы в Ruby можно (и нужно) рассматривать как обычные объекты, экземпляры класса Class.
  2. Ruby-классы, как и любые другие объекты, могут иметь синглтон-методы и, очевидно, имеют синглтон-класс.
  3. Так называемые "методы класса", если рассматривать класс как объект, фактически являются его синглтон-методами и определяются тем же способом - с явным указанием владельца.
  4. Определение синглтон-методов возможно и без явного указания объекта-владельца, если это происходит в контексте синглтон-класса, доступ к которому осуществляется с использованием конструкции class << object.

Иерархия наследования и поиск определения метода

Из теории ООП нам известно, что при вызове метода у объекта, поиск определения данного метода осуществляется сначала в классе, экземпляром которого объект непосредственно является, и далее вверх по иерархии наследования. Так же обстоят дела и в Ruby, но ввиду того, что мы теперь знаем о существовании синглтон-класса, у нас может возникнуть вопрос о том, где его место и как он влияет на алгоритм поиска метода.

Поиск метода экземпляра

Проведем эксперимент, определим два метода с одинаковым именем, один как метод экземпляра, а другой как синглтон-метод. Посмотрим на результат, возвращаемый вызовом метода.

class A
  # метод экземпляра
  def foo
    "bar from class instance method"
  end
end

a = A.new

# синглтон-метод с тем же именем
def a.foo
  "bar from singleton method"
end

a.foo # => "bar from singleton method"

Оказалось, что синглтон-метод переопределил метод экземпляра. Давайте проверим, можно ли при определении синглтон-метода вызвать переопределяемый метод экземпляра с использованием ключевого слова super.

def a.foo
  result_from_parent_method = super
  result_from_parent_method + " and from singleton method"
end

a.foo # => "bar from class instance method and from singleton method"

Синглтон-методы могут переопределять методы экземпляра точно так же, как методы дочернего класса переопределяют методы родительского. Можно предположить, что синглтон-класс встраивается в иерархию наследования, и как бы становится дочерним классом класса своего объекта. В нашем случае синглтон-класс объекта a становится подклассом его класса A. Давайте проверим это.

a.singleton_class.superclass == A # => true
a.kind_of? a.singleton_class      # => true

# Тем не менее:
a.instance_of?(a.singleton_class) # => false
a.instance_of?(A)                 # => true

Получается, так оно и есть: синглтон-класс встраивается в иерархию наследования и участвует в алгоритме поиска метода в качестве первоначального источника, хотя судя по методу instance_of? таковым не является, но это и к лучшему.

Есть, кстати, один полезный метод - ancestors. Он позволяет получить иерархию наследования, т.е. список всех классов, в которых будет осуществляться поиск определения метода. Проверим, подтвердится ли наше только что сделанное открытие, вызвав этот метод у синглтон-класса.

a.singleton_class.ancestors # => [#<Class:#<A:0x00000000014598f8>>, A, Object, Kernel, BasicObject]

В полученном массиве на первом месте стоит синглтон-класс, у которого вызывался метод. Далее за ним следует класс A и все его родительские классы. Это подтверждает то, что синглтон-класс объекта a находится в иерархии наследования класса A и при этом стоит перед ним, на первом месте, являясь его подклассом.

Таким образом, синглтон-класс любого объекта является подклассом класса этого объекта, и объект, хоть и неявно, как бы является его экземпляром, и в нем в первую очередь происходит поиск определения методов.

Поиск метода класса

Я бы не стал рассматривать отдельно поиск методов класса (ведь это синглтон-методы, роль которых в алгоритме поиска метода мы уже рассмотрели в контексте обычных объектов), если бы не один нюанс. Дело в том, что классы, помимо того, что ведут себя как обычные объекты, могут быть связаны механизмом наследования. Рассмотрим два класса, один из которых является подклассом другого.

class A; end

class B < A; end

У родительского класса определим метод класса (или синглтон-метод).

def A.foo
  "bar from A singleton method"
end

A.foo # => "bar from A's singleton method"

Мы определили синглтон-метод у класса A, который является самостоятельным объектом, отличным от B, хоть они и связаны, как классы, механизмом наследования. Более того, метод был объявлен и определен после определения самих классов. Можно предположить, что класс B не должен ничего знать о синглтон-методе, определенном для класса A. Но мы знаем, что в Ruby методы класса, также как и методы экземпляра, наследуются.

B.foo # => "bar from A's singleton method"

Как это объяснить с точки зрения теории синглтон-класса и синглтон-методов?

Давайте попытаемся спроецировать наши знания о том, каким образом синглтон-класс встраивается в алгоритм поиска метода в случае обычных объектов, на классы. При вызове метода foo у класса B сначала происходит поиск метода среди его синглтон-методов. Но мы определяли синглтон-метод у класса A, поэтому в синглтон-классе класса B его по идее быть не должно. Затем происходит попытка поиска метода в классе класса B, коим является Class. Ну там то точно нашего сингтон-метода не может быть. Где же он тогда?

Нам не придётся долго мучиться в поисках ответа, нам поможет метод ancestors. Получим иерархию наследования синглтон-класса класса B.

B.singleton_class.ancestors # => [#<Class:B>, #<Class:A>, #<Class:Object>,
                                  #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

На первом месте стоит синглтон-класс класса B (#<Class:B>). Обратите внимание на классы, идущие далее по цепочке. Это синглтон-класс класса A (#<Class:A>) и синглтон-классы его родительских классов Object (#<Class:Object>) и BasicObject (#<Class:BasicObject>).

Оказывается, когда в Ruby наследуются классы, их синглтон-классы также наследуются. Когда класс B унаследовал класс A, синглтон-класс класса B также унаследовал синглтон-класс класса A, а последний, в свою очередь, унаследовал синглтон-класс родительского класса Object, являющегося общим родительским классом всех классов Ruby, и так далее.

Теперь понятно как объявленный у класса A синглтон-метод нашелся у класса B. Сначала был произведен поиск метода в синглтон-классе класса B и, в соответствии с принципом наследования, во всех его родительских классах, среди которых был синглтон-класс класса A, где и находился определенный нами синглтон-метод.

Сформулируем выводы по 3 части статьи.

Что нужно запомнить:

  1. Синглтон-класс любого объекта фактически является подклассом класса этого объекта и может переопределять его методы.
  2. Синглтон-класс замыкает иерархию наследования, в нем в первую очередь происходит поиск определения метода (если конечно в сам синглтон-класс ничего не подмешали).
  3. При наследовании классов их синглтон-классы также наследуют друг друга, таким образом дочерние классы наследуют синглтон-методы своих родительских классов.

Применение знаний о синглтон-классе

В заключение приведу пару примеров того, как можно применить знания о синглтон-классе на практике. Мы можем динамически изменять поведение отдельных объектов, расширяя или модифицируя их синглтон-класс таким же образом, как это делается с обычными классами, например с помощью подмешивания модуля. Модификация синглтон-класса даёт возможность изменять поведение конкретного объекта, не оказывая при этом влияния на все другие объекты того же класса.

Предположим, что у нас есть следующие класс и модуль.

class Product
  attr_reader :price

  def initialize(price)
    @price = price.to_f
  end
end

module ProductWithDiscount
  DISCOUNT_PERCENTAGE = 10

  def self.included(base)
    base.class_eval do
      alias original_price price

      def price
        original_price - discount
      end
    end
  end

  def discount
    return 0 if original_price == 0
    original_price * DISCOUNT_PERCENTAGE / 100
  end
end

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

# до включения модуля
Product.new(1000).price # => 1000.0

Product.include ProductWithDiscount

# после включения модуля
Product.new(1000).price # => 900.0

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

product = Product.new(1000)
product.price # => 1000.0

# изменение конкретного объекта
product.singleton_class.include ProductWithDiscount
product.price # => 900.0

# другие экземпляры класса не изменены
Product.new(1000).price # => 1000.0

Следующий пример демонстрирует модификацию экземпляра через его синглтон-класс с помощью Proc-объекта и метода class_exec.

class Product
  attr_reader :price

  def initialize(price)
    @price = price.to_f
  end
end

extension =
  proc do |percentage|
    alias original_price price

    def price
      original_price - discount
    end

    define_method(:discount) do
      return 0 if original_price == 0
      original_price * percentage / 100
    end
  end

product1 = Product.new(1000)
product2 = Product.new(1000)

product1.price # => 1000.0
product2.price # => 1000.0

product1.singleton_class.class_exec(10, &extension)
product2.singleton_class.class_exec(50, &extension)

product1.price # => 900.0
product2.price # => 500.0

Благодарю за внимание! Надеюсь данная статья была для вас полезной. Если у вас есть какие-либо вопросы или комментарии, вы можете оставить их ниже, я буду рад на них ответить.

3
Комментарии (0)

Неизвестный пользователь Войти