Наследование и композиция это два способа переиспользовать код. При наследовании класс расширяет другой через extends и получает все его поля и методы: отношение «является» (IS-A). При композиции класс хранит другой объект в поле и делегирует ему работу: отношение «имеет» (HAS-A). Общая рекомендация: начинать с композиции, а наследование брать только для стабильной иерархии типов, где наследник действительно является частным случаем родителя. Ниже на примере системы учета сотрудников видно, почему.

Кратко

  • Наследование (extends) жестко связывает классы: наследник получает все методы родителя, даже ненужные, и правка родителя ломает всех потомков.
  • Композиция собирает объект из частей: поведение выносится в интерфейсы (SalaryCalculator, TimeTracker), а класс просто делегирует им вызовы.
  • Композиция обходит запрет множественного наследования: комбинаций поведения может быть сколько угодно, и менять их можно прямо во время работы программы.
  • Признак, что нужно наследование: честное «является» и стабильная иерархия. Признак композиции: «имеет», сменное поведение, несколько источников функциональности.
  • Классический антипример из JDK: Stack наследует Vector и вместе с ним получает методы, которые ломают дисциплину стека.
  • Золотое правило «предпочитай композицию наследованию» не запрещает наследование, а говорит, с чего начинать выбор.
Сегодня разберем одну из самых важных тем в ООП: выбор между наследованием и композицией. Это не просто теоретический вопрос: правильный выбор сделает твой код гибким и поддерживаемым, а неправильный превратит его в кошмар для рефакторинга 😅 Давай разбираться на реальных примерах!Поединок двух бойцов в клетке: наследование против композиции

Наследование: мощный, но опасный инструмент

Наследование это когда один класс расширяет другой, получая все его поля и методы. Звучит удобно, правда? Но есть подводные камни.

Пример: система управления сотрудниками

Представь, что ты разрабатываешь систему учета сотрудников для компании:
public class Employee {
    private String name;
    private String id;
    private double baseSalary;

    public Employee(String name, String id, double baseSalary) {
        this.name = name;
        this.id = id;
        this.baseSalary = baseSalary;
    }

    public double calculateSalary() {
        return baseSalary;
    }

    public void clockIn() {
        System.out.println(name + " пришел на работу в " + getCurrentTime());
    }

    public void clockOut() {
        System.out.println(name + " ушел с работы в " + getCurrentTime());
    }

    private String getCurrentTime() {
        return java.time.LocalTime.now().toString();
    }

    // Геттеры опущены для краткости
}
Теперь нужно добавить менеджера с бонусами:
public class Manager extends Employee {
    private double bonus;

    public Manager(String name, String id, double baseSalary, double bonus) {
        super(name, id, baseSalary);
        this.bonus = bonus;
    }

    @Override
    public double calculateSalary() {
        return super.calculateSalary() + bonus;
    }
}
Пока все выглядит отлично! Но подожди... 🤔

Проблема №1: Хрупкость базового класса

Через полгода HR-отдел просит добавить фрилансеров. Фрилансеры тоже сотрудники, но им не нужно отмечать приход и уход на работу, они работают удаленно:
public class Freelancer extends Employee {
    private double hourlyRate;
    private int hoursWorked;

    public Freelancer(String name, String id, double hourlyRate, int hoursWorked) {
        super(name, id, 0); // Базовая зарплата = 0
        this.hourlyRate = hourlyRate;
        this.hoursWorked = hoursWorked;
    }

    @Override
    public double calculateSalary() {
        return hourlyRate * hoursWorked;
    }

    // Но что делать с clockIn() и clockOut()?
    // Они унаследовались, но для фрилансера бессмысленны!
}
Видишь проблему? Фрилансер унаследовал методы clockIn() и clockOut(), которые ему не нужны. Можно, конечно, переопределить их как пустые, но это костыль.

Проблема №2: Жесткая связанность

Теперь представь, что компания решила изменить систему учета рабочего времени. Вместо простого clockIn/clockOut нужна интеграция с биометрическими терминалами. Придется менять базовый класс Employee, а это затронет ВСЕ дочерние классы, даже те, которым эти изменения не нужны. Твой код стал хрупким: изменения в родительском классе могут сломать наследников.

