Блог 7even

про ruby, rails, sinatra, git и всё на свете

Объектная модель Ruby

Данный опус является моей попыткой собрать воедино все принципы, согласно которым работает объектная модель в языке Ruby. Цель статьи в том, чтобы читатель увидел логику там, где раньше видел “магию”.

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

Основы

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

1
2
3
4
5
6
7
8
class MyClass
  def my_method
    # some code
  end
end

obj = MyClass.new
obj.my_method

Традиционно в ООП используется наследование классов - методы класса-родителя доступны объектам дочерних классов (а также их дочерних классов итд). Разумеется, в ruby этот механизм тоже не забыт:

1
2
3
4
5
6
7
8
9
10
11
class Parent
  def some_method
    'Parent#some_method'
  end
end

class Child < Parent
end

obj = Child.new
obj.some_method # => "Parent#some_method"

Однако, множественное наследование не поддерживается: у каждого класса может быть только один родительский класс - за исключением BasicObject (в 1.8 и ранее Object), который является корнем иерархии и не имеет родителя. В случаях, когда нужно предоставить какое-то кол-во методов нескольким классам, но нежелательно пользоваться наследованием (скажем, когда нужно наследоваться от какого-то другого класса), удобно использовать механизм примесей (mixins). Это происходит следующим образом: в модуле определяются методы, потом определенный класс подключает этот модуль - и объекты класса получают все методы, определенные в модуле. При этом кол-во включаемых в класс модулей ограничено только здравым смыслом.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module MyFirstModule
  def first_module_method
    'MyFirstModule#first_module_method'
  end
end

module MySecondModule
  def second_module_method
    'MySecondModule#second_module_method'
  end
end

class MyClass
  include MyFirstModule
  include MySecondModule

  def method_from_class
    'MyClass#method_from_class'
  end
end

obj = MyClass.new
obj.first_module_method   # => "MyFirstModule#first_module_method"
obj.second_module_method  # => "MySecondModule#second_module_method"
obj.method_from_class     # => "MyClass#method_from_class"

Вызов метода

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

Объект содержит какое-то количество инстанс-переменных (например, объект класса Person может иметь инстанс-переменную @name) и ссылку на свой класс.

Класс содержит определенное количество инстанс-методов (так я буду называть методы, которые класс предоставляет своим объектам и объектам дочерних классов, чтобы не путать их с классовыми методами - такими как Time.now), некоторое число подключенных модулей и ссылку на родительский класс.

Посмотрим, что происходит, когда вызывается определенный метод:

  1. определяется объект-получатель вызова
  2. в иерархии классов ищется сам метод

Начнем с получателя. В любом участке кода всегда есть текущий объект - его возвращает псевдопеременная self. У текущего объекта есть два основных значения: при обращении к инстанс-переменным ruby будет искать их в текущем объекте, и при вызове метода без указания получателя он будет вызван на текущем объекте. При вызове метода с указанием получателя, соответственно, получателем будет указанный объект.

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

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

Если же метод так и не был найден, будет вызван метод method_missing с параметрами, первым из которых будет название вызванного метода, далее аргументы, переданные при вызове, и блок при его наличии. Вызов происходит по той же схеме - сначала method_missing ищется в классе объекта, потом в его родителе итд. Стандартный BasicObject#method_missing выбрасывает NoMethodError, если вызов добирается до него.

Прокси-классы

Но как в эту схему вписываются модули, подключенные в классы из цепочки наследования? Их методы также должны быть видимы объекту класса, который стоит ниже в цепи (если считать, что BasicObject стоит на самом верху).

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module M1
  def method
    'M1#method'
  end
end

module M2
  def method
    'M2#method'
  end
end

class C
  include M1
  include M2
end

# при вызове #method будет найден нижний модуль в цепочке наследования
# а так как M2 был включен последним, он будет стоять прямо над C
C.new.method # => "M2#method"

# также можно посмотреть всю цепочку с помощью метода Module#ancestors
C.ancestors # => [C, M2, M1, Object, Kernel, BasicObject]

Синглтон-классы

В ruby есть специальная форма определения метода: def object.method_name. Созданный таким образом метод называется синглтон-методом - он определен только для этого конкретного объекта.

1
2
3
4
5
6
7
8
name = 'Vasya'
def name.spacify
  self.split('').join(' ')
end

