Полиморфизм: 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 в действии: хотя тип переменной animalAnimal, вызывается версия 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 — одинаковая сигнатура в подклассе, выбор реализации на этапе выполнения по реальному типу объекта (позднее связывание).
  • Тип переменной-ссылки ограничивает набор доступных методов, но НЕ определяет, какая реализация метода вызовется — это решает реальный тип объекта.
  • Поля (в отличие от методов) не участвуют в позднем связывании — они скрываются, и доступ к ним определяется типом ссылки, а не реальным типом объекта.
Проверьте себя
1. class Animal { String sound() { return "звук"; } } class Dog extends Animal { @Override String sound() { return "гав"; } }. Что выведет System.out.println(new Animal[]{ new Dog() }[0].sound())?
Aзвук
Bгав
CОшибку компиляции
DОшибку во время выполнения (исключение)
2. class Parent { String x = "P"; } class Child extends Parent { String x = "C"; }. Что выведет System.out.println(((Parent) new Child()).x)?
AC
BP
CОшибку компиляции
Dnull