Проблема №3: Невозможность множественного наследования

А что если сотрудник одновременно и программист, и менеджер проекта? В Java нельзя унаследоваться от двух классов:
// Такое не работает в Java!
public class TechLead extends Programmer, Manager {
    // Ошибка компиляции
}

Композиция: гибкое решение

Композиция это когда объект содержит другие объекты как свои части. Вместо отношения «является» (IS-A) мы используем «имеет» (HAS-A). Давай перепишем нашу систему:

Шаг 1: Выделяем поведение в отдельные классы

// Интерфейс для расчета зарплаты
public interface SalaryCalculator {
    double calculate();
}

// Фиксированная зарплата
public class FixedSalary implements SalaryCalculator {
    private final double amount;

    public FixedSalary(double amount) {
        this.amount = amount;
    }

    @Override
    public double calculate() {
        return amount;
    }
}

// Почасовая оплата
public class HourlySalary implements SalaryCalculator {
    private final double hourlyRate;
    private int hoursWorked;

    public HourlySalary(double hourlyRate) {
        this.hourlyRate = hourlyRate;
    }

    public void addHours(int hours) {
        this.hoursWorked += hours;
    }

    @Override
    public double calculate() {
        return hourlyRate * hoursWorked;
    }
}

// Зарплата с бонусом: обертка над другим калькулятором
public class SalaryWithBonus implements SalaryCalculator {
    private final SalaryCalculator baseSalary;
    private final double bonus;

    public SalaryWithBonus(SalaryCalculator baseSalary, double bonus) {
        this.baseSalary = baseSalary;
        this.bonus = bonus;
    }

    @Override
    public double calculate() {
        return baseSalary.calculate() + bonus;
    }
}
Обрати внимание на SalaryWithBonus: он принимает другой SalaryCalculator и добавляет к его результату бонус. Это паттерн Декоратор в чистом виде, и работает он именно за счет композиции.

Шаг 2: Система учета времени

// Интерфейс для учета времени
public interface TimeTracker {
    void clockIn();
    void clockOut();
}

// Обычный учет времени
public class OfficeTimeTracker implements TimeTracker {
    private final String employeeName;

    public OfficeTimeTracker(String employeeName) {
        this.employeeName = employeeName;
    }

    @Override
    public void clockIn() {
        System.out.println("Вход в офис: " + employeeName + ", " + getCurrentTime());
    }

    @Override
    public void clockOut() {
        System.out.println("Выход из офиса: " + employeeName + ", " + getCurrentTime());
    }

    private String getCurrentTime() {
        return java.time.LocalTime.now().toString();
    }
}

// Пустой трекер для удаленных сотрудников
public class NoTimeTracker implements TimeTracker {
    @Override
    public void clockIn() {
        // Для фрилансеров не нужно
    }

    @Override
    public void clockOut() {
        // Для фрилансеров не нужно
    }
}

Шаг 3: Новый класс сотрудника

public class Employee {
    private final String name;
    private final String id;
    private final SalaryCalculator salaryCalculator;
    private final TimeTracker timeTracker;

    public Employee(String name, String id, 
                    SalaryCalculator salaryCalculator, 
                    TimeTracker timeTracker) {
        this.name = name;
        this.id = id;
        this.salaryCalculator = salaryCalculator;
        this.timeTracker = timeTracker;
    }

    public double getSalary() {
        return salaryCalculator.calculate();
    }

    public void clockIn() {
        timeTracker.clockIn();
    }

    public void clockOut() {
        timeTracker.clockOut();
    }

    public String getName() {
        return name;
    }

    public String getId() {
        return id;
    }
}

Шаг 4: Создаем разных сотрудников