name.spacify    # => "V a s y a"
# другие объекты класса String этот метод не получают
"Petya".spacify # => NoMethodError: undefined method `spacify' for "Petya":String

Именно благодаря этой особенности в ruby работают классовые методы: каждый класс является объектом класса Class, который предоставляет лишь необходимый минимум методов - new, superclass итд. Если же нам нужно определить классовый метод (который будет инстанс-методом с точки зрения класса Class), обычно мы задаем его следующим образом:

1
2
3
4
5
class MyClass
  def self.class_method
    'MyClass.class_method'
  end
end

Разумеется, в данном случае метод class_method становится доступен только MyClass, и никакому другому классу (на самом деле он также будет доступен дочерним классам MyClass, но об этом чуть позже).

Часто для определения сразу нескольких классовых методов используется конструкция class << self внутри определения класса; в этом случае методы, определенные в обычной форме (def method_name), становятся классовыми методами. То же самое применимо и к обычным объектам - в общем случае конструкция выглядит как class << object:

1
2
3
4
5
6
7
8
9
10
11
12
13
array = [1, 2, 3]
class << array
  def thousands
    self.map { |item| item * 1_000 }
  end

  def millions
    self.map { |item| item * 1_000_000 }
  end
end

array.thousands # => [1000, 2000, 3000]
array.millions  # => [1000000, 2000000, 3000000]

Это все может выглядеть как магия, но на самом деле ruby руководствуется единой логикой. Посмотрим внимательнее, что тут происходит.

Как уже говорилось выше, объект - это просто группа инстанс-переменных и ссылка на класс, который в свою очередь содержит некоторое кол-во инстанс-методов. Но синглтон-методы видны только одному конкретному объекту, поэтому не могут находиться в классе объекта; в то же время метод не может находиться в объекте, так как только классы и модули могут содержать методы (предоставляя их своим объектам и объектам дочерних классов).

Решением этой загадки является синглтон-класс (также встречается термин eigenclass). Это эдакая прослойка между объектом и его номинальным классом (тем самым, который можно получить через object.class). Все синглтон-методы объекта попадают в его синглтон-класс, и уже он является “реальным” классом объекта. В то же время, чтобы не терять возможность вызывать инстанс-методы, определенные в номинальном классе, синглтон-класс наследуется от него, поэтому поиск метода при его вызове начинается с синглтон-класса, дальше поднимается в номинальный класс, в его родителя и дальше вверх до BasicObject.

1
2
3
4
5
6
7
8
9
obj = 3..5
obj.class                       # => Range
# метод Kernel#singleton_class возвращает синглтон-класс объекта
# (добавлен в ruby 1.9.2)
obj.singleton_class             # => #<Class:#<Range:0x007f8fa418f930>>
# хак для обращения к синглтон-классу для версий < 1.9.2
class << obj; self; end         # => #<Class:#<Range:0x007f8fa418f930>>
# родительский класс синглтон-класса = номинальный класс исходного объекта
obj.singleton_class.superclass  # => Range

Наследование синглтон-классов классов устроено несколько сложнее. Классы наследуют у своих родителей не только инстанс-методы, но и классовые методы - но, как мы знаем, они содержатся в синглтон-классе класса, который при вышеописанной схеме наследования не попадает в цепочку. Поэтому в ruby синглтон-класс класса наследуется от синглтон-класса его родителя. Звучит немного запутанно, поэтому лучше посмотреть на примере:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class C
  # родитель не указан - значит класс наследуется от Object
  def self.my_class_method
    'C.my_class_method'
  end
end

class D < C
  # родительский класс - C
end

D.singleton_class                       # => #<Class:D>
D.singleton_class.superclass            # => #<Class:C>
# все синглтон-методы D
D.sigleton_methods                      # => [:my_class_method]
D.my_class_method                       # => "C.my_class_method"
# только собственные (не унаследованные) синглтон-методы D
D.sigleton_methods(false)               # => []

# синглтон-класс C наследуется от синглтон-класса Object, т.к. C наследуется от Object
C.singleton_class.superclass            # => #<Class:Object>
# аналогично
Object.singleton_class.superclass       # => #<Class:BasicObject>
# BasicObject ни от кого не наследуется
# поэтому его синглтон-класс уже наследуется от обычного класса Class
BasicObject.singleton_class.superclass  # => Class

К сожалению (а, может, и к счастью), синглтон-методы скрыты от глаз программиста в методах, инспектирующих цепочку наследования - ни Kernel#class, ни Module#ancestors их не показывают. Но, как видно из последнего примера, с помощью Object#singleton_class и Class#superclass это можно обойти.

