Полиморфизм: override и overload
Два похожих по звучанию слова — override и overload — путают почти всех новичков. А ещё этот вопрос почти всегда заканчивается кодом «а что выведет вот это», где ответ неочевиден без понимания позднего связывания.
Вопрос с собеседования: «В чём разница между переопределением (override) и перегрузкой (overload) методов? Что выведет программа, если поле с тем же именем есть и в родительском, и в дочернем классе?»
Оба механизма называют «полиморфизмом», но работают они принципиально по-разному: один определяется во время компиляции, другой — во время выполнения программы. Эта разница и есть суть вопроса.
Что выведет этот код?
class Animal {
String makeSound() {
return "Какой-то звук";
}
String makeSound(String mood) { // overload: тот же метод, другие параметры
return "Звук с настроением: " + mood;
}
}
class Cat extends Animal {
@Override
String makeSound() { // override: та же сигнатура, новое поведение
return "Мяу";
}
}
public class Main {
public static void main(String[] args) {
Animal animal = new Cat(); // тип ссылки Animal, реальный объект — Cat
System.out.println(animal.makeSound());
System.out.println(animal.makeSound("игривое"));
}
}Вывод:
Мяу
Звук с настроением: игривоеПервая строка — override в действии: хотя тип переменной animal — Animal, вызывается версия makeSound() из реального класса объекта, Cat. Вторая строка — overload: это вообще другой метод, с другим набором параметров, унаследованный от Animal без изменений, потому что Cat его не переопределял.
Overload (перегрузка): выбор во время компиляции
Перегрузка — это несколько методов с одинаковым именем, но разным набором параметров (по количеству, типу или порядку) в одном классе. Какой именно метод вызовется, компилятор решает ещё на этапе компиляции, глядя на типы и число переданных аргументов — то есть это статическое связывание (static binding). К полиморфизму в «интересном» смысле overload имеет мало отношения — это скорее удобство именования, а не позднее решение во время выполнения.
class Calculator {
int sum(int a, int b) {
return a + b;
}
double sum(double a, double b) { // отличается типом параметров
return a + b;
}
int sum(int a, int b, int c) { // отличается количеством параметров
return a + b + c;
}
}
public class Main {
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println(calc.sum(2, 3));
System.out.println(calc.sum(2.5, 3.5));
System.out.println(calc.sum(1, 2, 3));
}
}Вывод:
5
6.0
6Компилятор смотрит на аргументы каждого конкретного вызова — (2, 3), (2.5, 3.5), (1, 2, 3) — и ещё до запуска программы решает, какую именно версию sum() подставить. Это и есть статическое связывание: выбор зафиксирован в скомпилированном байт-коде, менять его во время выполнения уже нельзя.
Override (переопределение) и позднее связывание: выбор во время выполнения
Переопределение — когда подкласс задаёт собственную реализацию метода родителя с точно такой же сигнатурой (имя + параметры). А вот какая версия метода выполнится — решается не на этапе компиляции, а в момент вызова, во время выполнения программы. Это называется поздним связыванием (dynamic binding, или динамическая диспетчеризация): JVM смотрит на реальный тип объекта в памяти (а не на тип переменной-ссылки) и вызывает переопределённую версию метода именно этого типа.
Именно поэтому в первом примере Animal animal = new Cat() вызывает "Мяу", хотя переменная объявлена как Animal. Тип переменной — это лишь ограничение на то, какие методы вообще можно вызвать через эту ссылку (компилятор это проверяет). А то, какая реализация метода запустится, — определяет только реальный тип объекта в динамической памяти, известный лишь во время выполнения.
Более сложный случай: иерархия из трёх классов
Разберём вопрос, который на собеседовании задают, чтобы проверить, действительно ли понято позднее связывание, — а не просто заучен один пример.
class Vehicle {
String describe() {
return "Транспортное средство";
}
}
class Car extends Vehicle {
@Override
String describe() {
return "Автомобиль (" + super.describe() + ")"; // вызов родительской версии через super
}
}
class SportsCar extends Car {
@Override
String describe() {
return "Спорткар (" + super.describe() + ")";
}
}
public class Main {
public static void main(String[] args) {
Vehicle vehicle = new SportsCar(); // ссылка Vehicle, реальный объект SportsCar
System.out.println(vehicle.describe());
}
}Вывод:
Спорткар (Автомобиль (Транспортное средство))Даже несмотря на то, что переменная объявлена как Vehicle, вызов describe() уходит на три уровня вглубь до реального типа объекта — SportsCar — и разворачивается наружу через цепочку super.describe(): SportsCar вызывает версию Car, та вызывает версию Vehicle, и результаты складываются в одну строку. Ключевой момент: super.describe() внутри Car вызывает именно родительскую реализацию, а не запускает позднее связывание заново — иначе получилась бы бесконечная рекурсия.
Ловушка: поля НЕ участвуют в позднем связывании
Вот вопрос, который отсеивает тех, кто просто заучил правило «Java всегда вызывает версию из реального класса объекта» — без понимания, что это правило касается только методов.
class Parent {
String label = "родитель"; // поле — НЕ участвует в позднем связывании
}
class Child extends Parent {
String label = "потомок"; // это НЕ override поля, а отдельное поле, которое "прячет" родительское
}
public class Main {
public static void main(String[] args) {
Parent p = new Child(); // ссылка Parent, реальный объект Child
System.out.println(p.label); // какое поле возьмётся?
}
}Вывод:
родительВот и ловушка: для методов результатом было бы «потомок» (позднее связывание по реальному типу объекта), а для полей — «родитель». Поля в Java не переопределяются, а скрываются (field hiding): у объекта на самом деле есть два разных поля label — от Parent и от Child — и то, какое из них видно, определяется типом ссылки, а не реальным типом объекта. Это статическое связывание, точно как у методов с overload, а не позднее связывание, как у override.
Частые ошибки на собеседовании
- Путать overload и override как синонимы. Overload — разные методы с одним именем, выбор на этапе компиляции. Override — одна и та же сигнатура в наследнике, выбор реализации на этапе выполнения.
- Не знать про позднее связывание для родительской ссылки. Многие забывают, что тип переменной (
Animal animal = ...) определяет только доступный набор методов, а не то, какая реализация выполнится. - Считать, что поля работают как методы. Поля скрываются, а не переопределяются — доступ к полю через ссылку родительского типа всегда берёт поле именно этого типа, а не реального объекта.
- Забывать про static-методы. Static-методы тоже можно «переопределить» по сигнатуре в подклассе, но для них работает не override, а field hiding: выбор идёт по типу ссылки, а не по реальному объекту — потому что static-методы не участвуют в динамической диспетчеризации.
Итоги-шпаргалка
- Overload — одинаковое имя, разные параметры, выбор версии на этапе компиляции (статическое связывание).
- Override — одинаковая сигнатура в подклассе, выбор реализации на этапе выполнения по реальному типу объекта (позднее связывание).
- Тип переменной-ссылки ограничивает набор доступных методов, но НЕ определяет, какая реализация метода вызовется — это решает реальный тип объекта.
- Поля (в отличие от методов) не участвуют в позднем связывании — они скрываются, и доступ к ним определяется типом ссылки, а не реальным типом объекта.