В этой лекции мы рассмотрим объектно-ориентированный стиль в 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 — объектно-ориентированного. Готовим для вас следующую лекцию, всего хорошего!