self и текущий класс

В каждой точке кода определены так называемые “текущий объект” и “текущий класс”.

Про текущий объект мы уже поговорили - это объект, который доступен через псевдопеременную self; к нему адресуются все вызовы методов без указания получателя и в нем ищутся все запрошенные инстанс-переменные.

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

1
2
3
4
5
6
class MyClass
  # здесь текущий класс - MyClass
  def my_method
    # этот метод станет инстанс-методом класса MyClass
  end
end

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

Посмотрим все типичные случаи, имеющие собственные правила определения self и текущего класса.

Верхний уровень

До того, как мы входим в определение какого-либо класса, self указывает на main, а текущим классом является Object. Это объясняет тот факт, что методы, определяемые на верхнем уровне, вызываются из любого участка кода, причем без получателя (такой метод будет приватным, т.е. его нельзя вызывать с явным получателем; а self указывает на объект класса Object либо его потомка, поэтому метод будет доступен).

1
2
3
4
5
6
7
def function
  "I am #{self}, of class #{self.class}"
end

function # => "I am main, of class Object"

Object.private_instance_methods.grep(/function/) # => [:function]

Определение класса

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

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass
  # self == MyClass
  # поэтому инстанс-переменные ищутся в MyClass
  @my_var = 5
  # и макросы обращаются к MyClass
  attr_reader :name
  # текущий класс - тоже MyClass, поэтому опрелеляемые методы
  # становятся инстанс-методами MyClass
  def my_method
    'MyClass#my_method'
  end
end

Определение метода

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

1
2
3
4
5
6
7
8
9
class MyClass
  def my_method
    puts "self = #{self.inspect}"
  end
end

obj = MyClass.new
obj.my_method
# self = #<MyClass:0x007fae8447d0f8>

Текущий класс в определении метода не меняется; внутри определения метода можно определить второй метод, и при вызове первого второй станет методом того же класса:

1
2
3
4
5
6
7
8
9
10
11
class MyClass
  def outer_method
    def inner_method
      puts "self = #{self.inspect}"
    end
  end
end

obj = MyClass.new
obj.outer_method
obj.inner_method

class << object

Как уже говорилось чуть выше, у каждого объекта есть свой синглтон-класс - собственный класс объекта, содержащий все его синглтон-методы и наследующий от его номинального класса. Для определения синглтон-метода достаточно формы def self.method_name, но если необходимо поработать с синглтон-классом более плотно, на помощь приходит конструкция class << object. Как только мы попадаем внутрь нее, self и текущий класс меняются на синглтон-класс нашего объекта.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
str = "abc"

class << str
  def length_plus_one
    length + 1
  end

  def length_minus_one
    length - 1
  end
end

str.length_plus_one   # => 4
str.length_minus_one  # => 2
str.singleton_methods # => [:length_minus_one, :length_plus_one]

В более распространенной ситуации, когда class << self используется в определении класса, в роли объекта выступает сам класс, и мы попадаем в синглтон-класс класса:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
  # self указывает на MyClass
  @class_instance_variable = 'hello'

  class << self
    # а здесь и self, и текущий класс указывают на эйгенкласс MyClass
    def class_method
      'MyClass.class_method'
    end

    # можно даже сделать так:
    attr_reader :class_instance_variable
  end
end

MyClass.singleton_methods       # => [:class_instance_variable, :class_method]
MyClass.class_instance_variable # => "hello"

BasicObject#instance_eval / Module#class_eval

Часто возникает необходимость открыть существующий объект и изменить его “внутренности” - изменить инстанс-переменные, не имеющие attr_writer и недоступные снаружи; добавить пару синглтон-методов разом; а если объект является классом - то и инстанс-методы (синтаксис class MyClass ... end не подходит, если название класса определяется в рантайме, и на класс просто указывает переменная). Для таких случаев есть BasicObject#instance_eval и Module#class_eval. Они также меняют self и текущий класс.

BasicObject#instance_eval

instance_eval - это интерпретация кода в контексте определенного объекта. Внутри блока self соответствует самому объекту, а текущий класс - синглтон-классу объекта:

1
2
3
4
5
6
7
8
9
10
str = "abc"
str.instance_eval do
  p self              # => "abc"
  def double_length
    length * 2
  end
end

str.singleton_methods # => [:double_length]
str.double_length     # => 6

Впрочем, синглтон-методы обычно определяют в конструкции class << object.

Интересное достоинство instance_eval - в лаконичных DSL, где self, указывающий на нужный разработчику объект, принимает на себя все вызовы методов без указания получателя. Например, так устроен механизм задания маршрутов в роутере rails - вызывается метод ApplicationName::Application.routes.draw, который передает блок методу eval_block, а тот в свою очередь запускает instance_exec на объекте mapper - и все методы resources, get, post, member, collection вызываются с этим объектом в качестве получателя:

1
2
3
4
5
6
7
8
9
10
ApplicationName::Application.routes.draw do
  # здесь self соответствует mapper
  resources :users do
    collection do
      get :banned
    end
  end

  root to: 'users#index'
end

Module#class_eval

Метод class_eval, как и его синоним module_eval, определен в классе Module. В блоке, который передается в class_eval, self и текущий класс указывают на класс/модуль, на котором вызван этот метод. Фактически, это аналог повторного открытия класса, которое дает возможность обыкновенного monkeypatching: мы можем переопределять инстанс-методы класса и работать с инстанс-переменными самого класса.

1
2
3
4
5
6
7
8
9
10
11
12
String.class_eval do
  attr_accessor :var

  def crazy_case
    chars.map { |char| rand(2).zero? ? char.upcase : char.downcase }.join
  end
end

str = 'abcde'
str.crazy_case  # => "ABcdE"
str.var = 5
str.var         # => 5

Цепочки наследования

Мы уже знаем, что при вызове метода он ищется по всей цепочке наследования классов, начиная с синглтон-класса объекта-получателя и до BasicObject. Рассмотрим все случаи наследования классов:

  • При объявлении нового класса:

    • если указан класс-родитель, то новый класс наследуется от него
    • если родитель не указан, новый класс наследуется от Object
  • При включении модуля в класс создается прокси-класс, содержащий инстанс-методы модуля, и помещается в цепочку прямо над включающим классом

  • Синглтон-класс обычного объекта (не класса и не модуля) наследуется от номинального класса этого объекта (класс, который возвращает object.class)

  • Синглтон-класс класса наследуется от синглтон-класса родителя исходного класса (кроме синглтон-класса BasicObject, который наследуется от Class)

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

При подключении в один класс нескольких модулей порядок в цепочке наследования определяется порядком подключения: подключенный последним модуль будет стоять сразу после класса, его подключившего; далее предпоследний подключенный модуль итд; т.е., например, последний модуль может переопределить метод, заданный в предпоследнем.

Классовые методы любого класса, в свою очередь, состоят из синглтон-методов этого класса и инстанс-методов модулей, подключенных в его синглтон-класс, далее синглтон-методов класса-родителя и инстанс-методов его модулей, итд до синглтон-методов BasicObject; потом инстанс-методов классов Class, Module и Object, модуля Kernel и наконец класса BasicObject (так как цепочка наследования синглтон-класса BasicObject выглядит следующим образом: #BasicObject < Class < Module < Object < Kernel < BasicObject).

Выводы

Резюмируя все вышеописанное:

  • Любой объект содержит группу инстанс-переменных и ссылку на класс.

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

  • Любой объект может иметь собственный синглтон-класс, содержащий его синглтон-методы; он наследуется от номинального класса исходного объекта.

  • Классы и модули являются объектами, и обладают всеми свойствами объектов (имеют свой класс и полученные от него методы, также могут иметь синглтон-класс).

  • Любой класс имеет свою цепочку наследования - сам класс, его родительский класс, родительский класс родительского класса итд до корня классовой иерархии - класса BasicObject.

  • При подключении модуля в класс он помещается в цепочку наследования прямо над этим классом.

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

  • При вызове метода любого объекта этот метод ищется во всех классах цепочки наследования класса исходного объекта, начиная с синглтон-класса и заканчивая BasicObject.

  • При обращении к инстанс-переменной она ищется в текущем объекте (self); при вызове метода без указания получателя он будет вызван на текущем объекте.

  • При объявлении метода в обычной форме (def method_name) он становится инстанс-методом текущего класса;

    При объявлении метода с указанием получателя (def object.method_name) он становится синглтон-методом указанного объекта.

  • Указатели на текущий объект (self) и текущий класс меняются в зависимости от контекста.

Что еще полезно почитать

Комментарии