public class CompanyDemo {
    public static void main(String[] args) {
        // Обычный сотрудник с фиксированной зарплатой
        Employee john = new Employee(
            "Джон",
            "EMP001",
            new FixedSalary(5000),
            new OfficeTimeTracker("Джон")
        );

        // Менеджер с бонусом
        Employee sarah = new Employee(
            "Сара",
            "MNG001",
            new SalaryWithBonus(new FixedSalary(7000), 1500),
            new OfficeTimeTracker("Сара")
        );

        // Фрилансер с почасовой оплатой
        HourlySalary freelancerSalary = new HourlySalary(100);
        freelancerSalary.addHours(120);
        
        Employee alex = new Employee(
            "Алекс",
            "FRL001",
            freelancerSalary,
            new NoTimeTracker()
        );

        // Используем
        john.clockIn();
        System.out.println("Зарплата Джона: " + john.getSalary() + " usd.");

        sarah.clockIn();
        System.out.println("Зарплата Сары: " + sarah.getSalary() + " usd.");

        alex.clockIn(); // Ничего не выведет
        System.out.println("Зарплата Алекса: " + alex.getSalary() + " usd.");
    }
}
Результат выполнения:
Вход в офис: Джон, 09:15:23.456 Зарплата Джона: 5000.0 usd. Вход в офис: Сара, 09:16:10.789 Зарплата Сары: 8500.0 usd. Зарплата Алекса: 12000.0 usd.

Преимущества композиции

1. Гибкость конфигурации

Можешь создавать любые комбинации поведения:
// Сотрудник с фиксированной зарплатой + бонус + офисный трекер
Employee mixedEmployee = new Employee(
    "Мария",
    "EMP002",
    new SalaryWithBonus(new FixedSalary(60000), 10000),
    new OfficeTimeTracker("Мария")
);

2. Легко менять поведение в рантайме

Хочешь изменить систему учета времени? Просто передай другую реализацию TimeTracker. Обрати внимание: ради сеттера поле timeTracker придется объявить без final, в отличие от варианта из шага 3.
public class Employee {
    private TimeTracker timeTracker;

    public void setTimeTracker(TimeTracker tracker) {
        this.timeTracker = tracker;
    }
}

// Сотрудник перешел на удаленку
employee.setTimeTracker(new NoTimeTracker());

3. Независимость компонентов

Изменения в TimeTracker не влияют на SalaryCalculator. Компоненты слабо связаны.

4. Можно комбинировать множество поведений

Помнишь проблему с TechLead? Теперь это не проблема:
// Создаем уникальную комбинацию поведения
Employee techLead = new Employee(
    "Игорь",
    "TL001",
    new SalaryWithBonus(new HourlySalary(2000), 30000),
    new OfficeTimeTracker("Игорь")
);

Реальный пример из Java API

Знаешь, в стандартной библиотеке Java есть класс Stack, который наследуется от Vector:
public class Stack<E> extends Vector<E> {
    // ...
}
Это был плохой дизайн! Stack получил все методы Vector, включая те, которые нарушают концепцию стека (например, add(index, element)). Класс никуда не делся и работает до сих пор, но в его документации прямо написано: более полный и последовательный набор операций стека дает интерфейс Deque, и использовать надо его реализации, например ArrayDeque. А свой стек поверх ArrayDeque собирают уже через композицию:
public class Stack<E> {
    private final Deque<E> elements = new ArrayDeque<>();

    public void push(E item) {
        elements.addFirst(item);
    }

    public E pop() {
        return elements.removeFirst();
    }
}

Когда использовать наследование, а когда композицию?

Используй наследование, если:

  • ✅ Есть четкая иерархия типов с отношением «является» (IS-A)
  • ✅ Дочерний класс действительно должен наследовать ВСЕ поведение родителя
  • ✅ Иерархия стабильна и вряд ли изменится
  • ✅ Хочешь использовать полиморфизм через общий интерфейс
Пример: Animal → Dog, Cat, Bird (животное это стабильная иерархия)

Используй композицию, если:

  • ✅ Нужна гибкость в комбинировании поведения
  • ✅ Отношение «имеет» (HAS-A): Car имеет Engine
  • ✅ Поведение может меняться в рантайме
  • ✅ Хочешь избежать хрупкости базового класса
  • ✅ Нужно комбинировать несколько источников функциональности
