У яких випадках краще використовувати композицію замість наслідування
У книзі «Effective Java» Джошуа Блох сформулював фундаментальне правило:
🟢 Junior Level
У книзі «Effective Java» Джошуа Блох сформулював фундаментальне правило:
«Віддавайте перевагу композиції перед наслідуванням (Favor composition over inheritance).»
Наслідування створює найсильніший і найжорсткіший зв’язок в об’єктно-орієнтованому програмуванні. Нащадок стає заручником усіх помилок, вразливостей та змін внутрішньої реалізації батьківського класу. Композиція залишає класи незалежними.
Проста аналогія
- Наслідування: Зрощення кісток. Якщо ви пришиєте до руки сокиру, ви зможете колоти дрова, але мити посуд, водити автомобіль і спати вам стане нестерпно важко.
- Композиція: Робочий інструмент у руці. Ви берете сокиру, коли треба розколоти поліно, потім кладете її на полицю і берете горнятко чаю.
Топ-3 випадки, коли композиція обов’язкова для Junior:
- Немає суворого відношення «Є частковим випадком» (IS-A):
- Якщо клас
Carхоче повторно використати кодEngine, він не повинен наслідуватися від двигуна (Car extends Engine— безглуздя). Автомобіль містить двигун (Car has-a Engine).
- Якщо клас
- Базовий клас не розроблявся спеціально для наслідування:
- Наслідування від готових класів JDK (
ArrayList,HashSet,HashMap) майже завжди призводить до прихованих багів та порушення інваріантів.
- Наслідування від готових класів JDK (
- Потрібно приховати зайві методи:
- Якщо ви створюєте структуру даних
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) чітко сформульовано:
«Проєктуйте та документуйте клас для наслідування або забороняйте його.»
Якщо автор класу суворо не задокументував:
- Які методи викликають інші перевизначувані методи всередині самого класу (Self-use of overridable methods).
- Які хуки (
protectedметоди) надаються для кастомізації. - Які інваріанти зобов’язані підтримувати нащадки.
— такий клас повинен бути оголошений як 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.
Для вирішення проблеми:
- Поле оголошується як
private transient Delegate delegate;. - У класі реалізуються приватні методи життєвого циклу серіалізації
writeObject()таreadObject(), де стан делегата зберігається вручну у вигляді примітивів або відновлюється заново під час десеріалізації.
🎯 Шпаргалка для інтерв’ю
Головні тези (30 секунд на відповідь):
- Головний принцип: Favor composition over inheritance (Effective Java).
- Випадки для композиції:
- Немає відношення IS-A (клас просто хоче використати чужу функцію).
- Базовий клас не призначений для наслідування (не
finalпомилково). - Потрібно приховати методи батька (захист від API Pollution, як у
Properties). - Потрібна заміна поведінки на льоту (Strategy / State у runtime).
- Для захисту від крихкого базового класу (проблема подвійного підрахунку в
addAll).
- Коли наслідування виправдане: Template Method, ієрархія Exception, інфраструктурний
@MappedSuperclassу JPA.
Чого уникати (Red Flags):
- ❌ Наслідувати стандартні колекції (
ArrayList,HashMap) для створення своїх структур даних. - ❌ Використовувати наслідування заради доступу до
protected-полів. - ❌ Заявляти, що наслідування краще за композицію, тому що «менше писати коду» (технічний борг на порядок перевищить економію рядків).