📐 Раздел 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 и иерархии исключений требуют наследования).

Связанные темы: