Переопределение методов в Java
1. Что такое переопределение методов
В классе Sub объявлен метод go() с точно такой же сигнатурой, как в его суперклассе Base. Пишем Base ob = new Sub(); ob.go(); и ожидаем увидеть «метод из Sub», а в консоли появляется «метод из Base». Метод не переопределён — он скрыт, потому что объявлен как static. Разница между переопределением и сокрытием — один из самых частых вопросов на собеседовании, и разбирается она ниже.
Переопределение методов (method overriding) в Java — это объявление в подклассе метода с тем же именем и тем же списком параметров, что и у метода суперкласса, но с собственной реализацией. Вызов такого метода разрешается во время выполнения по фактическому типу объекта, а не по типу переменной.
Переопределение выполняется только в том случае, если совпадают имя метода и список параметров (сигнатура). Если списки параметров различаются, переопределения не происходит — методы считаются перегружаемыми.
В следующем примере в классе M определён метод print(). В его наследнике — классе N — тоже определён метод print() с такой же сигнатурой, но другим поведением. Это и есть переопределение:
public class M {
public int i;
public int j;
public M(int i, int j) {
this.i = i;
this.j = j;
}
public void print() {
System.out.println("Метод M i = " + i + " j = " + j);
}
} Когда переопределённый метод вызывается из своего подкласса, он всегда ссылается на свой вариант, определённый в подклассе. Вариант метода из суперкласса напрямую по имени больше не доступен — до него можно добраться только через super. Из метода someMethod() будет вызван метод print() того же класса N:
public class N extends M {
public int k;
public N(int i, int j, int k) {
super(i, j);
this.k = k;
}
@Override
public void print() {
System.out.println("Метод N k = " + k);
}
public void someMethod() {
print();
}
} Создадим три объекта и для каждого вызовем метод print():
public class OverrideExample {
public static void main(String[] args) {
M obj1 = new M(7, 8);
obj1.print();
N obj2 = new N(4, 5, 6);
obj2.print();
M obj3 = new N(1, 2, 3);
obj3.print();
}
} Результат выполнения:
Метод M i = 7 j = 8
Метод N k = 6
Метод N k = 3 Первая переменная obj1 типа M указывает на объект того же типа M — ожидаемо вызывается метод класса M. Вторая переменная obj2 типа N указывает на объект N — вызывается метод класса N. Третий вариант самый интересный: переменная obj3 объявлена как M, но указывает на объект N. Вызывается метод класса N.
Ключевое правило
Вариант переопределённого метода выбирает JVM по типу объекта, а не по типу переменной. Тип переменной определяет только то, какие методы вообще можно вызвать (что видит компилятор), а тип объекта — какая реализация выполнится.
Механизм, с помощью которого вызов переопределённого метода разрешается во время выполнения, а не во время компиляции, называется динамической диспетчеризацией методов (late binding, позднее связывание).
Переопределение методов — это одна из форм реализации полиморфизма. Оно позволяет объявить в общем классе методы, которые станут общими для всех производных классов, а в подклассах задать конкретные реализации некоторых или всех этих методов.
2. Правила переопределения методов
Чтобы метод считался переопределённым, а не перегруженным или скрытым, он должен соответствовать набору правил. Компилятор проверяет их все:
| Элемент | Правило при переопределении | Что будет при нарушении |
|---|---|---|
| Имя метода | Должно совпадать с методом суперкласса | Это просто новый метод подкласса |
| Список параметров | Должен совпадать полностью: типы, их порядок и количество | Получится перегрузка (overload), а не переопределение |
| Тип возвращаемого значения | Тот же или ковариантный (подтип исходного) | Ошибка компиляции |
| Модификатор доступа | Можно расширить (protected → public), сузить нельзя | Ошибка компиляции: attempting to assign weaker access privileges |
| Проверяемые исключения | Можно убрать или сузить; добавить новые или более широкие нельзя | Ошибка компиляции |
| Непроверяемые исключения | Можно бросать любые (RuntimeException, Error) | Ограничений нет |
Метод static | Не переопределяется — происходит сокрытие (hiding) | Компилируется, но работает по типу ссылки |
Метод private | Не наследуется, переопределить нельзя | Метод подкласса просто независим |
Метод final | Переопределить нельзя | Ошибка компиляции: cannot override final method |
| Конструкторы | Не наследуются и не переопределяются | Одноимённые конструкторы — это перегрузка |
3. Переопределение и перегрузка: в чём разница
Переопределение (override) и перегрузка (overload) звучат похоже, но это разные механизмы. Различие удобно запомнить по одному критерию: перегрузку разрешает компилятор, переопределение — JVM во время выполнения.
| Критерий | Переопределение (override) | Перегрузка (overload) |
|---|---|---|
| Где объявлены методы | В разных классах, связанных наследованием | Обычно в одном классе |
| Список параметров | Одинаковый | Обязательно разный |
| Тип возвращаемого значения | Тот же или ковариантный | Может быть любым |
| Связывание | Позднее, во время выполнения, по типу объекта | Раннее, на этапе компиляции, по типу выражения |
| Форма полиморфизма | Динамический полиморфизм | Статический полиморфизм |
Аннотация @Override | Применима и рекомендуется | Неприменима — будет ошибка компиляции |
4. Полиморфизм на практике: пример с фигурами
Рассмотрим пример, который показывает, зачем вообще переопределяют методы. Создадим класс Figure, описывающий абстрактную фигуру, и классы-наследники Triangle и Rectangle. Класс Figure содержит метод calculateArea(), подсчитывающий площадь фигуры. У каждой фигуры своя формула, поэтому в классах Triangle и Rectangle метод calculateArea() переопределяется:
public class Figure {
double dimension1;
double dimension2;
public Figure(double dimension1, double dimension2) {
this.dimension1 = dimension1;
this.dimension2 = dimension2;
}
public double calculateArea() {
System.out.println("Площадь фигуры не определена.");
return 0;
}
} public class Rectangle extends Figure {
public Rectangle(double dimension1, double dimension2) {
super(dimension1, dimension2);
}
@Override
public double calculateArea() {
System.out.println("В области четырёхугольника.");
return dimension1 * dimension2;
}
} public class Triangle extends Figure {
public Triangle(double dimension1, double dimension2) {
super(dimension1, dimension2);
}
@Override
public double calculateArea() {
System.out.println("В области треугольника.");
return dimension1 * dimension2 / 2;
}
} Создадим массив типа Figure, который будет содержать объекты типа Figure, Triangle и Rectangle. Подсчитаем площадь для каждого элемента, перебирая массив и вызывая calculateArea(). Нам всё равно, какого типа объект: у каждого есть нужный метод, а JVM с помощью динамической диспетчеризации выбирает подходящую реализацию по реальному типу объекта:
public class FindAreas {
public static void main(String[] args) {
Figure[] figures = new Figure[3];
figures[0] = new Figure(10, 10);
figures[1] = new Rectangle(10, 10);
figures[2] = new Triangle(10, 10);
for (Figure figure : figures) {
double area = figure.calculateArea();
System.out.println(area);
}
}
} Результат выполнения кода:
Площадь фигуры не определена.
0.0
В области четырёхугольника.
100.0
В области треугольника.
50.0 Как это пишут в реальном коде
Заглушка вида «Площадь фигуры не определена» в базовом классе — учебный приём. В рабочем коде Figure обычно объявляют abstract, а calculateArea() — абстрактным методом без тела: тогда компилятор сам заставит каждого наследника дать свою реализацию, и создать бессмысленный объект new Figure(10, 10) будет невозможно.
5. Вызов версии суперкласса через super
Переопределение не обязано полностью заменять поведение родителя. Часто нужно выполнить исходную логику и дополнить её. Для этого из переопределённого метода вызывают версию суперкласса через super.имяМетода():
public class N extends M {
public int k;
public N(int i, int j, int k) {
super(i, j);
this.k = k;
}
@Override
public void print() {
super.print(); // сначала выполнится вариант из класса M
System.out.println("Метод N k = " + k);
}
} Для объекта new N(1, 2, 3) вывод будет таким:
Метод M i = 1 j = 2
Метод N k = 3 Вызов super.print() допустим только внутри самого класса-наследника и только на один уровень вверх: конструкции вида super.super.print() в Java нет.
6. Ковариантный тип возвращаемого значения (методы подставки)
Начиная с Java 5 при переопределении можно указать другой тип возвращаемого значения — но только тип, находящийся ниже в иерархии наследования, чем исходный. Такие типы называют ковариантными, а сами методы в русскоязычной литературе иногда называют методами подставки.
Пусть есть класс Box и его наследник HeavyBox:
public class Box {
double width;
double height;
} public class HeavyBox extends Box {
double weight;
} Класс BoxFactory объявляет метод getInstance(), возвращающий Box. Наследник HeavyBoxFactory переопределяет его и сужает тип возврата до HeavyBox — это допустимо, потому что HeavyBox является подтипом Box:
public class BoxFactory {
Box getInstance() {
return new Box();
}
} public class HeavyBoxFactory extends BoxFactory {
@Override
HeavyBox getInstance() {
return new HeavyBox();
}
} Практическая польза: код, работающий с переменной типа HeavyBoxFactory, получает сразу HeavyBox без приведения типа:
HeavyBoxFactory factory = new HeavyBoxFactory();
HeavyBox box = factory.getInstance(); // приведение типа не нужно Расширить тип возврата нельзя: если бы HeavyBoxFactory.getInstance() возвращал Object, код не скомпилировался бы. Классический пример ковариантного возврата в стандартной библиотеке — метод clone(): в Object он возвращает Object, а в своём классе его обычно переопределяют с возвратом конкретного типа.
7. Переопределение и статические методы
Статические методы не могут быть переопределены. Класс-наследник может объявить метод с такой же сигнатурой, что и суперкласс, но это будет не переопределение, а сокрытие метода (method hiding).
Причина проста: при вызове переопределённого метода JVM выбирает вариант по типу объекта, а вызов статического метода происходит без объекта. Версия вызываемого статического метода всегда определяется на этапе компиляции по типу ссылки, а не по типу объекта, ей присвоенного.
Создадим в суперклассе и наследнике статические методы с одинаковой сигнатурой:
public class Base {
public static void go() {
System.out.println("метод из Base");
}
} public class Sub extends Base {
public static void go() {
System.out.println("метод из Sub");
}
} Попробуем вызвать статический метод через переменную типа Base, которая указывает на объект типа Sub:
public class Runner {
public static void main(String[] args) {
Base ob = new Sub();
ob.go(); // компилятор смотрит на тип переменной -> Base
Sub.go();
}
} Результат выполнения:
метод из Base
метод из Sub Не вызывайте статику через ссылку
Запись ob.go() компилируется, но любая современная IDE подчеркнёт её предупреждением «Static member accessed via instance reference». Вызывайте статический метод через имя класса — Base.go() или Sub.go(): тогда сразу видно, какая версия выполнится, и путаницы с сокрытием не возникнет.
8. Модификаторы доступа, final и private
Методы, объявленные как private, не видит никто, кроме самого класса. Поэтому их наличие или отсутствие никак не отражается на классах-наследниках: наследник может свободно объявить метод с такой же сигнатурой и любым модификатором, и переопределением это считаться не будет. Полагаться на такое совпадение имён — плохой стиль.
Расширять видимость при переопределении разрешено, сужать — нет. Допустимое направление: protected → public, package-private → protected или public. Обратный порядок вызовет ошибку компиляции: иначе нарушился бы принцип подстановки, и код, работающий через ссылку суперкласса, потерял бы доступ к методу.
public class Parent {
protected void show() { }
}
public class Child extends Parent {
@Override
public void show() { } // OK: доступ расширен
}
public class Bad extends Parent {
@Override
private void show() { } // ошибка компиляции: attempting to assign weaker access privileges
} Метод, помеченный как final, переопределить нельзя вообще — это способ явно зафиксировать поведение в иерархии. Аналогично класс, объявленный final, не может иметь наследников, поэтому ни один его метод переопределить невозможно.
9. Аннотация @Override
Аннотация @Override ставится перед методом и сообщает компилятору: «этот метод должен переопределять метод суперкласса или интерфейса». Формально она необязательна, но если метод переопределён неверно — например, в имени опечатка или не совпал список параметров — код не скомпилируется, и ошибка обнаружится сразу, а не во время работы программы.
public class HeavyBoxFactory extends BoxFactory {
@Override
HeavyBox getInstance() {
return new HeavyBox();
}
} Самый показательный случай — переопределение методов класса Object. Классическая ошибка: вместо equals(Object obj) пишут equals(User obj). Это перегрузка, а не переопределение: коллекции продолжат использовать исходный equals из Object, и баг проявится далеко от места объявления. С @Override компилятор укажет на проблему немедленно:
public class User {
private String name;
@Override
public boolean equals(Object obj) { // с @Override ошибку в сигнатуре видно сразу
if (this == obj) return true;
if (!(obj instanceof User)) return false;
return Objects.equals(name, ((User) obj).name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
@Override
public String toString() {
return "User{name='" + name + "'}";
}
} Начиная с Java 6 @Override можно ставить и на методы, реализующие интерфейс, — в Java 5 это считалось ошибкой компиляции.
10. На чём чаще всего ошибаются
Несколько неочевидных моментов, на которых спотыкаются и новички, и опытные разработчики:
- Поля не переопределяются. Если в подклассе объявить поле с тем же именем, оно скроет поле суперкласса. Обращение к полю разрешается по типу ссылки на этапе компиляции: для
M obj = new N(); obj.iбудет прочитано поле изM, даже если объект имеет типN. - Изменение списка параметров — это перегрузка. Метод
print(int k)в наследнике не переопределяетprint()из родителя; оба метода будут существовать параллельно. - Переопределение
equalsбезhashCode. Объекты, равные поequals, обязаны иметь одинаковыйhashCode, иначеHashMapиHashSetначнут терять элементы. - Вызов переопределяемого метода из конструктора. Пока работает конструктор суперкласса, поля подкласса ещё не инициализированы, поэтому переопределённый метод увидит значения по умолчанию (
0,null). Это классический источник трудноуловимых багов. - Попытка расширить список проверяемых исключений. Если метод суперкласса ничего не бросает, переопределяющий метод не может объявить
throws IOException— только непроверяемые исключения.
Часто задаваемые вопросы
Чем переопределение методов отличается от перегрузки?
При переопределении (override) методы находятся в разных классах, связанных наследованием, и имеют одинаковый список параметров; нужная реализация выбирается во время выполнения по типу объекта. При перегрузке (overload) методы имеют одинаковое имя, но разные параметры, и нужный вариант выбирает компилятор ещё до запуска программы. Переопределение — динамический полиморфизм, перегрузка — статический.
Можно ли переопределить static, final или private метод?
Нет. Статический метод с той же сигнатурой в наследнике не переопределяется, а скрывает метод родителя, и выбор версии происходит по типу ссылки на этапе компиляции. Метод final переопределить нельзя — будет ошибка компиляции. Метод private не наследуется, поэтому одноимённый метод в наследнике является самостоятельным методом, а не переопределением. Конструкторы также не переопределяются.
Как вызвать реализацию суперкласса из переопределённого метода?
Через ключевое слово super: например, super.print(). Такой вызов допустим только внутри класса-наследника и поднимается ровно на один уровень иерархии — конструкции super.super.print() в Java не существует. Если нужна логика «прародителя», её выносят в отдельный метод.
Можно ли при переопределении изменить модификатор доступа или список исключений?
Видимость можно только расширить: protected разрешено заменить на public, а сузить до private нельзя — компилятор выдаст ошибку о более слабых правах доступа. Список проверяемых исключений можно сузить или убрать полностью, но не расширить: объявить в переопределяющем методе новое или более широкое проверяемое исключение нельзя. Непроверяемые исключения этим правилом не ограничены.
Обязательно ли писать @Override?
Нет, аннотация необязательна, и без неё переопределение работает так же. Но её стоит ставить всегда: она заставляет компилятор проверить, что метод действительно переопределяет метод суперкласса или интерфейса. Это ловит опечатки в имени и несовпадения параметров — например, случайную перегрузку equals(User obj) вместо equals(Object obj).
Официальная документация: Overriding and Hiding Methods (Oracle Java Tutorials) и JLS 8.4.8. Inheritance, Overriding, and Hiding.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии