Привет! Сегодня мы поговорим об одном из самых важных принципов в Java: о наследовании. Это такая штука, которая сначала кажется простой, а потом оказывается, что в ней куча нюансов. Но не переживай, разберемся вместе!

Кратко

  • Наследование позволяет создать класс на основе существующего: наследник получает поля и методы родителя и добавляет свои. Пишется через extends.
  • Конструктор родителя всегда отрабатывает первым. Из конструктора наследника его зовут через super(...), и если у родителя нет конструктора без параметров, этот вызов обязателен.
  • Наследнику видно все, кроме private. Чтобы открыть поле потомкам, ставят protected.
  • Метод родителя можно переопределить, пометив его в наследнике аннотацией @Override.
  • Цепочки наследования допустимы любой длины, а корень у всех один: каждый класс в Java неявно наследуется от Object.
  • Наследование это отношение «является», композиция это «имеет». Если класс не является разновидностью другого, а просто им пользуется, нужна композиция.
Помнишь, когда ты только начинал программировать, ты просто писал код? Создавал классы, методы, переменные... А потом вдруг понял, что копируешь один и тот же код в разные места? Вот тут-то и приходит на помощь наследование.Плоская иллюстрация: генеалогическое дерево, вместо плодов на ветках круглые портреты членов семьи от старшего поколения к младшему

Что такое наследование и зачем оно вообще нужно?

Наследование это механизм в Java, который позволяет создавать новый класс на основе уже существующего. Класс-наследник получает доступ к полям и методам родительского класса. Звучит абстрактно? Давай на примере. Представь, что тебе нужно создать в программе несколько классов машин: Грузовик, Гоночная машина, Седан, Пикап и так далее. Ты садишься писать код и понимаешь: блин, у всех же машин есть куча общего! У всех есть:
  • Название модели
  • Год выпуска
  • Объем двигателя
  • Максимальная скорость
  • Четыре колеса (ну или больше, но суть ты понял)
  • Возможность ехать вперед и тормозить
Что ты можешь сделать? Вариант 1: Создавать эти поля в каждом классе заново. Грузовик: пишешь поля. Седан: пишешь те же поля. Пикап: опять те же поля. Скучно, долго, и главное: если потом захочешь что-то изменить, придется лезть в каждый класс. Вариант 2: Вынести общие поля и методы в родительский класс Car, а все остальные классы унаследовать от него с помощью ключевого слова extends. Второй вариант, конечно, гораздо лучше. Давай посмотрим, как это работает:

public class Car {
    private String model;
    private int maxSpeed;
    private int yearOfManufacture;

    public Car(String model, int maxSpeed, int yearOfManufacture) {
        this.model = model;
        this.maxSpeed = maxSpeed;
        this.yearOfManufacture = yearOfManufacture;
    }

    public void gas() {
        System.out.println("Машина ускоряется!");
    }

    public void brake() {
        System.out.println("Машина тормозит!");
    }
}
Вот наш базовый класс Car. А теперь создадим класс Truck (грузовик), который наследуется от Car:

public class Truck extends Car {
    public Truck(String model, int maxSpeed, int yearOfManufacture) {
        super(model, maxSpeed, yearOfManufacture);
    }
}
Видишь ключевое слово extends? Оно говорит: "Эй, Java, класс Truck это расширенная версия класса Car". И теперь у грузовика есть все, что есть у машины! А вот и седан:

public class Sedan extends Car {
    public Sedan(String model, int maxSpeed, int yearOfManufacture) {
        super(model, maxSpeed, yearOfManufacture);
    }
}

Стоп, а что за super?

Отличный вопрос! Слово super это способ обратиться к родительскому классу. Когда ты пишешь super(model, maxSpeed, yearOfManufacture), ты вызываешь конструктор родительского класса Car. Это очень важно понимать: когда ты создаешь объект класса-наследника, сначала Java создает "родительскую часть" объекта, а потом добавляет к ней то, что специфично для наследника. Давай посмотрим на более детальный пример:

public class Animal {
    String brain = "Мозг животного";
    String heart = "Сердце животного";

    public Animal(String brain, String heart) {
        System.out.println("Создается животное!");
        System.out.println("Мозг: " + brain);
        System.out.println("Сердце: " + heart);
        this.brain = brain;
        this.heart = heart;
    }
}

public class Cat extends Animal {
    String tail = "Хвост кота";

