📐 Розділ 18 · Питання #11

У яких випадках краще використовувати композицію замість наслідування

У книзі «Effective Java» Джошуа Блох сформулював фундаментальне правило:


🟢 Junior Level

У книзі «Effective Java» Джошуа Блох сформулював фундаментальне правило:

«Віддавайте перевагу композиції перед наслідуванням (Favor composition over inheritance).»

Наслідування створює найсильніший і найжорсткіший зв’язок в об’єктно-орієнтованому програмуванні. Нащадок стає заручником усіх помилок, вразливостей та змін внутрішньої реалізації батьківського класу. Композиція залишає класи незалежними.

Проста аналогія

  • Наслідування: Зрощення кісток. Якщо ви пришиєте до руки сокиру, ви зможете колоти дрова, але мити посуд, водити автомобіль і спати вам стане нестерпно важко.
  • Композиція: Робочий інструмент у руці. Ви берете сокиру, коли треба розколоти поліно, потім кладете її на полицю і берете горнятко чаю.

Топ-3 випадки, коли композиція обов’язкова для Junior:

  1. Немає суворого відношення «Є частковим випадком» (IS-A):
    • Якщо клас Car хоче повторно використати код Engine, він не повинен наслідуватися від двигуна (Car extends Engine — безглуздя). Автомобіль містить двигун (Car has-a Engine).
  2. Базовий клас не розроблявся спеціально для наслідування:
    • Наслідування від готових класів JDK (ArrayList, HashSet, HashMap) майже завжди призводить до прихованих багів та порушення інваріантів.
  3. Потрібно приховати зайві методи:
    • Якщо ви створюєте структуру даних Queue (Черга), наслідування від ArrayList виставить назовні методи list.remove(5) або list.clear(), що зламає FIFO-семантику черги.

🟡 Middle Level

5 сценаріїв безумовної перемоги композиції

1. Захист від забруднення API (API Pollution) та помилок проєктування

Хрестоматійна помилка творців Java у 1995 році — клас java.util.Properties:

// У JDK: Properties наслідує Hashtable<Object, Object>
Properties props = new Properties();
props.setProperty("timeout", "5000"); // Очікується робота з рядками

// Але через наслідування доступний сирий метод предка:
props.put(new Object(), 12345); // Порушено контракт рядкових налаштувань!
props.getProperty("timeout");   // Можливі ClassCastException у рантаймі!

Висновок: Якщо підкласу потрібні не всі методи батька, наслідування заборонене. Використовуйте композицію і надайте лише чисті спеціалізовані методи.


2. Динамічна зміна поведінки в Runtime (Патерн Strategy)

Зв’язок наслідування extends фіксується раз і назавжди під час компіляції. Композиція дозволяє підміняти алгоритм на льоту:

public class OrderService {
    private TaxCalculationStrategy taxStrategy;

    public void setTaxStrategy(TaxCalculationStrategy taxStrategy) {
        this.taxStrategy = taxStrategy; // Динамічна зміна в рантаймі!
    }

    public BigDecimal checkout(Order order) {
        return taxStrategy.calculate(order);
    }
}

3. Запобігання комбінаторному вибуху класів (Class Explosion)

Уявіть, що вам потрібні сповіщення з різними властивостями:

  • Канали: Email, SMS, Telegram (3 види).
  • Форматування: PlainText, HTML, Markdown (3 види).
  • Шифрування: Без шифрування, PGP (2 види).

  • При наслідуванні: вам доведеться створити $3 \times 3 \times 2 = 18$ комбінаторних класів (EncryptedMarkdownTelegramNotification, HtmlEmailNotification тощо).
  • При композиції (патерни Decorator / Bridge): створюється рівно $3 + 3 + 2 = 8$ незалежних класів-компонентів, які динамічно огортають один одного.

4. Необхідність розв’язати потокобезпеку (Concurrency Control)

  • При наслідуванні виклик synchronized методу блокує весь екземпляр класу (монітор об’єкта this).
  • При композиції сервіс може керувати незалежними блокуваннями для різних підсистем:
    public class HighThroughputService {
      private final Object readLock = new Object();
      private final Object writeLock = new Object();
      private final AuditLogger logger; // композиція
      private final MetricsRegistry metrics; // композиція
    
      public void process() {
          synchronized(writeLock) { /* зміна даних */ }
          metrics.record(); // незалежна синхронізація всередині метрик
      }
    }
    

5. Ізольоване Unit-тестування

Клас із композицією залежностей через конструктор (public UserService(UserRepository repo, MailClient mail)) тестується за мікросекунди з використанням Mockito:

UserService service = new UserService(mockRepo, mockMail);

Клас, успадкований від важкого базового BaseDatabaseEntityService, вимагає підняття всієї інфраструктури, з’єднань із базою даних та статичних моків.


🔴 Senior Level

Принцип Джошуа Блоха: “Design and document for inheritance or else prohibit it”

В Effective Java (стаття 19) чітко сформульовано:

«Проєктуйте та документуйте клас для наслідування або забороняйте його.»

Якщо автор класу суворо не задокументував:

  1. Які методи викликають інші перевизначувані методи всередині самого класу (Self-use of overridable methods).
  2. Які хуки (protected методи) надаються для кастомізації.
  3. Які інваріанти зобов’язані підтримувати нащадки.

— такий клас повинен бути оголошений як final, або його конструктори мають бути закриті (private / package-private фабричні методи).


Аналіз VTable, Memory Padding та Inlining у HotSpot JVM

При виборі між наслідуванням та композицією на рівні Highload-архітектури Senior оцінює компроміси машинного рівня:

Фактор Наслідування Композиція
VTable глибина Кожен рівень ієрархії розширює віртуальну таблицю методів класу. При глибині $> 4$ рівнів підвищується ймовірність поліморфного мегаморфізму. Композиція з інтерфейсами генерує invokeinterface (пошук за itable), але при стабільному типі інлайниться в 1 прямий виклик.
Field Padding та Alignment У JVM поля суперкласу вирівнюються за межею 8 байтів, потім додаються поля підкласу з новим вирівнюванням. Може призводити до неефективної витрати пам’яті на padding. Декілька об’єктів у купі вимагають по 12–16 байтів на Object Header кожен + 4 байти на OOP-посилання.
Escape Analysis Єдиний об’єкт простіше оптимізувати через скалярну заміну (Scalar Replacement), якщо він не витікає з методу. Якщо делегат створюється локально всередині методу, Escape Analysis HotSpot може розібрати обидва об’єкти на примітиви на стеку.

Архітектурний чек-лист: коли наслідування ВСЕ ЩЕ допустиме

Senior-інженер не виключає наслідування повністю, а застосовує його суворо за призначенням:

                  ┌───────────────────────────────────────────┐
                  │ Потрібно організувати зв'язок між класами? │
                  └─────────────────────┬─────────────────────┘
                                        │
                         Є суворе відношення IS-A?
                         Нащадок розширює, а не урізає контракт?
                                   /         \
                             [НІ]             [ТАК]
                              /                   \
                 ┌──────────────────────┐    Базовий клас явно спроєктований
                 │     КОМПОЗИЦІЯ       │    для наслідування (Template Method,
                 │ (Strategy/Decorator) │    @MappedSuperclass, Exception)?
                 └──────────────────────┘               /         \
                                                   [НІ]             [ТАК]
                                                    /                   \
                                       ┌──────────────────────┐    ┌─────────────┐
                                       │     КОМПОЗИЦІЯ       │    │ НАСЛІДУВАННЯ│
                                       └──────────────────────┘    └─────────────┘

4 Tricky Questions

1. Чому клас java.util.Stack у JDK визнано грубою архітектурною помилкою і як його слід було спроєктувати?

Відповідь: Stack був успадкований від Vector (class Stack<E> extends Vector<E>). Це грубе порушення відношення IS-A та інкапсуляції. Стек — це LIFO-структура з операціями push/pop. Але через наслідування від Vector стек отримав методи прямого індексного доступу (get(int index), insertElementAt(element, 0)), що дозволяє клієнту видалити або вставити елемент у середину чи дно стека в обхід LIFO. Правильний дизайн — композиція: клас Stack мав містити масив або Deque всередині себе приватним полем, виставляючи назовні лише 4 методи: push(), pop(), peek(), isEmpty().


2. Чи може композиція погіршити продуктивність порівняно з наслідуванням і коли?

Відповідь: Так, у вузькоспеціалізованих High-Frequency Trading та GameDev сценаріях. При композиції для виконання ланцюжка дій процесору потрібно перейти за посиланням-вказівником на інший об’єкт (Pointer Chasing). Якщо делегати розкидані в різних областях купи (Heap), кожне розіменування викликає промах процесорного кеша (L1/L2 Cache Miss) зі штрафом у 100–200 циклів очікування RAM. При наслідуванні всі поля скомпоновані в один неперервний блок пам’яті, що дає ідеальну просторову локальність (Spatial Locality) для кеш-ліній CPU.


3. Як патерн Delegating Wrapper (Forwarding Class) вирішує проблему Fragile Base Class при композиції?

Відповідь: Щоб не писати вручну десятки методів-делегатів інтерфейсу (delegate.size(), delegate.clear()), створюється абстрактний клас-обгортка ForwardingSet<E> implements Set<E>, у якому всі методи інтерфейсу за замовчуванням просто переадресовують виклик внутрішньому екземпляру. Конкретний клас InstrumentedSet<E> наслідує ForwardingSet<E> і перевизначає лише ті 1–2 методи, які потрібно кастомізувати (наприклад, add()). При цьому зберігається ізоляція від прихованих внутрішніх викликів чужої реалізації: виклик addAll() перенаправляється в delegate.addAll(), де виконується оригінальний алгоритм без подвійного обліку.


4. Що станеться під час десеріалізації об’єкта, який використовує композицію, якщо поле-делегат не реалізує Serializable?

Відповідь: Виклик ObjectOutputStream.writeObject(this) завершиться фатальним винятком java.io.NotSerializableException. Для вирішення проблеми:

  1. Поле оголошується як private transient Delegate delegate;.
  2. У класі реалізуються приватні методи життєвого циклу серіалізації writeObject() та readObject(), де стан делегата зберігається вручну у вигляді примітивів або відновлюється заново під час десеріалізації.

🎯 Шпаргалка для інтерв’ю

Головні тези (30 секунд на відповідь):

  • Головний принцип: Favor composition over inheritance (Effective Java).
  • Випадки для композиції:
    1. Немає відношення IS-A (клас просто хоче використати чужу функцію).
    2. Базовий клас не призначений для наслідування (не final помилково).
    3. Потрібно приховати методи батька (захист від API Pollution, як у Properties).
    4. Потрібна заміна поведінки на льоту (Strategy / State у runtime).
    5. Для захисту від крихкого базового класу (проблема подвійного підрахунку в addAll).
  • Коли наслідування виправдане: Template Method, ієрархія Exception, інфраструктурний @MappedSuperclass у JPA.

Чого уникати (Red Flags):

  • ❌ Наслідувати стандартні колекції (ArrayList, HashMap) для створення своїх структур даних.
  • ❌ Використовувати наслідування заради доступу до protected-полів.
  • ❌ Заявляти, що наслідування краще за композицію, тому що «менше писати коду» (технічний борг на порядок перевищить економію рядків).

Пов’язані теми