Вопросы, посвященные ООП — неотъемлемая часть технического интервью на позицию Java-разработчика в ИТ-компанию. В этой статье поговорим об одном из принципов ООП – полиморфизме. Мы остановимся на аспектах, о которых часто спрашивают на собеседованиях, а также приведём небольшие примеры для наглядности.

Что такое полиморфизм?

Полиморфизм – это способность программы идентично использовать объекты с одинаковым интерфейсом без информации о конкретном типе этого объекта. Если вы ответите на вопрос, что такое полиморфизм, таким образом, вас, скорее всего, попросят объяснить, что вы имели ввиду. Лишний раз, не напрашиваясь на кучу дополнительных вопросов, разложите интервьюеру все по полочкам. Начать можно с того, что подход ООП подразумевает построение 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 BreakDankDancer extends Dancer{

    public BreakDankDancer(String name, int age) {
        super(name, age);
    }
// переопределение метода базового класса
    @Override
    public void dance(){
        System.out.println(toString() + "Я танцую брейк-данс!");
    }
}
Пример полиморфизма и использования объектов в программе:
public class Main {

    public static void main(String[] args) {
        Dancer dancer = new Dancer("Антон", 18);

        Dancer breakDanceDancer = new BreakDankDancer("Алексей", 19);// восходящее преобразование к базовому типу
        Dancer electricBoogieDancer = new ElectricBoogieDancer("Игорь", 20); // восходящее преобразование к базовому типу

        List discotheque = Arrays.asList(dancer, breakDanceDancer, electricBoogieDancer);
        for (Dancer d : discotheque) {
            d.dance();// полиморфный вызов метода
        }
    }
}
На коде метода main покажите, что в строках:
Dancer breakDanceDancer = new BreakDankDancer("Алексей", 19);
Dancer electricBoogieDancer = new ElectricBoogieDancer("Игорь", 20);
мы объявили переменную типа суперкласса, а присвоили ей значение одного из наследников. Скорее всего, вас спросят, почему компилятор не будет «ругаться» на несоответствие типов, объявленных слева и справа от знака присваивания, ведь в Java строгая типизация. Поясните, что тут работает восходящее преобразование типов — ссылка на объект интерпретируется, как ссылка на базовый класс. Причем компилятор, встретив в коде такую конструкцию, делает это автоматически и неявно. На основе кода примера можно показать, что тип класса, объявленный слева от знака присваивания Dancer, имеет несколько форм (типов), объявленных справа BreakDankDancer, ElectricBoogieDancer. Каждая из форм может иметь собственное уникальное поведение для общей функциональности, определенной в суперклассе — метод dance. То есть метод, объявленный в суперклассе, может быть по-разному реализован в наследниках. В данном случае мы имеем дело с переопределением метода, а это именно то, что создает многообразие форм (поведений). Увидеть это можно, запустив код метода main на выполнение: Вывод программы Я Антон, мне 18 лет. Я танцую как все. Я Алексей, мне 19 лет. Я танцую брейк-данс! Я Игорь, мне 20 лет. Я танцую электрик буги! Если не использовать переопределение в наследниках, то мы не получим различного поведения. Например, если для наших классов BreakDankDancer и ElectricBoogieDancer закомментировать метод dance, то вывод программы будет таким: Я Антон, мне 18 лет. Я танцую как все. Я Алексей, мне 19 лет. Я танцую как все. Я Игорь, мне 20 лет. Я танцую как все. а это значит, что создавать новые классы BreakDankDancer и ElectricBoogieDancer просто нет смысла. А в чём же, собственно, проявляется принцип полиморфизма? Где спрятано использование объекта в программе без знания о его конкретном типе? В нашем примере — это вызов метода d.dance() на объекте d типа Dancer. Под полиморфизмом Java подразумевается то, что программе необязательно знать какого именно типа будет объект BreakDankDancer или 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 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 строит таблицы в памяти, и в них связывает переменные с их значениями, а объекты — с их методами. Причем если объект наследуется или имплементирует интерфейс, в первую очередь проверяется наличие переопределенных методов в его классе. Если таковые есть, они привязываются к этому типу, если нет – ищется метод, определенный в классе на ступень выше (в родителе) и так вплоть до корня при многоуровневой иерархии. Рассуждая о полиморфизме в ООП и его реализации в программном коде, отметим, что хорошей практикой является использование абстрактных описаний для определения базовых классов с помощью абстрактных классов, а также интерфейсов. Эта практика основана на использовании абстракции — выделении общего поведения и свойств и заключении их в рамки абстрактного класса, или выделении только общего поведения – в таком случае мы создаем интерфейс. Построение и проектирование иерархии объектов на основе интерфейсов и наследовании классов является обязательным условием для выполнения принципа полиморфизма ООП. Касаясь вопроса полиморфизма и нововведений в Java, можно упомянуть, что при создании абстрактных классов и интерфейсов, начиная с Java 8, есть возможность написания дефолтной реализации абстрактных методов в базовых классах с помощью ключевого слова default. Например:
public interface Swim {
    default void swim() {
        System.out.println("Просто плыву");
    }
}
Иногда могут задать вопрос о требованиях к объявлению методов в базовых классах, чтобы не нарушался принцип полиморфизма. Тут все просто: эти методы не должны быть static, private и final. Рrivate делает метод доступным только в классе, и вы не сможете его переопределить в наследнике. Static делает метод достоянием класса, а не объекта, поэтому всегда будет вызываться метод суперкласса. Final же сделает метод неизменяемым и скрытым от наследников.

Что нам даёт полиморфизм?

Вопрос, что дает нам использование полиморфизма скорей всего тоже будет. Тут можно отвечать кратко, особо не лазя в дебри:
  1. Позволяет подменять реализации объектов. На этом основано тестирование.
  2. Обеспечивает расширяемость программы — становится гораздо легче создавать задел на будущее. Добавление новых типов на основе существующих — наиболее частый способ расширения функциональности программ, написанных в ООП стиле.
  3. Позволяет объединять объекты с общим типом или поведением в одну коллекцию или массив и управлять ими единообразно (как в наших примерах, заставляя всех танцевать – метод dance или плыть – метод swim).
  4. Гибкость при создании новых типов: вы можете выбирать реализацию метода из родителя или переопределить его в потомке.

Напутствие в дорогу

Принцип полиморфизма — это очень важная и обширная тема. Она охватывает едва ли не половину ООП и добрую часть основ языка. Отделаться определением этого принципа на интервью не получиться. Незнание или непонимание его, скорее всего, поставит точку на собеседовании. Поэтому не поленитесь проверить свои знания перед испытанием и освежить их в случае необходимости.