1. Переопределение методов
Переопределение (overriding) — это возможность в подклассе написать собственную версию метода, который уже есть в родительском классе. Благодаря этому работает настоящий полиморфизм во время выполнения программы (run-time).
Простыми словами:
Если у вас есть базовый класс с методом, и вы хотите, чтобы подкласс выполнял этот метод по-своему, вы просто объявляете метод с такой же сигнатурой в подклассе. При вызове метода через ссылку базового типа будет вызываться версия метода из реального (фактического) типа объекта.
Пример из жизни.
Представьте, что у вас есть команда животных, и вы просите каждого «издать звук». Для всех это команда makeSound(), но собака лает, кошка мяукает, а корова мычит. Для кода это выглядит как вызов одного и того же метода, но результат разный — вот она, магия переопределения!
Синтаксис переопределения
Базовый пример
class Animal {
void makeSound() {
System.out.println("Животное издаёт какой-то звук");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Гав!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Мяу!");
}
}
Здесь в классе Animal определён метод makeSound(). Подклассы Dog и Cat переопределяют этот метод, предоставляя свою реализацию.
Аннотация @Override
В Java рекомендуется (и это хорошая привычка) отмечать переопределяемые методы аннотацией @Override:
@Override
void makeSound() { ... }
Это не обязательно для работы кода, но:
- Компилятор проверит, действительно ли вы переопределяете метод (а не случайно опечатались в имени или параметрах).
- Улучшает читаемость кода — другим программистам сразу видно, что этот метод переопределяет родительский.
Вызов переопределённого метода
Animal myDog = new Dog();
myDog.makeSound(); // Выведет: Гав!
Хотя переменная типа Animal, реально она указывает на объект Dog, и вызывается метод из класса Dog. Это и есть динамическое (позднее) связывание.
2. Отличие переопределения (overriding) от перегрузки (overloading)
Перегрузка (overloading)
- В одном классе (или в иерархии, но всегда «рядом»).
- Методы имеют одинаковое имя, но разные параметры (тип, количество, порядок).
- Выбор метода происходит во время компиляции.
void print(int x) { ... }
void print(String s) { ... }
Переопределение (overriding)
- В разных классах: метод объявлен в суперклассе и переопределён в подклассе.
- Методы имеют одинаковое имя и сигнатуру (параметры и возвращаемый тип).
- Выбор метода происходит во время выполнения (run-time), на основе фактического типа объекта.
Таблица-сравнение
| Перегрузка (overloading) | Переопределение (overriding) | |
|---|---|---|
| Где | В одном классе | В суперклассе и подклассе |
| Имя метода | Одинаковое | Одинаковое |
| Параметры | Разные | Одинаковые |
| Возвращаемый тип | Может отличаться | Должен совпадать или быть подтипом |
| Когда выбирается | Компиляция | Выполнение (run-time) |
| Аннотация | Не требуется | @Override (рекомендуется) |
3. Правила переопределения методов
Переопределение — штука мощная, но с ней связаны строгие правила. Давайте разберём их по порядку.
Сигнатура метода должна совпадать
- Имя метода, типы и порядок параметров должны быть идентичны методу в суперклассе.
- Возвращаемый тип должен совпадать или быть ковариантным (то есть подтипом возвращаемого типа родительского метода).
Пример с ковариантным возвращаемым типом:
class Animal {
Animal reproduce() { return new Animal(); }
}
class Cat extends Animal {
@Override
Cat reproduce() { return new Cat(); } // ОК! Cat — подтип Animal
}
Модификатор доступа
Модификатор доступа переопределённого метода не может быть более строгим, чем у метода в суперклассе. Если метод в родителе public, то в подклассе он обязан остаться таким же public. Сделать его менее доступным (protected или private) нельзя.
Пример:
class Parent {
public void greet() { }
}
class Child extends Parent {
// void greet() { } // Ошибка! Модификатор по умолчанию — package-private, менее доступен, чем public
@Override
public void greet() { } // ОК
}
Исключения
- Переопределённый метод не может выбрасывать новые checked-исключения, которые не объявлены в базовом методе.
- Можно выбрасывать меньше или те же исключения.
Пример:
class Parent {
void doWork() throws IOException { }
}
class Child extends Parent {
@Override
void doWork() throws FileNotFoundException { } // ОК, FileNotFoundException — подтип IOException
// void doWork() throws SQLException { } // Ошибка! SQLException не объявлен в родителе
}
Статические методы не переопределяются
Статические методы могут быть скрыты (hidden), но не переопределены. Если вы объявите статический метод с такой же сигнатурой в подклассе, это не переопределение! Это будет просто скрытие метода, а не полиморфизм.
class Animal {
static void info() { System.out.println("Animal"); }
}
class Dog extends Animal {
static void info() { System.out.println("Dog"); }
}
Вызов Dog.info() выведет "Dog", но если вызвать через переменную типа Animal, будет вызван метод Animal.info(). Это не полиморфизм!
final-методы нельзя переопределять
Если метод в суперклассе объявлен как final, попытка переопределить его приведёт к ошибке компиляции.
class Animal {
final void sleep() { }
}
class Dog extends Animal {
// @Override
// void sleep() { } // Ошибка! Нельзя переопределять final-метод
}
4. Практические примеры
Давайте рассмотрим на практике, как работает переопределение и чем оно отличается от перегрузки.
Пример 1: Класс Shape и его потомки
class Shape {
void draw() {
System.out.println("Рисуем фигуру");
}
}
class Circle extends Shape {
@Override
void draw() {
System.out.println("Рисуем круг");
}
}
class Rectangle extends Shape {
@Override
void draw() {
System.out.println("Рисуем прямоугольник");
}
}
Используем полиморфизм:
public class Main {
public static void main(String[] args) {
Shape s1 = new Circle();
Shape s2 = new Rectangle();
s1.draw(); // Рисуем круг
s2.draw(); // Рисуем прямоугольник
}
}
Хотя переменные объявлены как Shape, вызывается метод именно того класса, к которому реально относится объект.
Пример 2: Отличие от перегрузки
class Printer {
void print(String s) {
System.out.println("Строка: " + s);
}
void print(int n) {
System.out.println("Число: " + n);
}
}
Здесь оба метода называются print, но у них разные параметры — это перегрузка, а не переопределение.
5. Переопределение и вызов родительского метода (super)
Иногда в переопределённом методе хочется сначала выполнить логику родителя, а потом добавить своё. Для этого используется ключевое слово super.
class Animal {
void makeSound() {
System.out.println("Животное издаёт звук");
}
}
class Dog extends Animal {
@Override
void makeSound() {
super.makeSound(); // Вызов метода родителя
System.out.println("Гав!");
}
}
Вызов new Dog().makeSound() выведет:
Животное издаёт звук
Гав!
Как работает динамическое связывание (late binding)
Когда вы вызываете метод через ссылку на базовый тип, Java во время выполнения смотрит, к какому реальному объекту относится эта ссылка, и вызывает именно ту версию метода, которая определена в классе этого объекта.
Animal a = new Cat();
a.makeSound(); // Вызовет Cat.makeSound(), а не Animal.makeSound()
Это и есть основа полиморфизма в Java.
6. Как это связано с вашим приложением
В нашем учебном приложении (например, системе учёта сотрудников) вы можете создать базовый класс Employee с методом work(), а подклассы Manager и Developer могут по-своему реализовать этот метод:
class Employee {
void work() {
System.out.println("Сотрудник работает");
}
}
class Manager extends Employee {
@Override
void work() {
System.out.println("Менеджер руководит");
}
}
class Developer extends Employee {
@Override
void work() {
System.out.println("Разработчик пишет код");
}
}
Теперь вы можете хранить всех сотрудников в одном массиве или списке:
Employee[] employees = {new Manager(), new Developer(), new Developer()};
for (Employee e : employees) {
e.work(); // Для каждого — свой вывод!
}
7. Типичные ошибки при переопределении методов
Ошибка №1: опечатка в имени метода или параметрах. Если вы случайно ошиблись в имени метода или его параметрах, вы не переопределяете метод, а создаёте новый. В результате полиморфизм не работает. Именно поэтому всегда используйте аннотацию @Override — компилятор сразу вас поправит.
Ошибка №2: более строгий модификатор доступа. Если в родителе метод public, а в дочернем классе вы объявили его как protected или без модификатора — получите ошибку компиляции.
Ошибка №3: попытка переопределить static или final метод. Статические методы не переопределяются, а final-методы вообще не поддаются переопределению. Если вы это попробуете — компилятор вас остановит.
Ошибка №4: изменение типа возвращаемого значения на несовместимый. Если возвращаемый тип метода в подклассе не совпадает с типом в суперклассе (и не является его подтипом), компилятор не позволит переопределять метод.
Ошибка №5: добавление новых checked-исключений. Переопределённый метод не может выбрасывать новые checked-исключения, которых нет в объявлении базового метода. Если это сделать — компилятор выдаст ошибку.
Ошибка №6: забыли про super. Если в переопределённом методе вы хотите сохранить часть поведения родителя, не забудьте явно вызвать super.methodName(). Java сама этого не сделает.
Теперь вы знаете, как работает переопределение методов, чем оно отличается от перегрузки, и как с его помощью реализуется полиморфизм в Java. В следующей лекции мы рассмотрим, как применять полиморфизм на практике — с коллекциями, массивами и реальными задачами!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