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

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

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

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

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

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

  • delivery_label решает, как назвать способ доставки;
  • delivery_price решает, сколько он стоит;
  • delivery_eta решает, какой срок показать.

Пока способов доставки мало, это ещё можно читать. Но как только появляется новый locker delivery, правку нужно внести сразу во все три функции. Потом рядом обычно появляются ещё delivery_refundable?, delivery_tracking_label, delivery_warning_text, и то же ветвление течёт дальше по файлу.

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

Вот пример — здесь неважно перечислить все ветки, важно увидеть, как одно различие по kind начинает течь сразу в подпись, цену и срок.

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

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

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

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

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

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

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

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

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

Иерархия классов убирает разбросанное ветвление. Но у объектов осталось свойство, знакомое с заметки о памяти: их можно менять по ходу работы. Если баланс продавца прошёл через 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.

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