Що таке композиція і наслідування
Наслідування дозволяє створити новий клас на основі вже наявного. Клас-нащадок успадковує структуру (поля) та поведінку (методи) базового класу:
🟢 Junior Level
Наслідування та композиція — це два фундаментальних способи організації відносин між класами в об’єктно-орієнтованому програмуванні.
Наслідування (Зв’язок «IS-A» / «Є частковим випадком»)
Наслідування дозволяє створити новий клас на основі вже наявного. Клас-нащадок успадковує структуру (поля) та поведінку (методи) базового класу:
- Відношення виражається зв’язком «IS-A» («собака є твариною»).
- У Java задається ключовим словом
extends.
public class Animal {
public void eat() {
System.out.println("Тварина їсть");
}
}
public class Dog extends Animal { // Dog IS-A Animal
public void bark() {
System.out.println("Гав-гав!");
}
}
Композиція (Зв’язок «HAS-A» / «Містить у собі»)
Композиція — це підхід, за якого один клас містить об’єкт іншого класу як своє поле та викликає його методи (делегує виконання):
- Відношення виражається зв’язком «HAS-A» («автомобіль містить двигун»).
- Життєвий цикл складових частин часто нерозривно пов’язаний із життєвим циклом власника.
public class Engine {
public void start() {
System.out.println("Двигун запущено");
}
}
public class Car {
// Car HAS-A Engine (композиція)
private final Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start(); // Делегування
}
}
Головне емпіричне правило для початківців
- Якщо між сутностями є суворе смислове відношення «підтип є різновидом базового типу» — використовується наслідування.
- Якщо сутність збирається з деталей або використовує чужий функціонал як помічника — використовується композиція.
🟡 Middle Level
Різновиди включення: Агрегація проти Композиції
В UML та теорії ООП відношення «HAS-A» поділяється на дві категорії:
- Композиція (Жорстке володіння):
- Частина не може існувати без цілого. При знищенні зовнішнього об’єкта знищуються і його складові частини.
- Приклад:
Order(Замовлення) таOrderItem(Позиція замовлення). Позиція замовлення не має фізичного змісту поза межами свого замовлення.
- Агрегація (Слабке володіння):
- Частина існує незалежно від цілого. Зовнішній об’єкт лише тимчасово посилається на неї.
- Приклад:
Department(Відділ) таEmployee(Співробітник). Якщо розформувати відділ, співробітники залишаться в компанії.
White-box Reuse проти Black-box Reuse
| Критерій | Наслідування (White-box Reuse) | Композиція (Black-box Reuse) |
|---|---|---|
| Видимість деталей | «Біла скринька»: нащадок бачить нутрощі батька (protected поля, особливості викликів внутрішніх методів). |
«Чорна скринька»: клієнтський об’єкт бачить лише публічний контракт (API) компонента. |
| Зв’язування (Coupling) | Статичне (Compile-time): зв’язок задається жорстко на етапі компіляції, його не можна підмінити на льоту. | Динамічне (Runtime): реалізацію можна підмінити в рантаймі (патерн Strategy / State). |
| Інкапсуляція | Порушується: зміна внутрішньої реалізації батька ламає логіку нащадків. | Зберігається: внутрішній стан компонента повністю ізольований. |
| Тестованість | Низька: складно ізолювати логіку батька від нащадка без побічних ефектів. | Висока: залежність тривіально підміняється mock-об’єктом в юніт-тестах. |
Проблема крихкого базового класу (The Fragile Base Class Problem)
Класичний приклад Джошуа Блоха з Effective Java: спроба підрахувати кількість доданих елементів у колекцію через наслідування:
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() { return addCount; }
}
Де прихована проблема:
InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
set.addAll(List.of("A", "B", "C"));
System.out.println(set.getAddCount()); // Виведе 6 замість 3!
Чому вийшло 6: Усередині стандартного HashSet.addAll() розробники JDK реалізували цикл, який для кожного елемента викликає this.add(). У результаті addCount було збільшено один раз на 3 в addAll(), і ще тричі по 1 в перевизначеному методі add()! Нащадок став заручником недокументованої деталі внутрішньої реалізації батька.
Рішення через Композицію та Forwarding (Переадресацію):
public class InstrumentedSet<E> implements Set<E> {
private final Set<E> delegate;
private int addCount = 0;
public InstrumentedSet(Set<E> delegate) {
this.delegate = delegate;
}
@Override
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c);
}
public int getAddCount() { return addCount; }
// решта методів Set просто делегують: delegate.size(), delegate.contains()
}
Тепер код працює зі 100% передбачуваністю для будь-якого Set (TreeSet, LinkedHashSet), зберігаючи інкапсуляцію.
🔴 Senior Level
Фізичний Layout об’єктів у пам’яті JVM
З точки зору структури пам’яті HotSpot JVM композиція та наслідування поводяться принципово по-різному:
1. Структура пам’яті при наслідуванні:
Усі поля предка та нащадка упаковуються в єдиний неперервний блок пам’яті:
┌─────────────────────────────────────────────────────────────────┐
│ Mark Word (8B) │ Klass Word (4B/8B) │ Поля Parent │ Поля Child │
└─────────────────────────────────────────────────────────────────┘
- Плюси: Локальність даних у процесорному кеші L1/L2. Один заголовок об’єкта (Object Header) 12–16 байтів. Менше роботи збирачу сміття (GC відстежує один об’єкт).
- Мінуси: Неможливість динамічної зміни типу в рантаймі.
2. Структура пам’яті при композиції:
Об’єкт-власник зберігає посилання на окремий незалежний об’єкт у купі (Heap):
[Car Object (16B header + 4B/8B ref to Engine)] ──(pointer)──► [Engine Object (16B header + fields)]
- Плюси: Можливість поліморфної підміни
EngineнаElectricEngine, спільне володіння (flyweight), роздільна синхронізація блокувань (synchronized). - Накладні витрати: Додатковий заголовок об’єкта (16 байтів) + покажчик посилання (4 байти при Compressed OOPs) + вирівнювання до 8 байтів (padding). Додаткове розіменування покажчика (Pointer Chasing), що потенційно спричиняє Cache Miss у L1/L2 кеші CPU.
Вплив на JIT-компіляцію та Devirtualization
- Наслідування: якщо ієрархія класів глибока (4+ рівні) та поліморфна, виклики методів предка компілюються як
invokevirtualчерез перегляд VTable. При багатьох різнорідних нащадках точка виклику стає мегаморфною, блокуючи JIT-інлайнінг. - Композиція: якщо поле делегата оголошене як
private final Delegate delegate, C2 JIT-компілятор знає, що посилання незмінне з моменту завершення конструктора. JIT тривіально усуває накладні витрати делегування через Method Inlining: викликcar.start()перетворюється на асемблерні інструкції самогоengine.start()взагалі без проміжних викликів функцій.
4 Tricky Questions
1. У яких ситуаціях у сучасній enterprise-розробці наслідування класів однозначно краще за композицію?
Відповідь:
- Патерн Template Method (Шаблонний метод): коли незмінний каркас алгоритму та порядок виконання фаз зафіксований в абстрактному базовому класі (
abstract class BaseReportGenerator), а конкретні кроки кастомізуються в нащадках. - Ієрархія винятків: усі кастомні помилки зобов’язані наслідувати
ExceptionабоRuntimeException, оскільки механізм розкрутки стека JVM (athrow) зав’язаний на системний класThrowable. - Спільний технічний контекст в ORM/JPA: використання
@MappedSuperclass(наприклад,BaseEntityз полямиid,createdAt,updatedAt,version) усуває дублювання інфраструктурних полів без створення зайвих зв’язків у таблицях БД.
2. Що таке проблема ромбоподібного наслідування (Diamond Problem) і як Java вирішує її при наслідуванні класів та інтерфейсів?
Відповідь:
Проблема виникає при множинному наслідуванні, коли клас D наслідує B і C, а ті обидва наслідують A з методом foo(). Незрозуміло, чию реалізацію foo() повинен отримати D.
- Java повністю забороняє множинне наслідування класів (
extends OnlyOneClass), усуваючи ромб для полів стану. - Для інтерфейсів з default-методами Java вирішує конфлікт суворими правилами компілятора:
- Реалізація класу завжди перемагає default-метод інтерфейсу.
- Більш специфічний інтерфейс перемагає менш специфічний (нащадок перемагає предка).
- Якщо два незалежні інтерфейси надають однаковий default-метод, компілятор видає помилку, вимагаючи від розробника явно перевизначити метод у класі:
InterfaceA.super.foo().
3. Чи порушує композиція інкапсуляцію, якщо клас надає гетер до свого внутрішнього об’єкта-делегата?
Відповідь:
Так, це поширена помилка — витік внутрішнього представлення (Representation Exposure).
Якщо клас Car надає метод public Engine getEngine(), зовнішній викликаючий код може безпосередньо мутувати стан двигуна в обхід правил автомобіля (наприклад, розібрати його на ходу). Щоб зберегти інкапсуляцію, гетер не надається взагалі, або повертає захисну копію (Defensive Copy), або незмінне представлення (Unmodifiable View).
4. Чому в Java 17+ Sealed-класи кардинально змінили ставлення до наслідування?
Відповідь:
Раніше наслідування класів вважалося небезпечним, оскільки автор бібліотеки не міг контролювати, хто і як успадкує його клас у сторонньому коді.
Механізм sealed classes (public sealed class Shape permits Circle, Rectangle) дозволяє автору суворо зафіксувати список допустимих нащадків прямо в кодовій базі. Це повертає передбачуваність: ієрархія стає закритою, компілятор гарантує повноту покриття у switch виразах, а JIT-компілятор отримує можливість агресивніше виконувати мономорфний та біморфний інлайнінг.
🎯 Шпаргалка для інтерв’ю
Головні тези (30 секунд на відповідь):
- Наслідування (IS-A): жорсткий зв’язок часу компіляції («біла скринька»), клас отримує нутрощі батька. Ризик: Fragile Base Class Problem (поломка нащадків при зміні батька).
- Композиція (HAS-A): слабкий динамічний зв’язок часу виконання («чорна скринька»), об’єкт містить посилання на інший об’єкт і делегує йому роботу.
- Правило Джошуа Блоха: «Favor composition over inheritance» (віддавайте перевагу композиції перед наслідуванням).
- JIT & final: виклик через
final-делегат повністю інлайниться JIT-компілятором HotSpot, не створюючи накладних витрат.
Чого уникати (Red Flags):
- ❌ Наслідувати
HashSetабоArrayListзаради додавання одного службового методу (замість обгортки з композицією). - ❌ Стверджувати, що композиція повільніша через накладні витрати виклику методу (JIT C2 нівелює оверхед інлайнінгом).
- ❌ Заявляти, що наслідування класів застаріло і його не можна використовувати взагалі ніколи (Template Method та ієрархії винятків вимагають наслідування).