☕ Java

ООП: Наследование в Java: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты решил создать свою видеоигру. В ней будут разные персонажи: воин, маг и лучник. У каждого из них есть имя, уровень здоровья и способность атаковать. Если бы мы писали код без наследования, нам пришлось бы для каждого класса персонажей заново писать одни и те же поля (имя, здоровье) и методы (атака, получение урона). Это долго, скучно и чревато ошибками — вдруг в одном классе мы забудем обновить здоровье, а в другом нет?

А теперь представь, что существует базовый шаблон — «Персонаж». В нём уже есть всё общее: имя, здоровье, метод атаки. А воин, маг и лучик просто говорят: «Я — персонаж, но добавлю что-то своё». Воин добавит «броню», маг — «магическую энергию», а лучик — «дальность стрельбы». Это и есть наследование.

Наследование (Inheritance) — это механизм ООП, который позволяет создать новый класс на основе существующего. Новый класс (потомок, подкласс, дочерний класс) получает все поля и методы родительского класса (суперкласса, базового класса) и может добавить свои собственные или изменить (переопределить) существующие.

В реальной жизни наследование встречается повсюду:

  • Автомобили: есть базовый класс «Транспортное средство» (есть колёса, двигатель, руль). От него наследуют «Легковой автомобиль» (добавляет багажник, 4 двери) и «Грузовик» (добавляет кузов, грузоподъёмность).
  • Животные: базовый класс «Животное» (есть имя, возраст, метод «подать голос»). От него — «Кошка» (мяукает), «Собака» (лает), «Корова» (мычит).
  • Геометрические фигуры: базовый класс «Фигура» (есть цвет, метод «вычислить площадь»). От него — «Круг», «Квадрат», «Треугольник» (каждый вычисляет площадь по-своему).

Зачем это нужно?

  1. Переиспользование кода. Не нужно писать одно и то же много раз.
  2. Иерархия и логика. Код становится структурированным, как каталог в компьютере.
  3. Лёгкость изменений. Если нужно изменить что-то общее (например, способ расчёта здоровья), меняешь только в родительском классе — и всё обновляется у всех потомков.
  4. Полиморфизм. (Мы изучим его позже) — возможность работать с разными классами через единый интерфейс родителя.

Теория: как это работает изнутри

Давай заглянем «под капот» Java и поймём, что происходит в памяти, когда мы используем наследование.

Ключевые понятия

  • Суперкласс (родитель, базовый класс) — класс, от которого наследуют. Содержит общие поля и методы.
  • Подкласс (потомок, дочерний класс) — класс, который наследует. Он автоматически получает все public и protected поля и методы родителя.
  • Ключевое слово extends — именно оно указывает, что один класс наследует от другого.
  • Переопределение методов (Override) — когда потомок даёт новую реализацию метода родителя. Например, родительский метод «атаковать» наносит 10 урона, а воин переопределяет его и наносит 20.
  • Ключевое слово super — ссылка на родительский класс. С её помощью можно вызвать конструктор или метод родителя.

Что наследуется, а что нет?

Член класса Наследуется? Примечание
public поля и методы Да Доступны напрямую
protected поля и методы Да Доступны в потомке и в одном пакете
private поля и методы Нет Они есть в объекте, но напрямую не видны. Доступ через super или публичные методы родителя
Конструкторы Нет Потомок обязан вызвать конструктор родителя через super()
Статические методы и поля Да (с оговорками) Они принадлежат классу, а не объекту. Потомок может их использовать, но переопределять статические методы нельзя (это называется сокрытие)

Как Java строит объект потомка?

Когда ты создаёшь объект потомка (new Warrior()), Java делает следующее:

  1. Выделяет память под объект.
  2. Вызывает конструктор родительского класса (даже если ты его не написал — компилятор вставляет super() автоматически).
  3. Инициализирует поля родителя.
  4. Вызывает конструктор потомка.
  5. Инициализирует поля потомка.

В итоге в памяти лежит единый объект, который содержит все поля и родителя, и потомка. Просто некоторые поля (private) скрыты от прямого доступа.

Синтаксис в Java: все формы и варианты

В Java наследование реализуется с помощью ключевого слова extends. В отличие от некоторых языков (например, C++), Java поддерживает только одиночное наследование классов — один класс может иметь только одного прямого родителя. Однако можно строить цепочки: ClassC extends ClassB extends ClassA.

Базовая форма

// Родительский класс
class Animal {
    String name;
    int age;

    // Конструктор родителя
    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void makeSound() {
        System.out.println("Животное издаёт звук");
    }
}

// Дочерний класс
class Dog extends Animal {
    String breed; // своё поле

    // Конструктор потомка
    public Dog(String name, int age, String breed) {
        super(name, age); // обязательно вызываем конструктор родителя!
        this.breed = breed;
    }

