📐 Раздел 18 · Вопрос #11

В каких случаях лучше использовать композицию вместо наследования

В книге «Effective Java» Джошуа Блох сформулировал фундаментальное правило:


🟢 Junior Level

В книге «Effective Java» Джошуа Блох сформулировал фундаментальное правило:

«Отдавайте предпочтение композиции перед наследованием (Favor composition over inheritance).»

Наследование создает самую сильную и жесткую связь в объектно-ориентированном программировании. Наследник становится заложником всех ошибок, уязвимостей и изменений внутренней реализации родительского класса. Композиция оставляет классы независимыми.

Простая аналогия

  • Наследование: Сращивание костей. Если вы пришьете к руке топор, вы сможете колоть дрова, но мыть посуду, водить машину и спать вам станет невыносимо тяжело.
  • Композиция: Рабочий инструмент в руке. Вы берете топор, когда нужно расколоть полено, затем кладете его на полку и берете чашку чая.

Топ-3 случая, когда композиция обязательна для Junior:

  1. Нет строгого отношения «Является» (IS-A):
    • Если класс Car хочет повторно использовать код Engine, он не должен наследоваться от двигателя (Car extends Engine — бессмыслица). Машина содержит двигатель (Car has-a Engine).
  2. Базовый класс не разрабатывался специально для наследования:
    • Наследование от готовых классов JDK (ArrayList, HashSet, HashMap) почти всегда приводит к скрытым багам и нарушению инвариантов.
  3. Требуется скрыть лишние методы:
    • Если вы создаете структуру данных 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) четко сформулировано:

«Проектируйте и документируйте класс для наследования либо запрещайте его.»

Если автор класса не задокументировал строго:

  1. Какие методы вызывают другие переопределяемые методы внутри самого класса (Self-use of overridable methods).
  2. Какие хуки (protected методы) предоставляются для кастомизации.
  3. Какие инварианты обязаны поддерживать наследники.

— такой класс должен быть объявлен как 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. Для решения проблемы:

  1. Поле объявляется как private transient Delegate delegate;.
  2. В классе реализуются приватные методы жизненного цикла сериализации writeObject() и readObject(), где состояние делегата сохраняется вручную в виде примитивов или восстанавливается заново при десериализации.

🎯 Шпаргалка для интервью

Главные тезисы (30 секунд на ответ):

  • Главный принцип: Favor composition over inheritance (Effective Java).
  • Случаи для композиции:
    1. Нет отношения IS-A (класс просто хочет использовать чужую функцию).
    2. Базовый класс не предназначен для наследования (не final по ошибке).
    3. Нужно скрыть методы родителя (защита от API Pollution, как в Properties).
    4. Нужна замена поведения на лету (Strategy / State в runtime).
    5. Для защиты от хрупкого базового класса (проблема двойного счета в addAll).
  • Когда наследование оправдано: Template Method, иерархия Exception, инфраструктурный @MappedSuperclass в JPA.

Чего избегать (Red Flags):

  • ❌ Наследовать стандартные коллекции (ArrayList, HashMap) для создания своих структур данных.
  • ❌ Использовать наследование ради доступа к protected-полям.
  • ❌ Заявлять, что наследование лучше композиции, потому что «меньше писать кода» (технический долг на порядок превысит экономию строк).

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