Що таке композиція і наслідування
White-box reuse — видні нутрощі батька (наслідування). Black-box reuse — тільки публічний інтерфейс (композиція). Devirtualization — JIT замінює віртуальний виклик на прямий. Mo...
🟢 Junior Level
Композиція і наслідування — це два основних способи організації зв’язків між класами в ООП. Наслідування створює відношення “є” (IS-A): один клас стає приватним випадком іншого. Композиція створює відношення “має” (HAS-A): один об’єкт містить інший об’єкт як частину.
Проста аналогія: Наслідування — як отримання рис від батьків (ви є людиною). Композиція — як покупка телефону (ви маєте телефон, можете його замінити).
Приклад наслідування (IS-A):
public class Animal {
public void eat() { System.out.println("Eating..."); }
}
public class Dog extends Animal { // Dog IS-A Animal
public void bark() { System.out.println("Woof!"); }
}
Приклад композиції (HAS-A):
public class Engine {
public void start() { System.out.println("Engine started"); }
}
public class Car {
private final Engine engine; // Car HAS-A Engine
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start(); // Делегування
}
}
Коли використовувати:
- Наслідування: коли є реальна ієрархія типів (Dog IS-A Animal)
- Композиція: у більшості інших випадків — це гнучкіший підхід
🟡 Middle Level
Як це працює
Наслідування реалізує зв’язок “є” (IS-A) і використовує механізм таблиць віртуальних методів (VTable) на рівні JVM. Нащадок отримує доступ до protected полів батька — це “білий ящик” (White-box reuse).
Композиція реалізує зв’язок “має” (HAS-A) через виклики методів іншого об’єкта. Взаємодія тільки через публічний інтерфейс — це “чорний ящик” (Black-box reuse).
White-box reuse — видні нутрощі батька (наслідування). Black-box reuse — тільки публічний інтерфейс (композиція). Devirtualization — JIT замінює віртуальний виклик на прямий. Monomorphic = один тип виклику → inlining. Megamorphic = 3+ типи → пошук по таблиці.
Практичне застосування
Чому Effective Java каже “Favor Composition over Inheritance”:
| Критерій | Наслідування | Композиція |
|---|---|---|
| Зв’язок | Статичний (compile-time) | Динамічний (runtime) |
| Інкапсуляція | Порушується (видні protected поля) | Зберігається |
| Гнучкість | Не можна змінити батька | Можна підмінити компонент |
| Тестованість | Складно мокати | Легко підмінити на mock |
| Крихкість | Зміна батька ламає всіх | Ізольовані зміни |
Типові помилки
| Помилка | Рішення |
|---|---|
| Наслідування заради перевикористання коду | Використовувати композицію + делегування |
| Глибока ієрархія (5+ рівнів) | Рефакторинг в сторону композиції |
ArrayList → MySpecialList через наслідування |
MySpecialList з полем List всередині |
Альтернативи наслідуванню
- Strategy: замість
SmsNotification extends Notification→NotificationServiceз полемSenderStrategy - Decorator: обгортання об’єкта для додавання функцій (логування, кешування)
- Delegation: клас реалізує інтерфейс, але викликає методи делегата
Коли НЕ варто використовувати композицію
- Чисті ієрархії типів (винятки:
IOException→SocketException) - Фреймворки з Template Method патерном
- Коли потрібен поліморфізм і робота через спільний базовий тип
Template Method vs Strategy: Template Method = ви контролюєте алгоритм, підкласи — кроки. Strategy = клієнт обирає весь алгоритм повністю. Template Method — коли скелет алгоритму стабільний. Strategy — коли весь алгоритм може змінюватися.
🔴 Senior Level
Internal Implementation на рівні JVM
Наслідування та VTable: При виклику віртуального методу JVM використовує таблицю віртуальних методів (VTable). Кожен клас має свою VTable з вказівниками на реалізації методів. При глибокій ієрархії (5+ рівнів) JIT-компілятору складніше робити devirtualization та inlining.
// Наслідування: invokevirtual, пошук в VTable
class Parent { void foo() {} }
class Child extends Parent { @Override void foo() {} }
// parent.foo() → invokevirtual → VTable lookup
// Композиція з final: JIT може заинлайнити
class Service {
private final Delegate delegate; // final → monomorphic call
void execute() { delegate.run(); } // JIT inlines → прямий виклик
}
Архітектурні Trade-offs
Наслідування:
- ✅ Плюси: Поліморфізм з коробки, перевикористання коду, Template Method
- ❌ Мінуси: Крихкий базовий клас, утечка реалізації, статичний зв’язок
Композиція:
- ✅ Плюси: Динамічна поведінка, тестованість (легко мокати), слабка зв’язаність
- ❌ Мінуси: Більше boilerplate коду (делегування), більше об’єктів в heap
Edge Cases
- Fragile Base Class Problem: зміна
addAllу батька, який викликаєadd, ламає нащадка, що перевизначив обидва методи- Рішення: Композиція + Forwarding
- Це деталь реалізації HashSet: addAll викликає add internally. Але покладатися на це не можна — в іншій реалізації може бути інакше. Саме тому наслідування від класів, не призначених для цього, крихке.
- Shared State при композиції: кілька делегатів працюють зі спільними даними
- Рішення: Явна передача контексту або State Object
- Circular Delegation: A → B → A → StackOverflowError
- Рішення: Архітектурний аналіз, ArchUnit правила
Продуктивність
| Метрика | Наслідування | Композиція |
|---|---|---|
| Object size | Всі поля батьків в одному об’єкті | Окремі об’єкти + заголовки |
| Method call | invokevirtual (VTable lookup) | invokevirtual + possible inlining |
| JIT optimization | Складніше при deep hierarchy | Простіше з final полями |
| Memory | ~16 byte header + всі поля | ~16 bytes × N об’єктів + посилання |
- Inlining: JIT легко інлайнить
finalделегатів → оверхед ~0 - VTable: При глибині 5+ рівнів megamorphic calls → деоптимізація
Thread Safety
- Наслідування:
synchronizedна методі батька блокує весь об’єкт - Композиція: Можна використовувати різні lock’и для різних делегатів → finer-grained concurrency
Production Experience
Рефакторинг в enterprise проекті:
NotificationService на 2000 рядків з нащадками EmailNotification, SmsNotification, PushNotification. Проблема: додавання нової стратегії вимагало зміни базового класу.
Рефакторинг: виділили NotificationStrategy інтерфейс, кожен тип — окрема реалізація. NotificationService став координатором. Результат: нові типи додаються без зміни існуючого коду (OCP).
Monitoring
ArchUnit правила:
@ArchTest
static void no_deep_inheritance = classes()
.should().notHaveSimpleNameMatching(".*Impl.*")
.andShould().haveLessThanNAncestors(3);
SonarQube: Depth of Inheritance Hierarchy > 5 → Code Smell
Best Practices for Highload
- final делегати для кращого inlining
- Package-private для внутрішніх компонентів
- Sealed classes (Java 17+) для контрольованого наслідування
Sealed classes доступні з Java 17. Для Java 8/11 використовуйте
finalкласи і документуйте, які класи призначені для наслідування.
- Composition over Inheritance як default вибір
🎯 Шпаргалка для інтерв’ю
Обов’язково знати:
- Наслідування = IS-A (статичний зв’язок, white-box reuse), Композиція = HAS-A (динамічна, black-box)
- “Favor Composition over Inheritance” — Effective Java, Josh Bloch
- Композиція зберігає інкапсуляцію, наслідування порушує (видні
protectedполя) - JIT легко інлайнить
finalделегатів → оверхед композиції ~0 - Наслідування: крихкий базовий клас, утечка реалізації, статичний зв’язок
- Fragile Base Class: зміна
addAllу батька ламає нащадка, що перевизначивadd - Sealed classes (Java 17+) для контрольованого наслідування
Часті уточнюючі запитання:
- Чому композиція краща? — Динамічна заміна компонентів, тестованість (легко мокати), слабка зв’язаність
- Коли наслідування допустиме? — Справжнє IS-A, фреймворки з Template Method, спільні інфраструктурні поля (
BaseEntity) - Що таке devirtualization? — JIT замінює віртуальний виклик на прямий, коли знає точний тип
- VTable overhead при наслідуванні? — 5+ рівнів → megamorphic calls → деоптимізація JIT
Червоні прапори (НЕ говорити):
- “Наслідування — найкращий спосіб перевикористати код” (головна причина крихкості)
- “Глибока ієрархія — ознака хорошого дизайну” (5+ рівнів — code smell)
- “Композиція завжди краща” (є legit випадки наслідування: Template Method, IS-A)
Пов’язані теми:
- [[11. У яких випадках краще використовувати композицію замість наслідування]]
- [[12. Що таке делегування в ООП]]
- [[5. Що таке принцип Liskov Substitution]]
- [[6. Наведіть приклад порушення принципу Liskov Substitution]]