    // Переопределяем метод
    @Override
    public void makeSound() {
        System.out.println("Гав-гав!");
    }

    // Свой метод
    public void fetch() {
        System.out.println(name + " приносит палку");
    }
}

Ключевое слово super

super используется в двух случаях:

  1. Вызов конструктора родителя — super(параметры). Должен быть первой строкой в конструкторе потомка.
  2. Вызов метода или поля родителя — super.метод() или super.поле. Полезно, если ты переопределил метод, но хочешь использовать версию родителя внутри своей реализации.
class Cat extends Animal {
    public Cat(String name, int age) {
        super(name, age);
    }

    @Override
    public void makeSound() {
        // Сначала вызываем родительский метод
        super.makeSound();
        // Потом добавляем своё
        System.out.println("Мяу-мяу!");
    }
}

Аннотация @Override

Это не обязательная, но очень полезная подсказка компилятору. Она говорит: «Я намеренно переопределяю метод родителя». Если ты ошибёшься в имени или параметрах, компилятор выдаст ошибку. Всегда используй @Override — это защита от глупых опечаток.

Запрет наследования: final класс

Если поставить final перед классом, от него нельзя будет наследовать.

final class Constants {
    // Этот класс нельзя расширить
}

// Ошибка компиляции!
// class ExtendedConstants extends Constants { }

Запрет переопределения: final метод

Если метод помечен как final, потомок не сможет его переопределить.

class Parent {
    public final void criticalMethod() {
        // Этот метод нельзя изменить в потомках
    }
}

Абстрактные классы и наследование

Абстрактный класс — это класс, который может содержать абстрактные методы (без реализации). Он создан только для того, чтобы от него наследовали. Создать объект абстрактного класса напрямую нельзя.

abstract class Shape {
    String color;

    public Shape(String color) {
        this.color = color;
    }

    // Абстрактный метод — потомок обязан его реализовать
    public abstract double getArea();

    // Обычный метод
    public void displayColor() {
        System.out.println("Цвет: " + color);
    }
}

class Circle extends Shape {
    double radius;

    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }

    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}

Примеры кода — от простого к сложному

Пример 1: Базовое наследование (животные)

// Базовый класс
class Animal {
    String name;

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

    public void eat() {
        System.out.println(name + " ест");
    }
}

// Потомок
class Dog extends Animal {
    public Dog(String name) {
        super(name); // вызываем конструктор родителя
    }

    public void bark() {
        System.out.println(name + " лает: Гав!");
    }
}

// Использование
public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog("Бобик");
        dog.eat();  // Унаследованный метод
        dog.bark(); // Свой метод
    }
}

Объяснение: Класс Dog наследует поле name и метод eat() от Animal. Мы создаём собаку, вызываем оба метода. Всё работает.

Пример 2: Переопределение метода

class Animal {
    public void makeSound() {
        System.out.println("Животное издаёт звук");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Мяу!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal myAnimal = new Animal();
        Animal myCat = new Cat(); // Полиморфизм: ссылка типа Animal, объект типа Cat

        myAnimal.makeSound(); // "Животное издаёт звук"
        myCat.makeSound();    // "Мяу!" — вызывается переопределённый метод
    }
}

Объяснение: Даже если переменная имеет тип Animal, но хранит объект Cat, Java вызовет метод именно того класса, объект которого создан. Это называется динамическое связывание (позднее связывание).

Пример 3: Использование super для вызова родительского метода

class Vehicle {
    public void start() {
        System.out.println("Запуск двигателя...");
    }
}

class Car extends Vehicle {
    @Override
    public void start() {
        super.start(); // сначала делаем то, что делает родитель
        System.out.println("Проверка зеркал, пристегнуться!");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.start();
        // Вывод:
        // Запуск двигателя...
        // Проверка зеркал, пристегнуться!
    }
}

Объяснение: Мы не заменяем родительский метод полностью, а расширяем его. Сначала вызываем super.start(), потом добавляем свою логику.

Пример 4: Цепочка наследования (многоуровневое)

class LivingBeing {
    boolean isAlive = true;
}

class Animal extends LivingBeing {
    String name;
}

class Mammal extends Animal {
    boolean hasFur = true;
}

class Dog extends Mammal {
    public void bark() {
        System.out.println(name + " лает");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.name = "Рекс";
        System.out.println(dog.isAlive); // true — унаследовано от LivingBeing
        System.out.println(dog.hasFur);  // true — унаследовано от Mammal
        dog.bark();                      // "Рекс лает"
    }
}

Объяснение: Dog наследует от Mammal, тот от Animal, тот от LivingBeing. Все поля и методы доступны в самом нижнем классе.

Пример 5: Абстрактный класс и обязательная реализация

abstract class Employee {
    String name;
    double baseSalary;

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

    // Абстрактный метод — каждый сотрудник получает зарплату по-своему
    public abstract double calculateSalary();

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

class Manager extends Employee {
    double bonus;

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

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

class Developer extends Employee {
    int projectsCompleted;
    double ratePerProject;

    public Developer(String name, double baseSalary, int projectsCompleted, double ratePerProject) {
        super(name, baseSalary);
        this.projectsCompleted = projectsCompleted;
        this.ratePerProject = ratePerProject;
    }

    @Override
    public double calculateSalary() {
        return baseSalary + (projectsCompleted * ratePerProject);
    }
}

public class Main {
    public static void main(String[] args) {
        Employee[] employees = new Employee[2];
        employees[0] = new Manager("Анна", 50000, 15000);
        employees[1] = new Developer("Пётр", 40000, 5, 3000);

        for (Employee emp : employees) {
            emp.displayInfo();
        }
    }
}

Объяснение: Абстрактный класс Employee задаёт общую структуру, но расчёт зарплаты оставляет потомкам. Каждый потомок обязан реализовать calculateSalary(). Мы можем хранить разных сотрудников в одном массиве типа Employee[] — это полиморфизм.

Пример 6: Защита от изменений — final метод

class BankAccount {
    protected double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    // Этот метод нельзя переопределить — базовая логика должна быть неизменной
    public final void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            System.out.println("Внесено: " + amount + ". Баланс: " + balance);
        }
    }

    // А этот можно переопределить
    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            System.out.println("Снято: " + amount + ". Баланс: " + balance);
        } else {
            System.out.println("Недостаточно средств");
        }
    }
}

class SavingsAccount extends BankAccount {
    double interestRate;

    public SavingsAccount(double initialBalance, double interestRate) {
        super(initialBalance);
        this.interestRate = interestRate;
    }

    // Ошибка! Нельзя переопределить final метод
    // @Override
    // public void deposit(double amount) { ... }

    @Override
    public void withdraw(double amount) {
        // Добавляем комиссию за снятие
        double fee = 50;
        super.withdraw(amount + fee);
    }
}

Объяснение: Метод deposit помечен как final, чтобы никто не мог изменить базовую логику внесения денег. А withdraw можно переопределить, добавив комиссию.

Пример 7: Конструкторы и порядок инициализации

class Parent {
    String parentField;

    public Parent() {
        System.out.println("1. Конструктор Parent без параметров");
        parentField = "Родитель";
    }

    public Parent(String value) {
        System.out.println("1. Конструктор Parent с параметром: " + value);
        parentField = value;
    }
}

class Child extends Parent {
    String childField;

    public Child() {
        // Неявно вызывается super() — конструктор Parent без параметров
        System.out.println("2. Конструктор Child без параметров");
        childField = "Ребёнок";
    }

    public Child(String parentValue, String childValue) {
        super(parentValue); // Явно вызываем конструктор Parent с параметром
        System.out.println("2. Конструктор Child с параметрами");
        childField = childValue;
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("--- Создаём Child() ---");
        Child child1 = new Child();

        System.out.println("
--- Создаём Child("Папа", "Сын") ---");
        Child child2 = new Child("Папа", "Сын");
    }
}

Объяснение: При создании объекта потомка всегда сначала выполняется конструктор родителя (даже если он не указан явно). Это гарантирует, что родительская часть объекта будет корректно инициализирована.

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python или планируешь его изучать, вот ключевые различия в реализации наследования:

Характеристика Java Python
Ключевое слово для наследования extends Класс-родитель указывается в скобках: class Dog(Animal):
Множественное наследование Нет (только одиночное) Да (можно наследовать от нескольких классов)
Вызов конструктора родителя super(параметры) — обязательно первая строка super().__init__(параметры) — не обязательно первая строка
Переопределение методов Аннотация @Override (опционально) Просто объявляешь метод с тем же именем
Доступ к private полям Строгий: private поля не видны потомку Нет строгих модификаторов доступа (есть соглашение _private)
Абстрактные классы Через abstract class и abstract методы Через модуль abc (Abstract Base Classes)
Динамическое связывание Да (все нестатические методы) Да (все методы)
Статические методы Наследуются, но не переопределяются (скрываются) Наследуются и могут быть переопределены

Пример на Python для сравнения:

class Animal:
    def __init__(self, name):
        self.name = name

    def make_sound(self):
        print("Животное издаёт звук")

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # вызов конструктора родителя
        self.breed = breed

    def make_sound(self):  # переопределение
        print("Гав-гав!")

# Использование
dog = Dog("Бобик", "Такса")
dog.make_sound()  # "Гав-гав!"
Важное замечание: В Python множественное наследование может привести к «проблеме ромба» (когда два родителя наследуют от одного общего предка). Java решила эту проблему радикально — запретив множественное наследование классов. Вместо этого Java предлагает интерфейсы (о них мы поговорим в следующих уроках).

Типичные ошибки и подводные камни

1. Забыть вызвать super() в конструкторе потомка

Если в родительском классе нет конструктора без параметров, компилятор выдаст ошибку. Всегда явно вызывай super(параметры).

class Parent {
    public Parent(int x) { }
}

class Child extends Parent {
    public Child() {
        // Ошибка! Нет конструктора Parent() без параметров
        // Нужно: super(0);
    }
}

2. Переопределить метод, но случайно изменить сигнатуру

Если ты напишешь public void makeSound(String extra) вместо public void makeSound(), это будет не переопределение, а перегрузка (новый метод с другим именем или параметрами). Используй @Override — компилятор сразу укажет на ошибку.

3. Думать, что private поля наследуются

Они есть в объекте, но напрямую не видны. Если нужно дать доступ потомку, используй protected или создай публичные геттеры/сеттеры.

4. Слишком глубокая иерархия наследования

Цепочка из 5-6 уровней — это плохо. Код становится запутанным, сложно понять, откуда берётся тот или иной метод. Старайся ограничиваться 2-3 уровнями.

5. Наследование ради переиспользования, когда нужна композиция

Не всегда наследование — лучший выбор. Если связь между классами — «имеет» (has-a), а не «является» (is-a), лучше использовать композицию. Например, «Машина имеет двигатель» — не нужно наследовать Car от Engine. Сделай Engine полем класса Car.

6. Изменение состояния родителя через потомка

Если потомок изменяет protected поле родителя, это может сломать логику родительского класса. Будь осторожен и документируй такие изменения.

Лучшие практики и советы профессионалов

  1. Принцип подстановки Лисков (LSP). Потомок должен полностью заменять родителя без изменения ожидаемого поведения. Если метод родителя выбрасывает исключение, потомок не должен выбрасывать более общее исключение. Если родитель возвращает число, потомок не должен возвращать строку.
  2. Используй @Override всегда. Это спасёт от глупых ошибок и сделает код понятнее.
  3. Делай поля private, а не protected. Если потомку нужен доступ, предоставь protected геттер/сеттер. Это даёт больше контроля.
  4. Предпочитай композицию наследованию. Если сомневаешься, используй композицию. Наследование создаёт жёсткую связь, которую трудно изменить.
  5. Не создавай глубокие иерархии. 2-3 уровня — максимум. Если нужно больше, возможно, ты неправильно спроектировал архитектуру.
  6. Документируй, какие методы предназначены для переопределения. В JavaDoc пиши, что метод можно переопределить, и какие есть ограничения.
  7. Используй абстрактные классы для общих шаблонов. Если у тебя есть несколько классов с общей логикой, но разной реализацией некоторых методов, создай абстрактный класс.
  8. Помни о final. Если метод или класс не должны изменяться, помечай их final. Это делает код безопаснее и понятнее.

Вопросы для самопроверки

  1. Что такое наследование в ООП?

    Ответ

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

  2. Какое ключевое слово используется в Java для наследования?

    Ответ

    extends. Например: class Dog extends Animal.

  3. Что такое переопределение метода (Override)?

    Ответ

    Это когда потомок даёт новую реализацию метода, который уже есть в родителе. Метод должен иметь ту же сигнатуру (имя и параметры).

  4. Для чего используется аннотация @Override?

    Ответ

    Она сообщает компилятору, что мы намеренно переопределяем метод. Если сигнатура не совпадает с родительским методом, компилятор выдаст ошибку.

  5. Что делает ключевое слово super?

    Ответ

    Оно ссылается на родительский класс. Используется для вызова конструктора родителя (super(параметры)) или для вызова метода/поля родителя (super.метод()).

  6. Может ли класс наследовать от двух классов одновременно в Java?

    Ответ

    Нет, Java поддерживает только одиночное наследование классов. Один класс может иметь только одного прямого родителя.

  7. Что такое абстрактный класс?

    Ответ

    Это класс, помеченный ключевым словом abstract. Он может содержать абстрактные методы (без реализации). Создать объект абстрактного класса напрямую нельзя — только через потомков, которые реализуют все абстрактные методы.

  8. В чём разница между private и protected полями при наследовании?

    Ответ

    private поля не видны потомку напрямую, хотя они есть в объекте. protected поля видны потомку и классам в том же пакете.

  9. Что произойдёт, если не вызвать super() в конструкторе потомка?

    Ответ

    Компилятор автоматически вставит вызов super() (конструктор без параметров). Если такого конструктора в родителе нет, будет ошибка компиляции.

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

    Ответ

    Пометить класс ключевым словом final. Например: final class Constants { }.

Итог и что изучить дальше

Мы разобрали одну из тр