    public Cat(String brain, String heart, String tail) {
        super(brain, heart);  // Сначала вызываем родительский конструктор
        System.out.println("Теперь добавляем кошачьи части!");
        this.tail = tail;
    }

    public static void main(String[] args) {
        Cat cat = new Cat("Кошачий мозг", "Кошачье сердце", "Пушистый хвост");
    }
}
Запусти этот код в своей IDE, и увидишь:

Создается животное!
Мозг: Кошачий мозг
Сердце: Кошачье сердце
Теперь добавляем кошачьи части!
Видишь? Сначала отработал конструктор Animal, и только потом конструктор Cat. Это железное правило Java: конструктор родительского класса всегда вызывается первым.

Добавляем специфичное поведение

Окей, мы разобрались с общими полями. Но ведь у разных машин есть и уникальное поведение, верно? Гоночные машины могут делать пит-стопы, грузовики перевозить тяжелый груз, а седаны комфортно везти пассажиров. Давай добавим специфичные методы:

public class Car {
    private String model;
    private int maxSpeed;

    public Car(String model, int maxSpeed) {
        this.model = model;
        this.maxSpeed = maxSpeed;
    }

    public void gas() {
        System.out.println(model + " ускоряется!");
    }

    public void brake() {
        System.out.println(model + " тормозит!");
    }
}

public class F1Car extends Car {
    public F1Car(String model, int maxSpeed) {
        super(model, maxSpeed);
    }

    // Специфичный метод только для гоночных машин
    public void pitStop() {
        System.out.println("Пит-стоп! Меняем шины за 2 секунды!");
    }

    public static void main(String[] args) {
        F1Car formula = new F1Car("Ferrari SF-23", 350);
        formula.gas();       // Метод из родительского класса
        formula.pitStop();   // Свой метод
        formula.brake();     // Снова метод из родительского класса
    }
}
Вывод:

Ferrari SF-23 ускоряется!
Пит-стоп! Меняем шины за 2 секунды!
Ferrari SF-23 тормозит!
Круто, правда? У нас есть и общие методы (gas() и brake()), и специфичные для гоночных машин (pitStop()).

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

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

public class Car {
    protected String model;

    public Car(String model) {
        this.model = model;
    }

    public void gas() {
        System.out.println(model + " спокойно ускоряется");
    }
}

public class F1Car extends Car {
    public F1Car(String model) {
        super(model);
    }

    @Override
    public void gas() {
        System.out.println(model + " ВЗРЫВАЕТСЯ вперед с диким ревом мотора!");
    }

    public static void main(String[] args) {
        Car regularCar = new Car("Toyota Camry");
        F1Car raceCar = new F1Car("Ferrari SF-23");

        regularCar.gas();
        raceCar.gas();
    }
}
Вывод:

Toyota Camry спокойно ускоряется
Ferrari SF-23 ВЗРЫВАЕТСЯ вперед с диким ревом мотора!
Аннотация @Override говорит компилятору: "Я переопределяю метод родителя". Это не обязательно, но очень полезно: если ты случайно ошибешься в названии метода, компилятор тебя поправит. А сама возможность вызвать переопределенный метод через переменную родительского типа называется полиморфизмом. Кстати, обрати внимание: я изменил модификатор доступа поля model с private на protected. Это важный момент!

Модификаторы доступа при наследовании

Когда речь идет о наследовании, модификаторы доступа работают так:
  • private: поле или метод доступен только внутри класса. Наследники его не видят.
  • protected: доступен в самом классе, во всем его пакете и в наследниках, в том числе из других пакетов.
  • public: доступен везде.
  • package-private (без модификатора): доступен в пределах пакета.
Полная картина по четырем уровням доступа выглядит так (это стандартная таблица из документации Oracle):
МодификаторСвой классДругой класс того же пакетаНаследник в другом пакетеЛюбой класс в другом пакете
privateданетнетнет
package-private (без модификатора)даданетнет
protectedдададанет
publicдададада
Если ты хочешь, чтобы наследники могли работать с полем, используй protected. Если нет, private. Подробный разбор всех четырех уровней есть в статье про модификаторы доступа.

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

Давай посмотрим на более жизненный пример. Представь, что ты пишешь систему для компании, и тебе нужно работать с сотрудниками.

public class Employee {
    protected String name;
    protected int id;
    protected double baseSalary;

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

    public double calculateSalary() {
        return baseSalary;
    }

    public void displayInfo() {
        System.out.println("Сотрудник: " + name);
        System.out.println("ID: " + id);
        System.out.println("Зарплата: $" + calculateSalary());
    }
}

public class Manager extends Employee {
    private double bonus;

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

    @Override
    public double calculateSalary() {
        return baseSalary + bonus;
    }
}

public class Developer extends Employee {
    private int completedProjects;
    private double projectBonus;

    public Developer(String name, int id, double baseSalary, int completedProjects) {
        super(name, id, baseSalary);
        this.completedProjects = completedProjects;
        this.projectBonus = 500.0;
    }

    @Override
    public double calculateSalary() {
        return baseSalary + (completedProjects * projectBonus);
    }

    @Override
    public void displayInfo() {
        super.displayInfo();  // Вызываем родительский метод
        System.out.println("Завершенных проектов: " + completedProjects);
    }
}
Теперь проверим, как это работает:

public class Company {
    public static void main(String[] args) {
        Employee regularEmployee = new Employee("Иван Петров", 101, 3000);
        Manager manager = new Manager("Мария Сидорова", 201, 5000, 2000);
        Developer developer = new Developer("Алексей Кодов", 301, 4000, 3);

        System.out.println("=== Обычный сотрудник ===");
        regularEmployee.displayInfo();

        System.out.println("\n=== Менеджер ===");
        manager.displayInfo();

        System.out.println("\n=== Разработчик ===");
        developer.displayInfo();
    }
}
Вывод:

=== Обычный сотрудник ===
Сотрудник: Иван Петров
ID: 101
Зарплата: $3000.0

=== Менеджер ===
Сотрудник: Мария Сидорова
ID: 201
Зарплата: $7000.0

=== Разработчик ===
Сотрудник: Алексей Кодов
ID: 301
Зарплата: $5500.0
Завершенных проектов: 3
Видишь, как круто? У каждого типа сотрудника своя логика расчета зарплаты, но базовая структура одинаковая.

Цепочки наследования: наследник наследника

В Java можно создавать целые цепочки наследования. Например:

public class Vehicle {
    protected String name;

    public Vehicle(String name) {
        this.name = name;
        System.out.println("Создано транспортное средство: " + name);
    }
}

public class Car extends Vehicle {
    public Car(String name) {
        super(name);
        System.out.println("Это автомобиль!");
    }
}

public class ElectricCar extends Car {
    private int batteryCapacity;

    public ElectricCar(String name, int batteryCapacity) {
        super(name);
        this.batteryCapacity = batteryCapacity;
        System.out.println("Это электромобиль с батареей " + batteryCapacity + " кВт⋅ч");
    }

    public static void main(String[] args) {
        ElectricCar tesla = new ElectricCar("Tesla Model 3", 75);
    }
}
Вывод:

Создано транспортное средство: Tesla Model 3
Это автомобиль!
Это электромобиль с батареей 75 кВт⋅ч
Конструкторы вызываются по цепочке: сначала Vehicle, потом Car, потом ElectricCar. Это всегда работает так: от самого верхнего предка к самому нижнему потомку. А вот вширь цепочка не растет: у класса в Java может быть только один прямой родитель. Написать class ElectricCar extends Car, Battery не выйдет, множественное наследование классов в языке запрещено. Если нужно набрать поведение из нескольких источников, для этого есть интерфейсы: их один класс может реализовать сколько угодно.

Object: прародитель всех классов

Кстати, есть один интересный факт: в Java вообще все классы наследуются от класса Object. Даже если ты не пишешь extends Object, Java делает это автоматически.

public class MyClass {
    // Неявно здесь: extends Object
}
Это значит, что у любого твоего класса есть методы из Object: toString(), equals(), hashCode() и другие. Именно поэтому ты можешь вызвать toString() у любого объекта: этот метод унаследован от Object! Про то, как правильно переопределять equals() и hashCode(), у нас есть отдельная статья.

Композиция vs Наследование

Окей, наследование это круто. Но есть важный момент, о котором нужно знать: не всегда наследование правильный выбор. Иногда лучше использовать композицию. Наследование работает по принципу "является" (IS-A):
  • Грузовик ЯВЛЯЕТСЯ машиной
  • Кот ЯВЛЯЕТСЯ животным
  • Менеджер ЯВЛЯЕТСЯ сотрудником
Композиция работает по принципу "имеет" (HAS-A):
  • Машина ИМЕЕТ двигатель
  • Компьютер ИМЕЕТ процессор
  • Студент ИМЕЕТ адрес
Вот пример композиции:

public class Engine {
    private int horsepower;

    public Engine(int horsepower) {
        this.horsepower = horsepower;
    }

    public void start() {
        System.out.println("Двигатель запущен! Мощность: " + horsepower + " л.с.");
    }
}

public class Car {
    private String model;
    private Engine engine;  // Композиция: машина ИМЕЕТ двигатель

    public Car(String model, Engine engine) {
        this.model = model;
        this.engine = engine;
    }

    public void startCar() {
        System.out.println("Заводим " + model);
        engine.start();
    }

    public static void main(String[] args) {
        Engine powerfulEngine = new Engine(450);
        Car sportsCar = new Car("Porsche 911", powerfulEngine);
        sportsCar.startCar();
    }
}
Когда использовать наследование, а когда композицию? Общее правило такое: если между классами есть отношение "является", используй наследование. Если "имеет", то композицию.

Частые ошибки новичков

Давай разберем типичные косяки, которые делают начинающие:

Ошибка 1: Забыть вызвать super()

Классика, на которой спотыкается почти каждый новичок:

public class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }
}

public class Cat extends Animal {
    public Cat(String name) {
        // Ошибка! Забыли вызвать super(name)
        // Компилятор выдаст ошибку
    }
}
Если у родительского класса есть конструктор с параметрами, ты обязан вызвать super() с нужными аргументами в конструкторе наследника.

Ошибка 2: Пытаться обратиться к private полям родителя

Такой код тоже не скомпилируется:

public class Parent {
    private int secretNumber = 42;
}

public class Child extends Parent {
    public void revealSecret() {
        System.out.println(secretNumber);  // Ошибка! private поле не видно
    }
}
Если нужно, чтобы наследники видели поле, используй protected вместо private.

Ошибка 3: Бесконтрольное использование наследования

Не надо создавать наследование там, где его не должно быть. Классический антипример:

// ПЛОХОЙ пример!
public class Stack extends ArrayList {
    // ...
}
Стек это не "разновидность" ArrayList! Это структура данных, которая ИСПОЛЬЗУЕТ список внутри. Правильно было бы использовать композицию, а не наследование. Кстати, автор класса может закрыть эту лазейку сам. Если пометить класс словом final, наследоваться от него не получится вообще: именно так сделан String. А с Java 17 есть вариант помягче, модификатор sealed: он наследование разрешает, но только тем классам, которые автор перечислит явно.

Итак, что мы узнали?

Наследование это мощный инструмент, который:
  • Помогает избежать дублирования кода
  • Создает логичную иерархию классов
  • Позволяет переиспользовать и расширять существующую функциональность
Основные моменты:
  • Используй extends для создания наследника
  • Вызывай super() в конструкторе наследника
  • Используй @Override для переопределения методов
  • Выбирай protected для полей, которые должны быть доступны наследникам
  • Помни: наследование для отношения "является", композиция для "имеет"

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

Что такое наследование в Java?

Это механизм, который позволяет создать класс на основе уже существующего. Наследник получает поля и методы родителя, может добавить свои и переопределить чужие. Пишется через ключевое слово extends.

Зачем нужен вызов super() в конструкторе наследника?

Он запускает конструктор родителя, чтобы тот заполнил свою часть объекта: Java всегда собирает объект сверху вниз по иерархии. Если у родительского класса нет конструктора без параметров, вызов super(...) с аргументами обязателен, иначе код не скомпилируется.

Можно ли наследоваться сразу от нескольких классов?

Нет, у класса в Java может быть только один прямой родитель, множественное наследование классов запрещено. Зато класс может реализовать сколько угодно интерфейсов, а цепочка наследования может быть любой глубины.

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

Наследование это отношение «является»: грузовик является машиной. Композиция это отношение «имеет»: машина имеет двигатель. Если класс не является разновидностью другого, а просто им пользуется, брать надо композицию.

Видит ли наследник private-поля родителя?

Нет. Поле с модификатором private доступно только внутри своего класса, наследнику оно не видно. Чтобы открыть поле потомкам, ставят protected: такое поле видно и во всем пакете, и в наследниках из других пакетов.

Как запретить наследование от своего класса?

Пометить класс словом final: от финального класса наследоваться нельзя, так сделан, например, String. С Java 17 есть и более гибкий вариант, модификатор sealed, который разрешает наследование только перечисленным классам. Источник: Oracle Java Tutorials: Inheritance.

Читайте также

Что еще почитать

Теперь, когда ты разобрался с наследованием, самое время изучить Оператор Instanceof, он помогает проверять типы объектов в иерархии наследования.