Наследование и полиморфизм

Предпосылки: Объектно-ориентированное программирование (класс, объект, методы, инкапсуляция).

Объектно-ориентированное программирование | Функциональное программирование

Один класс удобен, пока все объекты ведут себя одинаково. Но это заканчивается быстро. В магазине появляются разные способы доставки: самовывоз, обычный курьер, экспресс-доставка. У каждого свой срок и своя цена.

Когда ветвление расползается

Первый работающий вариант хранит признак kind в хеше и ветвится по нему в каждой функции:

# checkout.rb
def delivery_label(method)
  if method["kind"] == "pickup"
    "Самовывоз"
  elsif method["kind"] == "courier"
    "Курьер"
  elsif method["kind"] == "express"
    "Экспресс"
  end
end
 
# order_total.rb — поле переименовали в прошлом квартале,
# старые функции ещё читают через "kind"
def delivery_price(method, order_total)
  if method["type"] == "pickup"
    0
  elsif method["type"] == "courier"
    300
  elsif method["type"] == "express"
    700
  end
end
 
# shipment_email.rb
def delivery_eta(method)
  if method["kind"] == "pickup"
    "сегодня"
  elsif method["kind"] == "courier"
    "2 дня"
  elsif method["kind"] == "express"
    "завтра"
  end
end

В этом файле три функции на тридцать строк — постамат добавить легко: три elsif, три минуты, здесь всё подсвечено. В реальном проекте делвери-логика размазана по пятнадцати местам: список способов на странице чекаута, расчёт итоговой суммы в заказе, шаблон письма с ETA, мобильный API, админка, экспорт в 1С, отчёт для бухгалтерии, складские уведомления. Обычный первый шаг — grep "pickup" по репозиторию; он хорошо ловит места, но даже тут видно, что один блок обращается к method["type"] вместо method["kind"]: поле когда-то переименовали, мигрировали не всё. Поиск по method["kind"] вернёт часть мест, поиск по "type" — другую часть, и ни один не покажет всех. Найти все пятнадцать за день ещё реально, если помнить про оба названия. Забыть одно — и мобильный клиент начнёт показывать пустой label рядом со способом «постамат», а заметит это через месяц первый раздражённый пользователь в отзыве.

Различие между видами доставки больше не живёт в одном месте. Оно раскопировано в трёх функциях (и трёх разных именах поля), и каждая новая функция — delivery_refundable?, delivery_tracking_label, delivery_warning_text — добавит четвёртую копию того же if/elsif. Запускаемая версия — в 09-delivery-dispatch.rb.

Наследование

Наследование позволяет вынести общую часть в базовый класс, а различия оставить в дочерних:

class DeliveryMethod
  def summary
    label + ": " + price.to_s + ", " + eta
  end
end
 
class Pickup < DeliveryMethod
  def label
    "Самовывоз"
  end
 
  def price
    0
  end
 
  def eta
    "сегодня"
  end
end
 
class Courier < DeliveryMethod
  def label
    "Курьер"
  end
 
  def price
    300
  end
 
  def eta
    "2 дня"
  end
end

Pickup < DeliveryMethod — знак < здесь означает «наследует от»: Pickup получает всё, что описано в DeliveryMethod, и добавляет своё. Общий метод summary живёт один раз в базовом классе, а дочерние классы задают различия через label, price и eta.

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

Полиморфизм

После этого внешний код может работать с разными объектами через один и тот же набор методов:

deliveries = [Pickup.new, Courier.new]
 
deliveries.each do |delivery|
  puts delivery.summary
end

.each do |delivery| ... end — конструкция, уже знакомая по коллекциям объектов: .each обходит массив, вызывая блок do ... end для каждого элемента; |delivery| — имя, которое блок даёт текущему элементу. .to_s внутри summary превращает число в строку, чтобы его можно было соединить с текстом.

Цикл не ветвится по типу. Он просто вызывает summary у каждого объекта. Для Pickup и Courier результат будет разный, но форма вызова одна и та же.

Это и есть полиморфизм (от греч. poly — «много», morphe — «форма»): один и тот же внешний вызов, разное поведение в зависимости от конкретного объекта. Ruby каждый раз смотрит на тип объекта и выбирает метод из его класса — это и называется динамической диспетчеризацией. Решение о том, какой label вызвать для Pickup и какой для Courier, принимается во время работы программы, а не во время её написания.

Важна именно эта идея, а не конкретно наследование в Ruby. В Java и C# её часто строят на классах и интерфейсах; в Go ту же задачу обычно решают через интерфейс без общего базового класса. Общая цель одна: внешний код работает с общим контрактом, а не с ветвлением по каждому виду объекта.

Польза здесь опять не для процессора, а для чтения и развития программы. Внешний код знает меньше подробностей о разновидностях сущностей. Разработчик, получивший тикет «добавить постамат», видит классы Pickup, Courier, Express и создаёт четвёртый — Locker < DeliveryMethod. grep DeliveryMethod находит всех наследников, IDE «покажи имплементации» показывает все точки входа. Искать, где ещё нужно что-то дописать под видом method["type"] или method["kind"], не приходится: этих имён в коде больше нет.

Когда это уместно

Наследование и полиморфизм не нужны автоматически всякий раз, когда объектов больше одного.

Они окупаются, когда:

  • у объектов есть общий контракт;
  • внешний код должен обрабатывать их одинаковым образом;
  • ветвление по виду объекта уже повторяется в нескольких местах.

Если общего контракта нет, искусственная иерархия только усложнит код.

Иерархия классов убирает разбросанное ветвление. Но у объектов осталось свойство, знакомое с заметки о памяти: их можно менять по ходу работы. Если баланс продавца прошёл через deposit, withdraw, charge_fee и refund, по итоговому числу уже не видно, какой шаг привёл к ошибке. Для отладки таких проблем помогает функциональное программирование.

Sources

  • Liskov, B. & Zilles, S., 1974, Programming with Abstract Data Types. ACM SIGPLAN Notices.
  • Gamma, E. et al., 1994, Design Patterns. Addison-Wesley.
  • Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.

Объектно-ориентированное программирование | Функциональное программирование