Вопросы, посвященные ООП, это неотъемлемая часть технического интервью на позицию Java-разработчика в ИТ-компанию. В этой статье поговорим об одном из принципов ООП, о полиморфизме. Мы остановимся на аспектах, о которых часто спрашивают на собеседованиях, а также приведем небольшие примеры для наглядности.![Собака, кот и корова стоят на одинаковых табличках Animal, но на общий вызов Speak() каждый отвечает по-своему: Woof, Meow и Moo]()
Что такое полиморфизм?
Полиморфизм это способность программы идентично использовать объекты с одинаковым интерфейсом без информации о конкретном типе этого объекта. Проще говоря: вы объявляете переменную типом родительского класса или интерфейса, кладете в нее объект любого наследника и вызываете метод, а какая именно реализация сработает, решается уже во время выполнения, по фактическому типу объекта. Если вы ответите на вопрос, что такое полиморфизм, таким образом, вас, скорее всего, попросят объяснить, что вы имели ввиду. Лишний раз, не напрашиваясь на кучу дополнительных вопросов, разложите интервьюеру все по полочкам.
Кратко
- Полиморфизм это возможность работать с объектами разных классов через один общий тип: переменную объявляют типом родителя или интерфейса, а кладут в нее наследника.
- Такое присваивание называется восходящим преобразованием, и компилятор выполняет его сам, без явного приведения типов.
- Разное поведение появляется только тогда, когда наследники переопределяют метод базового типа. Без переопределения все объекты будут вести себя одинаково.
- Какая именно реализация вызовется, решается во время выполнения программы: это позднее, или динамическое, связывание.
- Полиморфизм работает и через наследование классов (
extends), и через реализацию интерфейсов (implements), причем интерфейсов у класса может быть сколько угодно. - Не участвуют в полиморфизме методы
static,privateиfinal: переопределить их нельзя.

Классы, типы и восходящее преобразование
Начать можно с того, что подход ООП подразумевает построение Java-программы на основе взаимодействии объектов, которые базируются на классах. Классы это заранее написанные чертежи (шаблоны), по которым будут созданы объекты в программе. Причем класс всегда имеет определенный тип, который при хорошем стиле программирования своим названием «подсказывает» о своем предназначении. Далее можно отметить, что поскольку Java относится к строго типизированным языкам, в программном коде всегда нужно указать тип объекта при объявлении переменных. К этому добавьте, что строгая типизация повышает безопасность кода, и надежность программы и позволяет еще на стадии компиляции предотвратить ошибки несовместимости типов (например, попытку разделить строку на число). Естественно, компилятор должен «знать» объявляемый тип: это может быть класс из JDK или созданный нами собственноручно. Обратите внимание интервьюера, что при работе с программным кодом мы можем использовать не только объекты типа, который мы назначили при объявлении, но и его наследников. Это важный момент: мы можем работать со многими типами, как с одним (при условии, что эти типы являются производными от базового типа). Также это значит, что, объявив переменную типа суперкласса, мы можем присвоить ей значение одного из наследников.Пример: танцоры с разным поведением
Интервьюеру понравится, если вы приведете пример. Выберите какой-нибудь объект, который может быть общим (базовым) для группы объектов и унаследуйте от него парочку классов. Базовый класс:
public class Dancer {
private String name;
private int age;
public Dancer(String name, int age) {
this.name = name;
this.age = age;
}
public void dance() {
System.out.println(toString() + "Я танцую как все.");
}
@Override
public String toString() {
return "Я " + name + ", мне " + age + " лет. " ;
}
}
В наследниках переопределите метод базового класса:
public class ElectricBoogieDancer extends Dancer {
public ElectricBoogieDancer(String name, int age) {
super(name, age);
}
// переопределение метода базового класса
@Override
public void dance() {
System.out.println( toString() + "Я танцую электрик буги!");
}
}
public class BreakDanceDancer extends Dancer{
public BreakDanceDancer(String name, int age) {
super(name, age);
}
// переопределение метода базового класса
@Override
public void dance(){
System.out.println(toString() + "Я танцую брейк-данс!");
}
}
Пример полиморфизма в Java и использования объектов в программе:
public class Main {
public static void main(String[] args) {
Dancer dancer = new Dancer("Антон", 18);
Dancer breakDanceDancer = new BreakDanceDancer("Алексей", 19);// восходящее преобразование к базовому типу
Dancer electricBoogieDancer = new ElectricBoogieDancer("Игорь", 20); // восходящее преобразование к базовому типу
List<Dancer> discotheque = Arrays.asList(dancer, breakDanceDancer, electricBoogieDancer);
for (Dancer d : discotheque) {
d.dance();// полиморфный вызов метода
}
}
}
На коде метода main покажите, что в строках:
Dancer breakDanceDancer = new BreakDanceDancer("Алексей", 19);
Dancer electricBoogieDancer = new ElectricBoogieDancer("Игорь", 20);
мы объявили переменную типа суперкласса, а присвоили ей значение одного из наследников. Скорее всего, вас спросят, почему компилятор не будет «ругаться» на несоответствие типов, объявленных слева и справа от знака присваивания, ведь в Java строгая типизация. Поясните, что тут работает восходящее преобразование типов: ссылка на объект интерпретируется, как ссылка на базовый класс. Причем компилятор, встретив в коде такую конструкцию, делает это автоматически и неявно.
На основе кода примера можно показать, что тип класса, объявленный слева от знака присваивания Dancer, имеет несколько форм (типов), объявленных справа BreakDanceDancer, ElectricBoogieDancer. Каждая из форм может иметь собственное уникальное поведение для общей функциональности, определенной в суперклассе, это метод dance. То есть метод, объявленный в суперклассе, может быть по-разному реализован в наследниках. В данном случае мы имеем дело с переопределением метода, а это именно то, что создает многообразие форм (поведений). Увидеть это можно, запустив код метода main на выполнение:
Вывод программы
Я Антон, мне 18 лет. Я танцую как все.
Я Алексей, мне 19 лет. Я танцую брейк-данс!
Я Игорь, мне 20 лет. Я танцую электрик буги!
Если не использовать переопределение в наследниках, то мы не получим различного поведения. Например, если для наших классов BreakDanceDancer и ElectricBoogieDancer закомментировать метод dance, то вывод программы будет таким:
Я Антон, мне 18 лет. Я танцую как все.
Я Алексей, мне 19 лет. Я танцую как все.
Я Игорь, мне 20 лет. Я танцую как все.
а это значит, что создавать новые классы BreakDanceDancer и ElectricBoogieDancer просто нет смысла.
А в чем же, собственно, проявляется принцип полиморфизма Java? Где спрятано использование объекта в программе без знания о его конкретном типе? В нашем примере это вызов метода d.dance() на объекте d типа Dancer. Под полиморфизмом Java подразумевается то, что программе необязательно знать какого именно типа будет объект BreakDanceDancer или ElectricBoogieDancer. Главное, что он потомок класса Dancer.Полиморфизм через интерфейсы
И если рассуждать о потомках, следует заметить, что наследование в Java это не толькоextends, но и implements. Тут самое время вспомнить, что в Java не поддерживается множественное наследование: каждый тип может иметь одного родителя (суперкласс) и неограниченное количество наследников (подклассов). Поэтому для добавления нескольких функциональностей в классы используются интерфейсы.
Интерфейсы уменьшают связанность объектов с родителем по сравнению с наследованием и используются очень широко. В Java интерфейс является ссылочным типом, поэтому в программе может быть объявлен тип переменой типа интерфейса. Здесь самое время привести пример.
Создадим интерфейс:
public interface Swim {
void swim();
}
Для наглядности возьмем разные и не связанные между собой объекты и реализуем в них интерфейс:
public class Human implements Swim {
private String name;
private int age;
public Human(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void swim() {
System.out.println(toString()+" Я плаваю с помощью надувного круга.");
}
@Override
public String toString() {
return "Я " + name + ", мне " + age + " лет. ";
}
}
public class Fish implements Swim{
private String name;
public Fish(String name) {
this.name = name;
}
@Override
public void swim() {
System.out.println("Я рыба " + name + ". Я плыву, двигая плавниками.");
}
}
public class UBoat implements Swim {
private int speed;
public UBoat(int speed) {
this.speed = speed;
}
@Override
public void swim() {
System.out.println("Подводная лодка плывет, вращая винты, со скоростью " + speed + " узлов.");
}
}
Метод main:
public class Main {
public static void main(String[] args) {
Swim human = new Human("Антон", 6);
Swim fish = new Fish("кит");
Swim boat = new UBoat(25);
List<Swim> swimmers = Arrays.asList(human, fish, boat);
for (Swim s : swimmers) {
s.swim();
}
}
}
Результат выполнения полиморфного метода, определенного в интерфейсе, позволяет нам увидеть различия в поведении типов, реализующих этот интерфейс. Они заключаются в разных результатах выполнения метода swim.Позднее связывание: как выбирается нужная реализация
Изучив наш пример, интервьюер может спросить, почему при выполнении кода изmain
for (Swim s : swimmers) {
s.swim();
}
для наших объектов вызываются методы, определенные в этих классах? Каким образом происходит выбор нужной реализации метода при выполнении программы? Чтобы ответить на эти вопросы необходимо рассказать о позднем (динамическом) связывании. Под связыванием понимают установление связи между вызовом метода и его конкретной реализацией, в классах. По сути, определяется код, какого из трех методов, определенных в классах, будет выполнен. В Java по умолчанию используется позднее связывание (на стадии выполнения программы, а не во время компиляции, как в случае с ранним связыванием). Это значит, что при компиляции кода
for (Swim s : swimmers) {
s.swim();
}
компилятор еще не знает, код из какого класса, Human, Fish или UBoat он будет исполнять в методе swim. Это определится только при выполнении программы благодаря механизму динамической диспетчеризации: проверки типа объекта во время выполнения программы и выбора нужной реализации метода для этого типа.
Если вас спросят, как это реализовано, можете ответить, что при загрузке и инициализации объектов JVM строит таблицы в памяти, и в них связывает переменные с их значениями, а объекты с их методами. Так это описано и в руководстве Oracle по полиморфизму. Причем если объект наследуется или имплементирует интерфейс, в первую очередь проверяется наличие переопределенных методов в его классе. Если таковые есть, они привязываются к этому типу, если нет, ищется метод, определенный в классе на ступень выше (в родителе) и так вплоть до корня при многоуровневой иерархии.Абстрактные классы, интерфейсы и default-методы
Рассуждая о полиморфизме в ООП и его реализации в программном коде, отметим, что хорошей практикой является использование абстрактных описаний для определения базовых классов с помощью абстрактных классов, а также интерфейсов. Эта практика основана на использовании абстракции: выделении общего поведения и свойств и заключении их в рамки абстрактного класса, или выделении только общего поведения, и в таком случае мы создаем интерфейс. Построение и проектирование иерархии объектов на основе интерфейсов и наследовании классов является обязательным условием для выполнения принципа полиморфизма ООП. Касаясь вопроса полиморфизма и нововведений в Java, можно упомянуть, что при создании интерфейсов, начиная с Java 8, есть возможность написать реализацию метода прямо в интерфейсе, с помощью ключевого словаdefault.
Например:
public interface Swim {
default void swim() {
System.out.println("Просто плыву");
}
}
Что добавилось после Java 8
С восьмой версии вышло уже три LTS-релиза, и по теме полиморфизма появилось несколько вещей, о которых на собеседовании спрашивают регулярно. Sealed-классы и интерфейсы (Java 17). Обычный класс может наследовать кто угодно, и иерархия разрастается бесконтрольно. Модификаторsealed позволяет перечислить наследников явно, а каждый из них обязан объявить себя final, sealed или non-sealed:
public sealed class Dancer permits ElectricBoogieDancer, BreakDanceDancer {
// тело класса как раньше
}
public final class ElectricBoogieDancer extends Dancer {
// ...
}
public final class BreakDanceDancer extends Dancer {
// ...
}
Полиморфизм при этом никуда не девается, просто набор возможных форм становится закрытым и известным заранее. Это удобно, когда иерархию проектируете вы и не хотите, чтобы ее расширяли снаружи.
Records (Java 16). Record это компактный неизменяемый класс-носитель данных. Наследовать его нельзя, он неявно final, зато реализовывать интерфейсы он может, и полиморфизм через интерфейс работает для него как обычно:
public record Fish(String name) implements Swim {
@Override
public void swim() {
System.out.println("Я рыба " + name + ". Я плыву, двигая плавниками.");
}
}
Сопоставление с образцом в switch (Java 21). Иногда ветвление по типу пишут через switch, а не через полиморфный вызов:
switch (swimmer) {
case Human h -> System.out.println("Плывет человек");
case Fish f -> System.out.println("Плывет рыба");
case UBoat b -> System.out.println("Плывет лодка");
default -> System.out.println("Кто-то плывет");
}
Это не замена полиморфизму, а другой инструмент. Полиморфный вызов уместен, когда поведение принадлежит самому объекту, а switch по типу, когда логика внешняя по отношению к иерархии и держать ее внутри классов неправильно. Особенно хорошо связка работает с sealed-иерархиями: компилятор знает полный список наследников и сам проверит, что вы разобрали все варианты.Какие методы не участвуют в полиморфизме
Иногда могут задать вопрос о требованиях к объявлению методов в базовых классах, чтобы не нарушался принцип полиморфизма. Тут все просто: эти методы не должны быть static, private и final. Private делает метод доступным только в классе, и вы не сможете его переопределить в наследнике. Static делает метод достоянием класса, а не объекта. Переопределить его нельзя: объявив в наследнике метод с той же сигнатурой, вы получите сокрытие (hiding), и какая версия вызовется, определит тип ссылки, а не фактический тип объекта. Final же сделает метод неизменяемым и скрытым от наследников.| Модификатор | Почему метод нельзя переопределить |
|---|---|
private |
метод не виден в наследнике: объявив там метод с тем же именем, вы создадите новый метод, а не переопределение |
static |
метод принадлежит классу, а не объекту, поэтому в наследнике получится сокрытие (hiding), а не переопределение |
final |
модификатор прямо запрещает переопределять метод в наследниках |
Что нам дает полиморфизм в Java?
Вопрос, что дает нам использование полиморфизма скорей всего тоже будет. Тут можно отвечать кратко, особо не лазя в дебри:- Позволяет подменять реализации объектов. На этом основано тестирование.
- Обеспечивает расширяемость программы: становится гораздо легче создавать задел на будущее. Добавление новых типов на основе существующих это наиболее частый способ расширения функциональности программ, написанных в ООП стиле.
- Позволяет объединять объекты с общим типом или поведением в одну коллекцию или массив и управлять ими единообразно (как в наших примерах, заставляя всех танцевать, метод
dance, или плыть, методswim). - Гибкость при создании новых типов: вы можете выбирать реализацию метода из родителя или переопределить его в потомке.
Напутствие в дорогу
Принцип полиморфизма это очень важная и обширная тема. Она охватывает едва ли не половину ООП Java и добрую часть основ языка. Отделаться определением этого принципа на интервью не получится. Незнание или непонимание его, скорее всего, поставит точку на собеседовании. Поэтому не поленитесь проверить свои знания перед испытанием и освежить их в случае необходимости.Вопросы и ответы
Что такое полиморфизм простыми словами?
Это возможность работать с объектами разных классов одинаково, через общий для них тип. Вы объявляете переменную типом родительского класса или интерфейса, кладете в нее объект любого наследника и вызываете метод, не задумываясь, что именно там лежит. Какая конкретная реализация сработает, программа решит сама во время выполнения.Чем полиморфизм отличается от наследования?
Наследование это механизм, а полиморфизм это то, что он дает. Наследование позволяет создать класс на базе другого и переопределить его методы. Полиморфизм появляется тогда, когда вы обращаетесь к объекту наследника через переменную базового типа и получаете поведение наследника. Без наследования или реализации интерфейса полиморфизма не будет, но и само по себе наследование без переопределения методов ничего не даст.Что такое восходящее преобразование типов?
Это присваивание объекта наследника переменной базового типа, напримерDancer d = new ElectricBoogieDancer("Игорь", 20). Компилятор выполняет такое преобразование сам, писать приведение вручную не нужно: любой наследник гарантированно умеет все то же, что и родитель. Обратная операция, нисходящее преобразование, уже требует явного приведения и может закончиться ClassCastException.Чем позднее связывание отличается от раннего?
Связывание это сопоставление вызова метода с его конкретной реализацией. При раннем (статическом) связывании реализация выбирается на этапе компиляции, при позднем (динамическом) во время выполнения, по фактическому типу объекта. В Java обычные методы связываются поздно, и именно поэтому работает полиморфизм. Рано связываются только те методы, которые переопределить нельзя:static, private и final.Почему статический метод нельзя переопределить?
Потому чтоstatic-метод принадлежит классу, а не объекту, и выбирается по типу ссылки на этапе компиляции, а не по фактическому типу объекта. Если объявить в наследнике метод с такой же сигнатурой, получится не переопределение, а сокрытие: через ссылку типа родителя вызовется родительский метод, через ссылку типа наследника метод наследника.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