Пример: Employee имеет SalaryCalculator, имеет TimeTracker

Золотое правило

«Предпочитай композицию наследованию» это один из ключевых принципов ООП, сформулированный еще в книге «Приемы объектно-ориентированного проектирования» банды четырех. Это не значит, что наследование плохое, просто композиция дает больше гибкости в большинстве случаев. Начни с композиции, и только если уверен, что нужна жесткая иерархия типов, используй наследование.

Наследование и композиция: сравнение

Соберем различия в одну таблицу:
Критерий Наследование Композиция
Отношение «является» (IS-A): Manager это Employee «имеет» (HAS-A): Employee имеет SalaryCalculator
Связь классов жесткая, задана на этапе компиляции слабая, через интерфейс
Смена поведения в рантайме невозможна: тип объекта уже не изменить достаточно передать другую реализацию
Несколько источников поведения нельзя: у класса только один родитель сколько угодно полей с разными ролями
Что достается наследнику или владельцу все методы родителя, включая ненужные только то, что класс сам решил делегировать
Риск хрупкий базовый класс: правка родителя ломает потомков больше классов и объектов, нужен продуманный набор интерфейсов
Типичный случай стабильная иерархия типов: Animal, Dog, Cat сменные стратегии поведения, обертки, декораторы

Вопросы и ответы

Чем наследование отличается от композиции?

Наследование это отношение «является»: Manager extends Employee означает, что менеджер это сотрудник, и он получает все поля и методы родителя. Композиция это отношение «имеет»: класс хранит другой объект в поле и сам решает, какие его возможности показать наружу. Наследование фиксируется при компиляции и меняет тип объекта, композицию можно перенастроить в любой момент, просто подставив другую реализацию.

Что значит «предпочитай композицию наследованию»?

Это правило из книги «Приемы объектно-ориентированного проектирования» банды четырех: при переиспользовании кода сначала рассматривай композицию. Причина в том, что наследник видит внутренности родителя и зависит от его реализации, поэтому изменение базового класса легко ломает потомков. Композиция ограничивает связь публичным интерфейсом. Запрета на наследование правило не вводит: оно про то, с чего начинать выбор.

Когда наследование все-таки правильный выбор?

Когда между классами честное отношение «является», наследник не отменяет и не игнорирует поведение родителя, а иерархия стабильна: Animal и Dog, Exception и свой класс исключения, абстрактный класс фреймворка и его реализация. Если приходится переопределять унаследованные методы пустыми заглушками или бросать из них исключение, значит отношение не «является» и нужна композиция.

Что такое проблема хрупкого базового класса?

Это ситуация, когда безобидное изменение в родительском классе ломает наследников. Наследник зависит не только от сигнатур методов родителя, но и от того, как они устроены внутри: какой метод какой вызывает, в каком порядке меняется состояние. Стоит родителю поменять эти детали, и переопределенные методы начинают работать иначе. При композиции такой связи нет: класс обращается к чужому объекту только через его публичный интерфейс.

Почему java.util.Stack приводят как пример плохого наследования?

Потому что Stack наследует Vector и вместе с ним получает методы вроде add(index, element) и remove(index), которые позволяют лезть в середину стека и нарушают правило «последним пришел, первым ушел». Класс никуда не делся из JDK, но в его документации прямо сказано: предпочитать интерфейс Deque и его реализации, например ArrayDeque. Собственный стек поверх ArrayDeque это как раз композиция: наружу выходят только push() и pop().

Чем композиция отличается от агрегации?

И там, и там объект хранит ссылку на другой объект, разница во времени жизни частей. При композиции часть создается внутри владельца и умирает вместе с ним. При агрегации готовый объект передают снаружи, и он живет самостоятельно: именно так устроен пример в этой статье, где SalaryCalculator и TimeTracker приходят в конструктор. На практике оба приема называют композицией в широком смысле и противопоставляют наследованию. Подробнее в статье об отношениях между классами. Читайте также: