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

Що таке композиція і наслідування

Наслідування дозволяє створити новий клас на основі вже наявного. Клас-нащадок успадковує структуру (поля) та поведінку (методи) базового класу:


🟢 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» поділяється на дві категорії:

  1. Композиція (Жорстке володіння):
    • Частина не може існувати без цілого. При знищенні зовнішнього об’єкта знищуються і його складові частини.
    • Приклад: Order (Замовлення) та OrderItem (Позиція замовлення). Позиція замовлення не має фізичного змісту поза межами свого замовлення.
  2. Агрегація (Слабке володіння):
    • Частина існує незалежно від цілого. Зовнішній об’єкт лише тимчасово посилається на неї.
    • Приклад: 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-розробці наслідування класів однозначно краще за композицію?

Відповідь:

  1. Патерн Template Method (Шаблонний метод): коли незмінний каркас алгоритму та порядок виконання фаз зафіксований в абстрактному базовому класі (abstract class BaseReportGenerator), а конкретні кроки кастомізуються в нащадках.
  2. Ієрархія винятків: усі кастомні помилки зобов’язані наслідувати Exception або RuntimeException, оскільки механізм розкрутки стека JVM (athrow) зав’язаний на системний клас Throwable.
  3. Спільний технічний контекст в ORM/JPA: використання @MappedSuperclass (наприклад, BaseEntity з полями id, createdAt, updatedAt, version) усуває дублювання інфраструктурних полів без створення зайвих зв’язків у таблицях БД.

2. Що таке проблема ромбоподібного наслідування (Diamond Problem) і як Java вирішує її при наслідуванні класів та інтерфейсів?

Відповідь: Проблема виникає при множинному наслідуванні, коли клас D наслідує B і C, а ті обидва наслідують A з методом foo(). Незрозуміло, чию реалізацію foo() повинен отримати D.

  • Java повністю забороняє множинне наслідування класів (extends OnlyOneClass), усуваючи ромб для полів стану.
  • Для інтерфейсів з default-методами Java вирішує конфлікт суворими правилами компілятора:
    1. Реалізація класу завжди перемагає default-метод інтерфейсу.
    2. Більш специфічний інтерфейс перемагає менш специфічний (нащадок перемагає предка).
    3. Якщо два незалежні інтерфейси надають однаковий 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 та ієрархії винятків вимагають наслідування).

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