Наследование и полиморфизм
Предпосылки: Объектно-ориентированное программирование (класс, объект, методы, инкапсуляция).
← Объектно-ориентированное программирование | Функциональное программирование →
Один класс удобен, пока все объекты ведут себя одинаково. Но это заканчивается быстро. В магазине появляются разные способы доставки: самовывоз, обычный курьер, экспресс-доставка. У каждого свой срок и своя цена.
Когда ветвление расползается
Первый работающий вариант хранит признак 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
endPickup < 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.
← Объектно-ориентированное программирование | Функциональное программирование →