В этой лекции мы рассмотрим объектно-ориентированный стиль в Ruby: поговорим об объектах, классах и модулях, а также вспомним три принципа объектно-ориентированного программирования.

Основные свойства Ruby как чистого ОО-языка таковы:

  • Любая сущность — объект, исключений не существует.

  • Примитивных типов не существует.

  • Любая функция — это метод какого-либо объекта. Инфиксные операторы (например, + - / *) — не исключение.

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

  • Поведение объектов может программно изменяться в их жизненном цикле.

Объекты

Поговорим об объектах. Мы только что проговорили, что всё в Ruby является объектом. Например, число 42, которое вы видите — объект. Давайте это проверим. Вызовем какой-нибудь метод у этого числа. Допустим, метод class. И получим класс этого объекта:

pry(main)> 42
=> 42

pry(main)> 42.class
=> Integer

pry(main)> Integer.class
=> Class

Попробуем посмотреть все доступные методы у объекта 42. Среди них увидим математические операторы, которые можем использовать:

pry(main)> 42.methods
=> [
 # ...
 :%,
 :&,
 :*,
 :+,
 :-,
 :/,
 # ...
]

pry(main)> 42.method(:+)
=> #<Method: Integer#+(_)>

Давайте убедимся в том, что математические операции — это тоже методы. Умножим 2 на 2. Вот сокращённая форма записи, где мы опустили некоторые знаки. Эта упрощённая запись будет эквивалентна следующей: у объекта 2 вызывается метод «умножение», и в качестве аргумента передаётся двойка. Оба выражения вернут один и тот же результат:

pry(main)> 2 * 2
=> 4
pry(main)> 2.*(2)
=> 4

У объекта есть внутреннее скрытое состояние и есть методы, с помощью которых он может взаимодействовать с внешним миром: показывать своё состояние или как-то его изменять.

Классы

Теперь о классах. Объекты в Ruby —  это экземпляры, каждый своего класса. Классы можно воспринимать как шаблоны, по которым создаются новые объекты.  

Давайте создадим класс. Определим имя, опишем конструктор. Конструктор — это метод, который вызывается при создании нового экземпляра класса и выполняет первоначальную настройку состояния объекта. Опишем переменную экземпляра класса и зададим значение этой переменной. Опишем метод этого экземпляра класса. Создадим новый экземпляр класса и вызовем его метод:

class DeepThought
  def initialize
    @answer = 42
  end

  def answer_for(question)
    sleep 10

    puts "The Answer about life, universe and everything, including '#{question}' is #{@answer}"
  end
end

supercomputer = DeepThought.new
supercomputer.answer_for('To be or not to be?')

#> The Answer about life, universe and everything, including 'To be or not to be?' is 42

Прежде, чем продолжить, поговорим о собственном поведении класса. У класса есть методы, константы и переменные самого класса. Их мы можем использовать без создания нового экземпляра класса. Посмотрим на код: на второй строчке вы видите константу класса, на третьей — переменная класса. В конце класса находится конструкция class << self. Методы, которые описаны внутри этого блока, являются методами самого класса:

class DeepThought
  ANSWER = 42
  @@answer_template = 'Umm...'

  attr_reader :answer, :seconds_to_sleep

  def initialize(seconds_to_sleep)
    @answer = ANSWER
    @seconds_to_sleep = seconds_to_sleep
  end

  def answer_for(question)
    sleep(seconds_to_sleep)

    puts expand_answer(question)
  end

  def expand_answer(question)
    "#{@@answer_template.gsub(/$q/, question)} #{answer}"
  end

  class << self
    def answer_template=(string)
      @@answer_template = string
    end
  end
end

Обратите внимание на метод answer_template. В конце его имени стоит знак =. Он напрямую изменяет переменную класса. Такие методы называются сеттерами. Посмотрите на вызов метода attr_reader, — так создаются геттеры, — методы, возвращающие значения одноименных переменных экземпляра.

Мы можем обратиться к имени константы и получить её значение. Слева пишется название класса, потом двойное двоеточие и имя константы:

DeepThought::ANSWER
#> 42

Теперь снова создадим экземпляр класса и вызовем у него какой-нибудь метод. Затем у самого класса вызовем сеттер и изменим нужную нам переменную класса. После этого снова вызовем метод экземпляра. Мы получили новый результат, так как изменилось определённое свойство класса в целом:

supercomputer = DeepThought.new(10)
#> #<DeepThought:0x00007fece5c12418 @seconds_to_sleep=10>

supercomputer.answer_for('To be or not to be?')
#> Umm... 42

DeepThought.answer_template =
  "The Answer about life, universe and everything, including $q is"
supercomputer.answer_for('To be or not to be?')
#> The Answer about life, universe and everything, including $q is 42

Модули

Модули — Это особые объекты в Ruby. Они используются для группировки методов, констант, других классов и модулей. Модули также создают неймспейсы — пространства имён. Модули похожи на классы тем, что у них есть методы, константы, вложенные классы и модули. Но есть и отличие: их нельзя инстанциировать. Невозможно создать экземпляр модуля.

module SupercomputersPlanet
  PLANET = "Magrathea"

  class DeepThought
  # ...
  end

  def make_deep_thought
    puts "Making Deep Thought of #{PLANET}"
    DeepThought.new
  end
end

Посмотрим на примеры: мы можем получить список всех констант. Также можем попробовать создать экземпляр модуля, но в этом месте получим исключение. Можем попробовать прочитать константу, однако её нет в глобальной области видимости, константа описана только внутри модуля. Здесь же мы получим исключение. В конце мы явно указываем, к какому модулю хотим обратиться, и получаем нужное значение:

SupercomputersPlanet.class
#> Module

SupercomputersPlanet.constants
#> [:PLANET, :DeepThought]

SupercomputersPlanet.new
#> NoMethodError: undefined method `new' for SupercomputersPlanet:Module

PLANET
#> NameError: uninitialized constant PLANET

SupercomputersPlanet::PLANET
#> "Magrathea"

Теперь попробуем создать экземпляр класса, определённого внутри пространства имён нашего модуля. На первой строчке получаем исключение. Это произошло потому, что мы не указали, что хотим инстанциировать класс из конкретного namespace:

DeepThought.new
#> NameError: uninitialized constant DeepThought

SupercomputersPlanet::DeepThought.new
#> #<SupercomputersPlanet::DeepThought:0x00007fe16bae4490>

SupercomputersPlanet.make_deep_thought
#> NoMethodError: undefined method `make_deep_thought' for SupercomputersPlanet:Module

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

module SupercomputersPlanet
  PLANET = "Magrathea"

  class DeepThought
  # ...
  end

  def self.make_deep_thought
    puts "Making Deep Thought of #{PLANET}"
    DeepThought.new
  end
end

SupercomputersPlanet.make_deep_thought
#> Making Deep Thought of Magrathea
#> #<SupercomputersPlanet::DeepThought:0x00007fdc389159d0>

3 основных принципа объектно-ориентированного программирования: инкапсуляция, наследование, полиморфизм. 

Начнём с инкапсуляции. Инкапсуляция — это свойство объекта прятать своё состояние от внешнего мира. Напрямую с переменными класса или переменными экземпляра мы взаимодействовать не сможем. Для взаимодействия нужны методы. Они позволяют читать и изменять состояние класса. В Ruby существует инструмент, с помощью которого можно управлять доступом к методам. Делается это с помощью ключевых слов private и protected. private делает так, что методы могут вызываться только изнутри экземпляра класса. Попробуем на примере увидеть, как это работает. Создадим экземпляры и попробуем вызвать у них приватные метод. Получим исключения:

class DeepThought
  ANSWER = 42

  attr_reader :seconds_to_sleep
  private :seconds_to_sleep

  def initialize(seconds_to_sleep)
    @seconds_to_sleep = seconds_to_sleep
  end

  def answer_for(question)
    sleep(seconds_to_sleep)
    puts full_answer_text(question)
  end

  private

  def full_answer_text(question)
    <<~TXT.gsub(/\n/, ' ')
      The Answer about life, universe and everything,
      including '#{question}' is #{answer}"
    TXT
  end

  def answer
    ANSWER
  end
end

DeepThought.new(5).answer
#> NoMethodError: private method `answer' called ...

DeepThought.new(5).seconds_to_sleep
#> NoMethodError: private method `seconds_to_sleep' called ...

DeepThought.new(5).answer_for("Foo?")
#> The Answer about life, universe and everything, including 'Foo?' is 42"

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

Второй принцип — наследование. Это свойство класса повторять поведение и свойства родительского класса, который в этом случае называют суперклассом. Наследование появилось как инструмент уменьшения количества кода, чтобы можно было группировать некоторое общее для нескольких классов поведение и выносить его в отдельные суперклассы.  Наследование позволяет создавать иерархию классов, в которой каждый из классов может быть либо суперклассом, то есть родителем, либо подклассом, то есть наследником.

Смотрим пример: создадим два класса, где второй будет потомком первого. Создадим экземпляры этого класса и попробуем вызвать оба метода: как метод родительского класса, так и метод самого класса:

class Animal
  def breathe
    puts "inhale and exhale"
  end
end

class Cat < Animal
  def speak
    puts "Meow"
  end
end

nyan = Cat.new
nyan.breathe
nyan.speak

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

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

class LifeForm
  def breathe
    # all lifeforms breathe
  end
end

class Animal < LifeForm
  include AnimalCellsBiochemistry
  include AnimalGenetics
  include AnimalMetabolism
  include AnimalMotion
  include AnimalSounds
end

class Cat < Animal
  # это поведение — DSL из модуля AnimalSounds
  animal_sound "Meow"
end

Вернёмся к примеру с иерархией классов. У нас есть класс Animal, есть класс Cat, который является его наследником. Есть ещё класс японской кошки — наследник класса Cat:

class Animal
  def speak
    puts 'The animal sound'
  end
end

class Cat < Animal
  def speak
    puts 'The cat speaks:'
    super   # super вызывает одноимённый метод родителя
  end
end

module JapaneseCatSpeak
  def speak
    puts 'Nyaaaaa!'
  end
end

class JapaneseCat < Cat
end

JapaneseCat.new.speak
#> The cat speaks:
#  The animal sound

Создадим экземпляр японской кошки и вызовем у неё один единственный метод. Но сейчас кошка мяукает по-английски, а должна по-японски. Давайте это сделаем. Создадим отдельный модуль и «подмешаем» его в нужный класс с помощью ключевого слова include. Теперь наша кошка мяукает правильно:

class JapaneseCat < Cat
  include JapaneseCatSpeak

  def speak
    puts 'The cat speaks:'
    super   # теперь родитель для этого метода — модуль JapaneseCatSpeak
  end
end

JapaneseCat.new.speak
#> The cat speaks:
#  Nyaaaaa!

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

module CatsCommons
  def features
    %i[ears legs tail]
  end
end

class Cat
  include CatsCommons
end

Cat.features
#> NoMethodError: undefined method `features' for Cat:Class

Cat.new.features
#> [:ears, :legs, :tail]

Давайте попробуем создать экземпляр класса и вызовем метод на нём. Всё работает корректно. Однако изначально я хотел, чтобы метод вызывался на уровне класса. Исправим это поведение. Для этого используем ключевое слово eхtend:

class Cat
  extend CatsCommons
end

Cat.features
#> [:ears, :legs, :tail]

С его помощью мы подмешиваем содержимое модуля не в экземпляр класса, а в сам класс. 

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

И, наконец, третий принцип объектно-ориентированного программирования  — полиморфизм. Это способность одного и того же кода работать с разными типами данных.

В Ruby тождественны понятие "тип" и "поведение". За счёт динамической природы языка мы можем изменять экземпляр класса непосредственно. На примере у нас есть модуль и класс. Создадим экземпляр класса, опишем в модуле новый метод для него и подмешаем модуль непосредственно в экземпляр. Проверим — всё работает корректно:

module ExplicitCatThings
  def get_in_the_way
    "I'm on the laptop!"
  end
end

class Cat
  def speak
    'Meow!'
  end
end

cat = Cat.new
def cat.jump
  'Weeeee!!! Rumble-rumble!'
end
cat.extend ExplicitCatThings

cat.speak
#> "Meow!"
cat.jump
#> "Weeeee!!! Rumble-rumble!"
cat.get_in_the_way
#> "I'm on the laptop!"

Такой подход называется «monkey patching» — во время работы программы, после создания экземпляра мы меняем его поведение. Язык позволяет нам такую гибкость, но это считается не особенно хорошей практикой, поэтому старайтесь её избегать. Из-за «monkey patching» бывает сложно анализировать работу программы и отслеживать, где и как изменяются свойства классов и объектов.

Это был обзор основного стиля программирования в Ruby — объектно-ориентированного. Готовим для вас следующую лекцию, всего хорошего!

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